'fct-product-gallery-wrapper',
'thumb' => 'fct-product-gallery-thumb',
'controls' => 'fct-gallery-thumb-controls',
'button' => 'fct-gallery-thumb-control-button',
'main_image' => 'data-fluent-cart-single-product-page-product-thumbnail',
];
public const MARK = 'data-bp3d-fct';
private const ICON = '';
public function register(): void
{
add_filter('render_block_fluent-cart/product-gallery', [$this, 'filterGalleryBlock'], 20, 2);
add_filter('the_content', [$this, 'filterContent'], 1000);
add_filter('do_shortcode_tag', [$this, 'filterFluentShortcode'], 20, 3);
add_filter('pre_do_shortcode_tag', [$this, 'productShortcode'], 10, 3);
// The slot's stylesheet before the gallery paints; shortcode pages get it from apply().
add_action('wp_enqueue_scripts', [$this, 'enqueueEarly'], 20);
}
/** Quick-view modals and REST payloads keep FluentCart's own markup. */
private static function skip(): bool
{
return is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST);
}
private static function currentProductId(): int
{
$product = function_exists('fluent_cart_get_current_product') ? fluent_cart_get_current_product() : null;
return is_object($product) && isset($product->ID) ? (int) $product->ID : 0;
}
public function enqueueEarly(): void
{
if (!is_singular(FluentCart::POST_TYPE)) {
return;
}
$id = (int) get_queried_object_id();
if (ProductModel::hasModel($id) && ProductModel::summary(ProductModel::read($id))['position'] !== 'none') {
FluentCart::enqueue('fluentcart-storefront', FluentCart::FRONT_HANDLE);
}
}
/**
* @param mixed $html
* @param mixed $block
* @return mixed
*/
public function filterGalleryBlock($html, $block = null)
{
if (self::skip() || !is_string($html) || $html === '') {
return $html;
}
$attrs = is_array($block) && isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
$id = ($attrs['query_type'] ?? 'default') !== 'default' ? (int) ($attrs['product_id'] ?? 0) : 0;
return self::apply($html, $id ?: self::currentProductId());
}
/**
* @param mixed $content
* @return mixed
*/
public function filterContent($content)
{
if (self::skip() || !is_string($content) || !is_singular(FluentCart::POST_TYPE)) {
return $content;
}
// Only the product's own content: related products on the same page run the_content too.
$id = (int) get_queried_object_id();
if (get_the_ID() !== $id) {
return $content;
}
return self::apply($content, $id);
}
/**
* @param mixed $output
* @param mixed $tag
* @param mixed $attr
* @return mixed
*/
public function filterFluentShortcode($output, $tag, $attr = [])
{
if ($tag !== 'fluent_cart_single_product' || self::skip() || !is_string($output)) {
return $output;
}
// FluentCart accepts id, product_id and productId; WordPress lowercases attribute names.
$id = is_array($attr) ? (int) ($attr['id'] ?? $attr['product_id'] ?? $attr['productid'] ?? 0) : 0;
return self::apply($output, $id ?: self::currentProductId());
}
/**
* `[3d_viewer_product id=N]` for a FluentCart ID; the core shortcode only answers Woo products.
*
* @param mixed $return
* @param mixed $tag
* @param mixed $attr
* @return mixed
*/
public function productShortcode($return, $tag, $attr = [])
{
if ($tag !== '3d_viewer_product' || $return !== false) {
return $return;
}
$id = is_array($attr) && isset($attr['id']) ? (int) $attr['id'] : (int) get_the_ID();
// A draft, private or password-protected product shows only to someone who may read it, as viewers do.
if (!ProductModel::isProduct($id) || !\BP3D\Shortcode\Shortcode::isViewable($id)) {
return $return;
}
return ProductModel::html($id, 'shortcode');
}
/** One product's viewer into one HTML fragment. A no-op without a model, with position `none`, or without a gallery. */
public static function apply(string $html, int $id): string
{
if ($id <= 0 || strpos($html, self::MARK) !== false || !ProductModel::isProduct($id) || !ProductModel::hasModel($id)) {
return $html;
}
$summary = ProductModel::summary(ProductModel::read($id));
if ($summary['position'] === 'none') {
return $html;
}
if (strpos($html, self::SELECTORS['wrapper']) === false) {
return $html;
}
$viewer = ProductModel::html($id, 'shortcode');
if ($viewer === '') {
return $html;
}
$active = $summary['position'] === 'thumb' && $summary['showFirst'];
$out = self::inject($html, $summary['position'], $viewer, self::thumbButton($summary['poster'], $active), $active);
if ($out === $html) {
FluentCart::debugLog("product {$id}: gallery markup not recognised, viewer not placed");
return $html;
}
FluentCart::enqueue('fluentcart-storefront', FluentCart::FRONT_HANDLE);
return $out;
}
public static function thumbButton(string $poster, bool $active): string
{
$inner = $poster !== ''
? ''
: self::ICON;
return '';
}
/**
* Pure: place `$viewer` relative to the first `.fct-product-gallery-wrapper` in `$html`.
* Unchanged input means the markup was not recognised.
*/
public static function inject(string $html, string $position, string $viewer, string $thumb, bool $showFirst): string
{
$open = self::classTag($html, 'div', self::SELECTORS['wrapper']);
if ($open === null) {
return $html;
}
[$start, $openEnd] = $open;
$end = self::elementEnd($html, $openEnd);
if ($end === null) {
return $html;
}
$gallery = substr($html, $start, $end - $start);
switch ($position) {
case 'top':
$replacement = '