Home / Admin / Add Custom Fields to Products.php
Duplicate Snippet

Embed Snippet on Your Site

Add Custom Fields to Products.php

Custom Fields

Code Preview
php
<?php
/**
 * Merchatura – Simple per-product custom fields (v4.2) ✅ Conditional Logic added
 *
 * Adds per-field conditional rules that work:
 * - In admin (inline rule builder under each field)
 * - On the product page (hide/show live)
 * - In validation + cart/order meta (hidden fields are ignored)
 *
 * Conditional rule format (per field):
 *   {Show/Hide} if {attribute or field} {is / is not / contains} {value}
 * Special case:
 *   If the source is a File Upload field -> operator becomes:
 *     {has file uploaded / doesn't have file uploaded}
 *
 * Notes:
 * - Multiple rules per field are supported.
 * - Rules are ANDed together (all must match).
 */
if ( ! defined( 'ABSPATH' ) ) exit;
const MERCHATURA_FIELDS_META_KEY_V4 = '_merchatura_custom_fields_v4';
/* =========================================================
 * Admin: Product data tab
 * ========================================================= */
add_filter( 'woocommerce_product_data_tabs', function( $tabs ) {
    $tabs['merchatura_custom_fields'] = array(
        'label'    => __( 'Custom Fields', 'merchatura' ),
        'target'   => 'merchatura_custom_fields_panel',
        'class'    => array(),
        'priority' => 80,
    );
    return $tabs;
} );
/* =========================================================
 * Admin: Render panel shell
 * ========================================================= */
add_action( 'woocommerce_product_data_panels', function() {
    ?>
    <div id="merchatura_custom_fields_panel" class="panel woocommerce_options_panel hidden">
        <div class="options_group">
            <p style="margin:0 0 12px;">
                <strong><?php esc_html_e( 'Custom Fields', 'merchatura' ); ?></strong><br>
                <?php esc_html_e( 'Add fields shown on the product page and saved with the order.', 'merchatura' ); ?>
            </p>
            <div id="merchatura-cf-accordion" data-initial="[]"></div>
            <p style="margin:12px 0 0;">
                <button type="button" class="button button-primary" id="merchatura-cf-add">
                    <?php esc_html_e( 'Add Field', 'merchatura' ); ?>
                </button>
            </p>
            <input type="hidden" id="merchatura_cf_json" name="merchatura_cf_json" value="" />
        </div>
    </div>
    <style>
        #merchatura-cf-accordion { margin-top: 10px; }
        .merchatura-cf-item { border: 1px solid #e5e5e5; border-radius: 10px; background: #fff; margin: 10px 0; overflow: hidden; }
        .merchatura-cf-head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding: 12px 14px; background:#f8f8f8; }
        .merchatura-cf-title { font-weight: 600; }
        .merchatura-cf-subtitle { color:#666; font-size: 12px; margin-top:2px; }
        .merchatura-cf-actions { display:flex; gap:10px; align-items:center; }
        .merchatura-cf-actions button { border:0; background:transparent; padding:4px; cursor:pointer; }
        .merchatura-cf-actions .dashicons { font-size:18px; width:18px; height:18px; }
        .merchatura-cf-body { padding: 14px; display:none; }
        .merchatura-cf-body.open { display:block; }
        .merchatura-cf-field { margin: 0 0 12px; }
        .merchatura-cf-field label { display:block; font-weight:600; margin: 0 0 6px; }
        .merchatura-cf-field input[type="text"],
        .merchatura-cf-field select,
        .merchatura-cf-field textarea { width: 100%; max-width: 100%; }
        #merchatura_custom_fields_panel .options_group { padding-right: 12px; }
        /* Condition builder */
        .mcf-conds { border-top: 1px solid #eee; padding-top: 12px; margin-top: 12px; }
        .mcf-conds h4 { margin: 0 0 8px; font-size: 13px; }
        .mcf-cond-row {
            display: grid;
            grid-template-columns: 110px 1fr 120px 1fr 34px;
            gap: 8px;
            align-items: center;
            margin-bottom: 8px;
        }
        .mcf-cond-row select, .mcf-cond-row input[type="text"] { width: 100%; }
        .mcf-cond-row button { border:0; background:#f5f5f5; border-radius:6px; cursor:pointer; height: 32px; }
        .mcf-cond-row button:hover { background:#eee; }
        .mcf-cond-add { margin-top: 6px; }
        @media (max-width: 1100px){
            .mcf-cond-row { grid-template-columns: 1fr; }
            .mcf-cond-row button { width: 44px; }
        }
		/* ✅ Fix WooCommerce panel floats interfering with our layout */
#merchatura_custom_fields_panel .merchatura-cf-field label,
#merchatura_custom_fields_panel .mcf-conds h4{
  float: none !important;
  width: auto !important;
  clear: both !important;
  display: block !important;
}
#merchatura_custom_fields_panel .mcf-conds{
  clear: both !important;
  float: none !important;
  width: 100% !important;
}
#merchatura_custom_fields_panel .mcf-conds-list{
  clear: both !important;
  width: 100% !important;
}
#merchatura_custom_fields_panel .mcf-cond-add{
  float: none !important;
  display: inline-block !important;
}
/* ✅ Prevent grid children from forcing overflow */
#merchatura_custom_fields_panel .mcf-cond-row{
  width: 100%;
  grid-template-columns: 110px minmax(0, 1fr) 120px minmax(0, 1fr) 34px;
}
#merchatura_custom_fields_panel .mcf-cond-row > *{
  min-width: 0 !important;
}
    </style>
    <?php
} );
/* =========================================================
 * Helpers: Admin sources (attributes + existing fields)
 * ========================================================= */
function merchatura_v42_admin_attribute_sources( $product_id ) {
    $out = array();
    $product = wc_get_product( $product_id );
    if ( ! $product ) return $out;
    $attributes = $product->get_attributes();
    foreach ( $attributes as $attr ) {
        if ( ! is_a( $attr, 'WC_Product_Attribute' ) ) continue;
        $name = $attr->get_name(); // e.g. pa_color OR custom name
        $is_tax = $attr->is_taxonomy();
        $label = $is_tax ? wc_attribute_label( $name ) : $name;
        $key = $is_tax ? 'attr_' . wc_sanitize_taxonomy_name( $name ) : 'attr_' . sanitize_key( $name );
        // options
        $opts = array();
        if ( $is_tax ) {
            $terms = wc_get_product_terms( $product_id, $name, array( 'fields' => 'names' ) );
            if ( is_array( $terms ) ) $opts = $terms;
        } else {
            $raw = $attr->get_options();
            foreach ( $raw as $r ) $opts[] = (string) $r;
        }
        $out[] = array(
            'id'      => $key, // matches frontend lookup logic below
            'label'   => 'Attribute: ' . $label,
            'type'    => 'select', // treated as dropdown (values list)
            'options' => array_values( array_unique( array_filter( array_map( 'trim', $opts ) ) ) ),
        );
    }
    return $out;
}
/* =========================================================
 * Admin: JS (product edit only)
 * ========================================================= */
add_action( 'admin_footer', function() {
    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    if ( ! $screen || $screen->id !== 'product' ) return;
    global $post;
    if ( ! $post || $post->post_type !== 'product' ) return;
    $fields = get_post_meta( $post->ID, MERCHATURA_FIELDS_META_KEY_V4, true );
    if ( ! is_array( $fields ) ) $fields = array();
    $attr_sources = merchatura_v42_admin_attribute_sources( $post->ID );
    ?>
    <script>
    (function(){
        const acc    = document.getElementById('merchatura-cf-accordion');
        const addBtn = document.getElementById('merchatura-cf-add');
        const hidden = document.getElementById('merchatura_cf_json');
        if(!acc || !addBtn || !hidden) return;
        const initial = <?php echo wp_json_encode( $fields ); ?>;
        const ATTR_SOURCES = <?php echo wp_json_encode( $attr_sources ); ?>;
        const TYPES = [
            {v:'text', t:'Text'},
            {v:'textarea', t:'Textarea'},
            {v:'email', t:'Email'},
            {v:'select', t:'Dropdown'},
            {v:'radio', t:'Radio'},
            {v:'file', t:'File upload'}
        ];
        function typeLabel(v){ const f = TYPES.find(x=>x.v===v); return f ? f.t : v; }
        function escapeHtml(s){ return (s||'').toString().replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'&quot;',"'":'&#039;'}[m])); }
        function escapeAttr(s){ return escapeHtml(s); }
        // Build a map of "sources" available for conditions:
        // - attributes
        // - all custom fields (by key)
        function buildSources(){
            const fieldSources = state
                .filter(f => (f.key||'').trim() && (f.name||'').trim())
                .map(f => ({
                    id: 'field_' + (f.key||'').trim(),
                    label: 'Field: ' + (f.name||'').trim(),
                    type: f.type || 'text',
                    options: (f.type==='select' || f.type==='radio')
                        ? (String(f.box3||'').replace(/\r\n/g,"\n").split("\n").map(x=>x.trim()).filter(Boolean))
                        : []
                }));
            // Merge, with attrs first
            return [...ATTR_SOURCES, ...fieldSources];
        }
        function getSourceById(id){
            const srcs = buildSources();
            return srcs.find(s => s.id === id) || null;
        }
        let state = Array.isArray(initial) ? initial.map(f => ({
            type: f.type || 'text',
            name: f.name || '',
            box3: f.box3 || '',
            required: (f.required === 'yes') ? 'yes' : 'no',
            info: f.info || '',
            key: f.key || '',
            conditions: Array.isArray(f.conditions) ? f.conditions.map(c => ({
                action: (c.action === 'hide') ? 'hide' : 'show',
                source: c.source || '',
                op: (c.op === 'is_not' || c.op === 'contains' || c.op === 'has_file' || c.op === 'no_file') ? c.op : 'is',
                value: (c.value ?? '').toString()
            })) : [],
            _open: false
        })) : [];
        function serializeOnly(){
            hidden.value = JSON.stringify(state.map(f => ({
                type: f.type || 'text',
                name: (f.name||'').trim(),
                box3: (f.box3||'').toString(),
                required: (f.required === 'yes') ? 'yes' : 'no',
                info: (f.info||'').toString(),
                key: (f.key||'').trim(),
                conditions: Array.isArray(f.conditions) ? f.conditions.map(c => ({
                    action: (c.action === 'hide') ? 'hide' : 'show',
                    source: (c.source||'').toString(),
                    op: (c.op||'').toString(),
                    value: (c.value ?? '').toString()
                })) : []
            })));
        }
        function updateHeaderInline(idx){
            const item = acc.querySelector(`.merchatura-cf-item[data-idx="${idx}"]`);
            if(!item) return;
            const titleEl = item.querySelector('.merchatura-cf-title');
            const subEl   = item.querySelector('.merchatura-cf-subtitle');
            const title = (state[idx].name && state[idx].name.trim()) ? state[idx].name.trim() : `Field ${idx+1}`;
            const subtitle = `${typeLabel(state[idx].type)} · Required: ${state[idx].required === 'yes' ? 'Yes' : 'No'}`;
            if(titleEl) titleEl.textContent = title;
            if(subEl) subEl.textContent = subtitle;
        }
        function renderConditionRow(idx, cIdx, c){
            const sources = buildSources();
            const sourceOptions = [
                `<option value="">Select field or attribute…</option>`,
                ...sources.map(s => `<option value="${escapeAttr(s.id)}" ${s.id===c.source?'selected':''}>${escapeHtml(s.label)}</option>`)
            ].join('');
            const src = c.source ? getSourceById(c.source) : null;
            const isFileSource = src && src.type === 'file';
            const hasOptions = src && (src.type === 'select' || src.type === 'radio' || src.id.startsWith('attr_')) && Array.isArray(src.options) && src.options.length;
            let opOptions = '';
            if(isFileSource){
                opOptions = `
                    <option value="has_file" ${c.op==='has_file'?'selected':''}>has file uploaded</option>
                    <option value="no_file"  ${c.op==='no_file'?'selected':''}>doesn't have file uploaded</option>
                `;
            } else {
                opOptions = `
                    <option value="is"      ${c.op==='is'?'selected':''}>is</option>
                    <option value="is_not"  ${c.op==='is_not'?'selected':''}>is not</option>
                    <option value="contains"${c.op==='contains'?'selected':''}>contains</option>
                `;
            }
            let valueControl = '';
            if(isFileSource){
                valueControl = `<input type="text" class="mcf-cond-value" value="" placeholder="(not used)" disabled>`;
            } else if(hasOptions){
                const opts = src.options.map(o => `<option value="${escapeAttr(o)}" ${o===c.value?'selected':''}>${escapeHtml(o)}</option>`).join('');
                valueControl = `<select class="mcf-cond-value"><option value="">Select…</option>${opts}</select>`;
            } else {
                valueControl = `<input type="text" class="mcf-cond-value" value="${escapeAttr(c.value||'')}" placeholder="Value…">`;
            }
            return `
                <div class="mcf-cond-row" data-cidx="${cIdx}">
                    <select class="mcf-cond-action">
                        <option value="show" ${c.action==='show'?'selected':''}>Show</option>
                        <option value="hide" ${c.action==='hide'?'selected':''}>Hide</option>
                    </select>
                    <select class="mcf-cond-source">${sourceOptions}</select>
                    <select class="mcf-cond-op">${opOptions}</select>
                    ${valueControl}
                    <button type="button" class="mcf-cond-del" title="Remove">✕</button>
                </div>
            `;
        }
        function render(){
            serializeOnly();
            acc.innerHTML = state.map((f, idx) => {
                const title = (f.name && f.name.trim()) ? f.name.trim() : `Field ${idx+1}`;
                const subtitle = `${typeLabel(f.type)} · Required: ${f.required === 'yes' ? 'Yes' : 'No'}`;
                const typeOptions = TYPES.map(t => `<option value="${t.v}" ${t.v===f.type?'selected':''}>${t.t}</option>`).join('');
                let box3Label = 'Placeholder';
                let box3Placeholder = 'e.g. John Smith';
                if (f.type === 'select' || f.type === 'radio') {
                    box3Label = 'Options (one per line)';
                    box3Placeholder = "Option 1\nOption 2\nOption 3";
                }
                if (f.type === 'file') {
                    box3Label = 'Allowed file extensions (optional)';
                    box3Placeholder = 'pdf, jpg, png (leave blank for any)';
                }
                const condHtml = (Array.isArray(f.conditions) && f.conditions.length)
                    ? f.conditions.map((c, cIdx) => renderConditionRow(idx, cIdx, c)).join('')
                    : '';
                return `
                    <div class="merchatura-cf-item" data-idx="${idx}">
                        <div class="merchatura-cf-head">
                            <div>
                                <div class="merchatura-cf-title">${escapeHtml(title)}</div>
                                <div class="merchatura-cf-subtitle">${escapeHtml(subtitle)}</div>
                            </div>
                            <div class="merchatura-cf-actions">
                                <button type="button" class="merchatura-cf-moveup" title="Move up"><span class="dashicons dashicons-arrow-up-alt2"></span></button>
                                <button type="button" class="merchatura-cf-movedown" title="Move down"><span class="dashicons dashicons-arrow-down-alt2"></span></button>
                                <button type="button" class="merchatura-cf-edit" title="Edit"><span class="dashicons dashicons-edit"></span></button>
                                <button type="button" class="merchatura-cf-copy" title="Copy"><span class="dashicons dashicons-admin-page"></span></button>
                                <button type="button" class="merchatura-cf-delete" title="Delete"><span class="dashicons dashicons-trash"></span></button>
                            </div>
                        </div>
                        <div class="merchatura-cf-body ${f._open ? 'open' : ''}">
                            <div class="merchatura-cf-field">
                                <label>Field Type</label>
                                <select class="merchatura-cf-type">${typeOptions}</select>
                            </div>
                            <div class="merchatura-cf-field">
                                <label>${escapeHtml(f.type === 'file' ? 'Uploader Label' : 'Field Name')}</label>
                                <input type="text" class="merchatura-cf-name" value="${escapeAttr(f.name||'')}" placeholder="e.g. Name">
                            </div>
                            <div class="merchatura-cf-field">
                                <label>${escapeHtml(box3Label)}</label>
                                <textarea class="merchatura-cf-box3" rows="3" placeholder="${escapeAttr(box3Placeholder)}">${escapeHtml(f.box3||'')}</textarea>
                            </div>
                            <div class="merchatura-cf-field">
                                <label>Info tooltip (optional)</label>
                                <textarea class="merchatura-cf-info" rows="2" placeholder="Any extra explanation shown to customers">${escapeHtml(f.info||'')}</textarea>
                            </div>
                            <div class="merchatura-cf-field">
                                <label>Required</label>
                                <select class="merchatura-cf-required">
                                    <option value="no" ${f.required==='no'?'selected':''}>No</option>
                                    <option value="yes" ${f.required==='yes'?'selected':''}>Yes</option>
                                </select>
                            </div>
                            <div class="mcf-conds">
                                <h4>Conditional Rules</h4>
                                <div class="mcf-conds-list">
                                    ${condHtml || '<div class="mcf-conds-empty" style="color:#666;font-size:12px;">No rules yet.</div>'}
                                </div>
                                <button type="button" class="button mcf-cond-add">Add rule</button>
                                <div style="margin-top:6px;color:#666;font-size:12px;">
                                    Rules are checked using AND logic (all must match).
                                </div>
                            </div>
                        </div>
                    </div>
                `;
            }).join('');
            bind();
        }
        function bind(){
            acc.querySelectorAll('.merchatura-cf-edit').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    e.preventDefault();
                    const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10);
                    state[idx]._open = !state[idx]._open;
                    render();
                });
            });
            acc.querySelectorAll('.merchatura-cf-copy').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    e.preventDefault();
                    const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10);
                    const clone = JSON.parse(JSON.stringify(state[idx]));
                    clone._open = false;
                    clone.key = '';
                    state.splice(idx+1, 0, clone);
                    render();
                });
            });
            acc.querySelectorAll('.merchatura-cf-delete').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    e.preventDefault();
                    const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10);
                    state.splice(idx, 1);
                    render();
                });
            });
            acc.querySelectorAll('.merchatura-cf-moveup').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    e.preventDefault();
                    const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10);
                    if (idx <= 0) return;
                    const tmp = state[idx-1];
                    state[idx-1] = state[idx];
                    state[idx] = tmp;
                    render();
                });
            });
            acc.querySelectorAll('.merchatura-cf-movedown').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    e.preventDefault();
                    const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10);
                    if (idx >= state.length - 1) return;
                    const tmp = state[idx+1];
                    state[idx+1] = state[idx];
                    state[idx] = tmp;
                    render();
                });
            });
            // Field inputs
            acc.querySelectorAll('.merchatura-cf-item').forEach(item => {
                const idx = parseInt(item.dataset.idx, 10);
                const type = item.querySelector('.merchatura-cf-type');
                const name = item.querySelector('.merchatura-cf-name');
                const box3 = item.querySelector('.merchatura-cf-box3');
                const info = item.querySelector('.merchatura-cf-info');
                const req  = item.querySelector('.merchatura-cf-required');
                if(type) type.addEventListener('change', () => { state[idx].type = type.value; serializeOnly(); render(); });
                if(req) req.addEventListener('change', () => {
                    state[idx].required = req.value;
                    serializeOnly();
                    updateHeaderInline(idx);
                });
                if(name) name.addEventListener('input', () => {
                    state[idx].name = name.value;
                    serializeOnly();
                    updateHeaderInline(idx);
                });
                if(box3) box3.addEventListener('input', () => { state[idx].box3 = box3.value; serializeOnly(); });
                if(info) info.addEventListener('input', () => { state[idx].info = info.value; serializeOnly(); });
                // Add rule
                const addRule = item.querySelector('.mcf-cond-add');
                if(addRule){
                    addRule.addEventListener('click', (e) => {
                        e.preventDefault();
                        state[idx].conditions = Array.isArray(state[idx].conditions) ? state[idx].conditions : [];
                        state[idx].conditions.push({ action:'show', source:'', op:'is', value:'' });
                        render();
                    });
                }
                // Bind condition rows
                item.querySelectorAll('.mcf-cond-row').forEach(row => {
                    const cIdx = parseInt(row.getAttribute('data-cidx'), 10);
                    const action = row.querySelector('.mcf-cond-action');
                    const source = row.querySelector('.mcf-cond-source');
                    const op     = row.querySelector('.mcf-cond-op');
                    const valEl  = row.querySelector('.mcf-cond-value');
                    const del    = row.querySelector('.mcf-cond-del');
                    function sync(){
                        const c = state[idx].conditions[cIdx];
                        if(!c) return;
                        if(action) c.action = action.value === 'hide' ? 'hide' : 'show';
                        if(source) c.source = source.value || '';
                        if(op)     c.op = op.value || 'is';
                        if(valEl && !valEl.disabled){
                            c.value = (valEl.value ?? '').toString();
                        } else {
                            c.value = '';
                        }
                        serializeOnly();
                    }
                    if(action) action.addEventListener('change', sync);
                    if(source) source.addEventListener('change', function(){
                        // when source changes, re-render the row so operator/value UI adapts (file / dropdown / text)
                        const c = state[idx].conditions[cIdx];
                        if(!c) return;
                        c.source = source.value || '';
                        // default operator depending on source type
                        const src = c.source ? getSourceById(c.source) : null;
                        if(src && src.type === 'file'){
                            c.op = 'has_file';
                            c.value = '';
                        } else {
                            c.op = 'is';
                            c.value = '';
                        }
                        render();
                    });
                    if(op) op.addEventListener('change', function(){
                        const c = state[idx].conditions[cIdx];
                        if(!c) return;
                        c.op = op.value || 'is';
                        serializeOnly();
                    });
                    if(valEl) valEl.addEventListener('input', sync);
                    if(valEl) valEl.addEventListener('change', sync);
                    if(del) del.addEventListener('click', (e) => {
                        e.preventDefault();
                        state[idx].conditions.splice(cIdx, 1);
                        render();
                    });
                });
            });
        }
        addBtn.addEventListener('click', (e) => {
            e.preventDefault();
            state.push({ type:'text', name:'', box3:'', required:'no', info:'', key:'', conditions:[], _open:false });
            render();
        });
        render();
    })();
    </script>
    <?php
} );
/* =========================================================
 * Admin: Save meta (fields + conditions)
 * ========================================================= */
add_action( 'woocommerce_admin_process_product_object', function( $product ) {
    if ( ! isset( $_POST['merchatura_cf_json'] ) ) return;
    $decoded = json_decode( wp_unslash($_POST['merchatura_cf_json']), true );
    if ( ! is_array($decoded) ) {
        $product->update_meta_data( MERCHATURA_FIELDS_META_KEY_V4, array() );
        return;
    }
    $allowed = array( 'text','textarea','email','select','radio','file' );
    $allowed_ops = array( 'is','is_not','contains','has_file','no_file' );
    $allowed_actions = array( 'show','hide' );
    $clean = array();
    $seen = array();
    foreach ( $decoded as $f ) {
        $type = isset($f['type']) ? sanitize_key($f['type']) : 'text';
        if ( ! in_array($type, $allowed, true) ) $type = 'text';
        $name = isset($f['name']) ? sanitize_text_field($f['name']) : '';
        if ( $name === '' ) continue;
        $key = isset($f['key']) ? sanitize_key($f['key']) : '';
        if ( $key === '' ) $key = sanitize_key( strtolower( preg_replace('/\s+/', '_', $name ) ) );
        if ( $key === '' ) continue;
        $base = $key; $i=2;
        while ( isset($seen[$key]) ) { $key = $base . '_' . $i; $i++; }
        $seen[$key] = true;
        $box3 = isset($f['box3']) ? trim((string)$f['box3']) : '';
        $info = isset($f['info']) ? trim((string)$f['info']) : '';
        $required = ( isset($f['required']) && $f['required'] === 'yes' ) ? 'yes' : 'no';
        if ( $type === 'select' || $type === 'radio' ) {
            $box3 = str_replace( array("\r\n","\r"), "\n", $box3 );
            $lines = array_filter( array_map('trim', explode("\n", $box3)) );
            $box3 = implode("\n", $lines );
        }
        if ( $type === 'file' ) {
            $box3 = strtolower($box3);
            $box3 = preg_replace('/[^a-z0-9,\s]/', '', $box3);
            $box3 = preg_replace('/\s+/', '', $box3);
        }
        // Conditions
        $conds_clean = array();
        if ( isset($f['conditions']) && is_array($f['conditions']) ) {
            foreach ( $f['conditions'] as $c ) {
                $action = isset($c['action']) ? sanitize_key($c['action']) : 'show';
                if ( ! in_array($action, $allowed_actions, true) ) $action = 'show';
                $source = isset($c['source']) ? sanitize_text_field($c['source']) : '';
                $source = trim($source);
                $op = isset($c['op']) ? sanitize_key($c['op']) : 'is';
                if ( ! in_array($op, $allowed_ops, true) ) $op = 'is';
                $value = isset($c['value']) ? sanitize_text_field($c['value']) : '';
                $value = trim($value);
                // allow "file operators" without value
                if ( $op === 'has_file' || $op === 'no_file' ) $value = '';
                // if no source, skip
                if ( $source === '' ) continue;
                $conds_clean[] = array(
                    'action' => $action,
                    'source' => $source,
                    'op'     => $op,
                    'value'  => $value,
                );
            }
        }
        $clean[] = array(
            'type'       => $type,
            'name'       => $name,
            'box3'       => $box3,
            'required'   => $required,
            'info'       => $info,
            'key'        => $key,
            'conditions' => $conds_clean,
        );
    }
    $product->update_meta_data( MERCHATURA_FIELDS_META_KEY_V4, $clean );
} );
/* =========================================================
 * Helpers
 * ========================================================= */
function merchatura_v41_get_fields( $product_id ) {
    $fields = get_post_meta( $product_id, MERCHATURA_FIELDS_META_KEY_V4, true );
    return is_array($fields) ? $fields : array();
}
function merchatura_v42_get_attr_post_value( $product_id, $source_id ) {
    // source_id looks like "attr_pa_color" OR "attr_custom"
    // match it to posted attribute_*
    $product = wc_get_product( $product_id );
    if ( ! $product ) return '';
    $attributes = $product->get_attributes();
    foreach ( $attributes as $attr ) {
        if ( ! is_a( $attr, 'WC_Product_Attribute' ) ) continue;
        $name = $attr->get_name();
        $expected = $attr->is_taxonomy()
            ? 'attr_' . wc_sanitize_taxonomy_name( $name )
            : 'attr_' . sanitize_key( $name );
        if ( $expected !== $source_id ) continue;
        // Posted field name:
        $posted_name = $attr->is_taxonomy()
            ? 'attribute_' . wc_sanitize_taxonomy_name( $name ) // e.g. attribute_pa_color
            : 'attribute_' . sanitize_key( $name );
        $val = isset($_POST[$posted_name]) ? (string) wp_unslash($_POST[$posted_name]) : '';
        $val = trim($val);
        // If the post value is a slug for taxonomy terms, Woo will post the term slug.
        // But in our admin we stored term NAMES for options. Many sites use names; some use slugs.
        // We'll attempt to compare both by also translating slug->name where possible.
        if ( $val !== '' && $attr->is_taxonomy() ) {
            $term = get_term_by( 'slug', $val, $name );
            if ( $term && ! is_wp_error($term) && ! empty($term->name) ) {
                // Return both possibilities via a marker; evaluation will check exact and contains against the returned string,
                // so we return the NAME primarily.
                return (string) $term->name;
            }
        }
        return $val;
    }
    return '';
}
function merchatura_v42_condition_matches( $product_id, $all_fields, $cond ) {
    $source = isset($cond['source']) ? (string) $cond['source'] : '';
    $op     = isset($cond['op']) ? (string) $cond['op'] : 'is';
    $value  = isset($cond['value']) ? (string) $cond['value'] : '';
    $source = trim($source);
    $value  = trim($value);
    // Source can be:
    // - attr_*  (product attribute)
    // - field_* (custom field key)
    if ( strpos($source, 'attr_') === 0 ) {
        $left = merchatura_v42_get_attr_post_value( $product_id, $source );
    } elseif ( strpos($source, 'field_') === 0 ) {
        $key = substr($source, 6);
        $field_name = 'merchatura_cf_' . sanitize_key($key);
        // Find the field type (for file ops)
        $srcType = 'text';
        foreach ( $all_fields as $f ) {
            if ( ! empty($f['key']) && $f['key'] === $key ) {
                $srcType = $f['type'] ?? 'text';
                break;
            }
        }
        if ( $srcType === 'file' ) {
            $raw = isset($_POST[$field_name]) ? (string) wp_unslash($_POST[$field_name]) : '';
            $arr = json_decode( $raw, true );
            $has = ( is_array($arr) && ! empty($arr) );
            if ( $op === 'has_file' ) return $has;
            if ( $op === 'no_file' )  return ! $has;
            // fallback to text comparisons if someone configured wrong op
            $left = $has ? '1' : '';
        } else {
            $left = isset($_POST[$field_name]) ? (string) wp_unslash($_POST[$field_name]) : '';
            $left = trim($left);
        }
    } else {
        return false;
    }
    // Operator evaluation
    if ( $op === 'has_file' ) return ( trim($left) !== '' );
    if ( $op === 'no_file' )  return ( trim($left) === '' );
    if ( $op === 'contains' ) {
        if ( $value === '' ) return false;
        return ( stripos( (string) $left, (string) $value ) !== false );
    }
    if ( $op === 'is_not' ) {
        return ( (string) $left !== (string) $value );
    }
    // default: is
    return ( (string) $left === (string) $value );
}
function merchatura_v42_field_is_visible_from_post( $product_id, $field, $all_fields ) {
    $conds = isset($field['conditions']) && is_array($field['conditions']) ? $field['conditions'] : array();
    if ( empty($conds) ) return true;
    // AND logic
    $allMatch = true;
    foreach ( $conds as $c ) {
        if ( ! merchatura_v42_condition_matches( $product_id, $all_fields, $c ) ) {
            $allMatch = false;
            break;
        }
    }
    // Apply action using FIRST rule's action as the field's action (consistent with UI "Show/Hide if …")
    // BUT your UI allows action per rule, so we apply each rule's action individually:
    // - If any rule is "hide" and that rule matches, hide wins.
    // - Else if there is any "show" rule, field only shows if ALL show-rules match (already in $allMatch).
    $anyHideMatch = false;
    $hasShowRule  = false;
    $allShowMatch = true;
    foreach ( $conds as $c ) {
        $action = ($c['action'] ?? 'show') === 'hide' ? 'hide' : 'show';
        $match = merchatura_v42_condition_matches( $product_id, $all_fields, $c );
        if ( $action === 'hide' && $match ) $anyHideMatch = true;
        if ( $action === 'show' ) {
            $hasShowRule = true;
            if ( ! $match ) $allShowMatch = false;
        }
    }
    if ( $anyHideMatch ) return false;
    if ( $hasShowRule ) return $allShowMatch;
    // only hide rules exist and none matched
    return true;
}
/* =========================================================
 * Front-end uploader JS (product only, only if file field exists)
 * (your existing v4.1 uploader remains, unchanged)
 * ========================================================= */
add_action( 'wp_footer', function() {
    if ( ! is_product() ) return;
    global $product;
    if ( ! $product ) return;
    $fields = merchatura_v41_get_fields( $product->get_id() );
    $has_file = false;
    foreach ( $fields as $f ) { if ( ($f['type'] ?? '') === 'file' ) { $has_file = true; break; } }
    if ( ! $has_file ) return;
    $nonce = wp_create_nonce( 'merchatura_v41_upload' );
    ?>
    <script>
    (function(){
        const ajaxUrl = "<?php echo esc_js( admin_url('admin-ajax.php') ); ?>";
        const nonce = "<?php echo esc_js( $nonce ); ?>";
        function qs(sel, root){ return (root||document).querySelector(sel); }
        function qsa(sel, root){ return Array.from((root||document).querySelectorAll(sel)); }
        function parseHidden(hidden){
            try {
                const v = (hidden.value || '').trim();
                if(!v) return [];
                const arr = JSON.parse(v);
                return Array.isArray(arr) ? arr : [];
            } catch(e){ return []; }
        }
        function writeHidden(hidden, arr){
            hidden.value = JSON.stringify(arr || []);
        }
        function escapeHtml(s){
            return (s||'').toString().replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'&quot;',"'":'&#039;'}[m]));
        }
        function renderList(box){
            const hiddenId = box.getAttribute('data-hidden-id');
            const hidden = document.getElementById(hiddenId);
            const list = qs('.mcf-upload-list', box);
            if(!hidden || !list) return;
            const files = parseHidden(hidden);
            list.innerHTML = files.map((f, idx) => {
                const safeName = (f.name || 'File').toString();
                return `
                  <div class="mcf-upload-item" data-idx="${idx}">
                    <a class="mcf-upload-link" href="${f.url}" target="_blank" rel="noopener">${escapeHtml(safeName)}</a>
                    <button type="button" class="mcf-upload-remove" aria-label="Remove file">Remove</button>
                  </div>
                `;
            }).join('');
            list.querySelectorAll('.mcf-upload-remove').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    e.preventDefault();
                    const row = btn.closest('.mcf-upload-item');
                    const idx = parseInt(row.dataset.idx, 10);
                    const files2 = parseHidden(hidden);
                    files2.splice(idx, 1);
                    writeHidden(hidden, files2);
                    renderList(box);
                    // trigger conditional refresh
                    document.dispatchEvent(new CustomEvent('mcf:filesChanged'));
                });
            });
        }
        function setStatus(box, text){
            const s = qs('.mcf-upload-status', box);
            if(s) s.textContent = text || '';
        }
        function setProgress(box, pct){
            const bar = qs('.mcf-upload-bar > span', box);
            const wrap = qs('.mcf-upload-bar', box);
            if(!bar || !wrap) return;
            wrap.style.display = 'block';
            bar.style.width = Math.max(0, Math.min(100, pct)) + '%';
        }
        window.__mcfUploadsInFlight = window.__mcfUploadsInFlight || 0;
        function setCartDisabled(disabled){
            const btn = document.querySelector('form.cart button.single_add_to_cart_button');
            if(!btn) return;
            btn.disabled = !!disabled;
            btn.classList.toggle('disabled', !!disabled);
            btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
        }
        document.addEventListener('submit', function(e){
            const form = e.target;
            if(!form || !form.matches('form.cart')) return;
            if((window.__mcfUploadsInFlight || 0) > 0){
                e.preventDefault();
                alert('Please wait — your file upload is still finishing.');
            }
        }, true);
        function uploadOne(box, file){
            const hiddenId = box.getAttribute('data-hidden-id');
            const hidden = document.getElementById(hiddenId);
            if(!hidden) return Promise.reject('Missing hidden field');
            window.__mcfUploadsInFlight++;
            setCartDisabled(true);
            setStatus(box, 'Uploading…');
            setProgress(box, 5);
            return new Promise((resolve, reject) => {
                const form = new FormData();
                form.append('action', 'merchatura_v41_upload_file');
                form.append('nonce', nonce);
                form.append('file', file);
                const xhr = new XMLHttpRequest();
                xhr.open('POST', ajaxUrl, true);
                xhr.upload.addEventListener('progress', function(e){
                    if(!e.lengthComputable) return;
                    const pct = Math.round((e.loaded / e.total) * 100);
                    setProgress(box, Math.min(95, pct));
                });
                xhr.onreadystatechange = function(){
                    if(xhr.readyState !== 4) return;
                    window.__mcfUploadsInFlight = Math.max(0, (window.__mcfUploadsInFlight || 1) - 1);
                    if((window.__mcfUploadsInFlight || 0) === 0){
                        setCartDisabled(false);
                    }
                    if(xhr.status !== 200){
                        setStatus(box, 'Upload failed.');
                        setProgress(box, 0);
                        reject('HTTP ' + xhr.status);
                        return;
                    }
                    try{
                        const res = JSON.parse(xhr.responseText);
                        if(!res || !res.success){
                            setStatus(box, (res && res.data && res.data.message) ? res.data.message : 'Upload failed.');
                            setProgress(box, 0);
                            reject('Server error');
                            return;
                        }
                        const files = parseHidden(hidden);
                        files.push({ id: res.data.attachment_id, url: res.data.url, name: res.data.original_name || file.name });
                        writeHidden(hidden, files);
                        renderList(box);
                        setStatus(box, 'Uploaded: ' + (res.data.original_name || file.name));
                        setProgress(box, 100);
                        // trigger conditional refresh
                        document.dispatchEvent(new CustomEvent('mcf:filesChanged'));
                        resolve(res.data);
                    }catch(err){
                        setStatus(box, 'Upload failed.');
                        setProgress(box, 0);
                        reject(err);
                    }
                };
                xhr.send(form);
            });
        }
        async function uploadManySequential(box, files){
            const arr = Array.from(files || []);
            if(!arr.length) return;
            for(const f of arr){
                try{
                    setProgress(box, 0);
                    await uploadOne(box, f);
                }catch(e){}
            }
            setStatus(box, 'All files uploaded.');
            setProgress(box, 100);
        }
        function initUploader(box){
            const fileInput = qs('input[type="file"]', box);
            const drop = qs('.mcf-upload-drop', box);
            const hiddenId = box.getAttribute('data-hidden-id');
            const hidden = document.getElementById(hiddenId);
            if(!fileInput || !drop || !hidden) return;
            renderList(box);
            function handleFiles(files){
                if(!files || !files.length) return;
                uploadManySequential(box, files);
            }
            fileInput.addEventListener('change', function(){
                handleFiles(fileInput.files);
                fileInput.value = '';
            });
            ['dragenter','dragover'].forEach(evt => {
                drop.addEventListener(evt, function(e){
                    e.preventDefault(); e.stopPropagation();
                    drop.classList.add('is-dragover');
                });
            });
            ['dragleave','drop'].forEach(evt => {
                drop.addEventListener(evt, function(e){
                    e.preventDefault(); e.stopPropagation();
                    drop.classList.remove('is-dragover');
                });
            });
            drop.addEventListener('drop', function(e){
                const dt = e.dataTransfer;
                if(dt && dt.files) handleFiles(dt.files);
            });
        }
        qsa('.mcf-upload-box').forEach(initUploader);
    })();
    </script>
    <?php
} );
/* =========================================================
 * Front-end: Render fields + attach condition data attributes
 * ========================================================= */
add_action( 'woocommerce_before_add_to_cart_button', function() {
    global $product;
    if ( ! $product ) return;
    $fields = merchatura_v41_get_fields( $product->get_id() );
    if ( empty($fields) ) return;
    echo '<div class="merchatura-custom-fields">';
    foreach ( $fields as $f ) {
        $type = $f['type'] ?? 'text';
        $name = $f['name'] ?? '';
        $key  = $f['key'] ?? '';
        $box3 = $f['box3'] ?? '';
        $info = $f['info'] ?? '';
        $required = ( (($f['required'] ?? 'no') === 'yes') );
        $conditions = ( isset($f['conditions']) && is_array($f['conditions']) ) ? $f['conditions'] : array();
        if ( ! $name || ! $key ) continue;
        $field_name = 'merchatura_cf_' . $key;
        $field_id   = 'merchatura_cf_' . $key;
        $conds_attr = ! empty($conditions) ? esc_attr( wp_json_encode( $conditions ) ) : '';
        echo '<div class="mcf-row" data-mcf-key="' . esc_attr($key) . '" data-mcf-type="' . esc_attr($type) . '" data-mcf-required="' . ($required ? '1':'0') . '" ' . ( $conds_attr ? 'data-mcf-conds="'.$conds_attr.'"' : '' ) . '>';
        echo '<label class="mcf-label" for="' . esc_attr($field_id) . '">';
        echo esc_html($name);
        if ($required) echo ' <span class="mcf-required">*</span>';
        if ( ! empty($info) ) {
            echo ' <span class="mcf-tooltip" tabindex="0" aria-label="' . esc_attr($info) . '">';
            echo '<span class="mcf-tooltip-icon">i</span>';
            echo '<span class="mcf-tooltip-box">' . esc_html($info) . '</span>';
            echo '</span>';
        }
        echo '</label>';
        if ( $type === 'textarea' ) {
            echo '<textarea data-mcf-input-key="' . esc_attr($key) . '" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_name) . '"' . ( $required ? ' required' : '' )
                . ( $box3 ? ' placeholder="' . esc_attr($box3) . '"' : '' ) . '></textarea>';
        } elseif ( $type === 'select' ) {
            $options = array_filter( array_map('trim', explode("\n", str_replace(array("\r\n","\r"), "\n", (string)$box3))) );
            echo '<select data-mcf-input-key="' . esc_attr($key) . '" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_name) . '"' . ( $required ? ' required' : '' ) . '>';
            echo '<option value="">' . esc_html__('Select…', 'merchatura') . '</option>';
            foreach ($options as $opt) echo '<option value="' . esc_attr($opt) . '">' . esc_html($opt) . '</option>';
            echo '</select>';
        } elseif ( $type === 'radio' ) {
            $options = array_filter( array_map('trim', explode("\n", str_replace(array("\r\n","\r"), "\n", (string)$box3))) );
            echo '<div class="mcf-radio-wrap" id="' . esc_attr($field_id) . '">';
            foreach ($options as $i => $opt) {
                $rid = $field_id . '_opt_' . $i;
                echo '<label class="mcf-radio">';
                echo '<input data-mcf-input-key="' . esc_attr($key) . '" type="radio" name="' . esc_attr($field_name) . '" id="' . esc_attr($rid) . '" value="' . esc_attr($opt) . '"' . ( $required ? ' required' : '' ) . '>';
                echo '<span>' . esc_html($opt) . '</span>';
                echo '</label>';
            }
            echo '</div>';
        } elseif ( $type === 'file' ) {
            $hidden_id = 'mcf_file_hidden_' . $key;
            echo '<input data-mcf-input-key="' . esc_attr($key) . '" type="hidden" id="' . esc_attr($hidden_id) . '" name="' . esc_attr($field_name) . '" value="[]">';
            $accept = '';
            if ( ! empty($box3) ) {
                $parts = array_filter(array_map('trim', explode(',', $box3)));
                $parts = array_map(function($p){ return '.' . ltrim($p, '.'); }, $parts);
                $accept = implode(',', $parts);
            }
            echo '<div class="mcf-upload-box" data-hidden-id="' . esc_attr($hidden_id) . '">';
                echo '<div class="mcf-upload-drop">';
                    echo '<div class="mcf-upload-inner">';
                        echo '<div class="mcf-upload-icon">☁</div>';
                        echo '<div class="mcf-upload-text">Drop Files Here, Or</div>';
                        echo '<label class="mcf-upload-btn">';
                            echo 'Browse…';
                            echo '<input type="file" multiple ' . ( $accept ? 'accept="' . esc_attr($accept) . '"' : '' ) . ' style="display:none;">';
                        echo '</label>';
                    echo '</div>';
                    echo '<div class="mcf-upload-status"></div>';
                    echo '<div class="mcf-upload-bar"><span></span></div>';
                echo '</div>';
                echo '<div class="mcf-upload-list"></div>';
            echo '</div>';
        } else {
            $input_type = ( $type === 'email' ) ? 'email' : 'text';
            echo '<input data-mcf-input-key="' . esc_attr($key) . '" type="' . esc_attr($input_type) . '" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_name) . '"'
                . ( $required ? ' required' : '' )
                . ( $box3 ? ' placeholder="' . esc_attr($box3) . '"' : '' )
                . '>';
        }
        echo '</div>'; // .mcf-row
    }
    echo '</div>';
} );
/* =========================================================
 * Front-end: Conditional logic runner (attributes + fields)
 * ========================================================= */
add_action( 'wp_footer', function() {
    if ( ! is_product() ) return;
    ?>
    <script>
    (function(){
        const form = document.querySelector('form.cart');
        if(!form) return;
        function parseJSON(s){
            try{ return JSON.parse(s); }catch(e){ return null; }
        }
        function getFieldValueByKey(key){
            // custom field input(s)
            const els = form.querySelectorAll(`[data-mcf-input-key="${CSS.escape(key)}"]`);
            if(!els || !els.length) return '';
            // file is stored in hidden JSON
            const first = els[0];
            if(first && first.type === 'hidden'){
                const arr = parseJSON(first.value || '[]');
                return Array.isArray(arr) && arr.length ? '__HAS_FILE__' : '';
            }
            // radio group
            if(first && first.type === 'radio'){
                const checked = form.querySelector(`[data-mcf-input-key="${CSS.escape(key)}"]:checked`);
                return checked ? (checked.value || '') : '';
            }
            return (first.value || '').trim();
        }
        function getAttributeValueBySourceId(sourceId){
            // sourceId looks like attr_pa_color OR attr_size
            // find attribute_*
            // we'll attempt to match the suffix to the name in the cart form.
            // e.g. attr_pa_color -> attribute_pa_color
            const name = 'attribute_' + sourceId.replace(/^attr_/, '');
            const el = form.querySelector(`[name="${CSS.escape(name)}"]`);
            if(!el) return '';
            return (el.value || '').trim();
        }
        function evalCond(cond){
            const action = (cond.action === 'hide') ? 'hide' : 'show';
            const source = (cond.source || '').toString();
            const op = (cond.op || 'is').toString();
            const value = (cond.value || '').toString();
            let left = '';
            let isFileSource = false;
            if(source.startsWith('attr_')){
                left = getAttributeValueBySourceId(source);
            } else if(source.startsWith('field_')){
                const key = source.replace(/^field_/, '');
                const v = getFieldValueByKey(key);
                if(v === '__HAS_FILE__'){ isFileSource = true; left = v; }
                else left = v;
            } else {
                return {action, match:false};
            }
            // file operators
            if(op === 'has_file'){
                return {action, match: (left === '__HAS_FILE__')};
            }
            if(op === 'no_file'){
                return {action, match: (left !== '__HAS_FILE__')};
            }
            if(op === 'contains'){
                if(!value) return {action, match:false};
                return {action, match: (left.toLowerCase().indexOf(value.toLowerCase()) !== -1)};
            }
            if(op === 'is_not'){
                return {action, match: (left !== value)};
            }
            // default is
            return {action, match: (left === value)};
        }
        function setRowVisible(row, visible){
            row.style.display = visible ? '' : 'none';
            // Disable inputs inside hidden rows so they:
            // - don't trigger browser required validation
            // - don't submit values
            const inputs = row.querySelectorAll('input, select, textarea');
            inputs.forEach(inp => {
                if(!inp.dataset) return;
                // remember original required
                if(inp.dataset.mcfWasReq === undefined){
                    inp.dataset.mcfWasReq = inp.required ? '1' : '0';
                }
                if(!visible){
                    inp.disabled = true;
                    inp.required = false;
                } else {
                    inp.disabled = false;
                    inp.required = (inp.dataset.mcfWasReq === '1');
                }
            });
        }
        function applyAll(){
            const rows = form.querySelectorAll('.mcf-row[data-mcf-conds]');
            rows.forEach(row => {
                const condsRaw = row.getAttribute('data-mcf-conds');
                const conds = parseJSON(condsRaw);
                if(!Array.isArray(conds) || !conds.length){
                    setRowVisible(row, true);
                    return;
                }
                // Priority:
                // - If any HIDE rule matches => hide
                // - If there are SHOW rules => show only if all show rules match
                let anyHideMatch = false;
                let hasShowRule = false;
                let allShowMatch = true;
                conds.forEach(c => {
                    const res = evalCond(c || {});
                    if(res.action === 'hide' && res.match) anyHideMatch = true;
                    if(res.action === 'show'){
                        hasShowRule = true;
                        if(!res.match) allShowMatch = false;
                    }
                });
                let visible = true;
                if(anyHideMatch) visible = false;
                else if(hasShowRule) visible = allShowMatch;
                setRowVisible(row, visible);
            });
        }
        // Run on:
        // - any change on the cart form (attributes/fields)
        // - upload/remove files events
        form.addEventListener('change', applyAll);
        form.addEventListener('input', applyAll);
        document.addEventListener('mcf:filesChanged', applyAll);
        // run once
        applyAll();
    })();
    </script>
    <?php
}, 20 );
/* =========================================================
 * AJAX upload (your existing handler unchanged)
 * ========================================================= */
add_action( 'wp_ajax_merchatura_v41_upload_file', 'merchatura_v41_upload_file' );
add_action( 'wp_ajax_nopriv_merchatura_v41_upload_file', 'merchatura_v41_upload_file' );
function merchatura_v41_upload_file() {
    if ( ! isset($_POST['nonce']) || ! wp_verify_nonce( sanitize_text_field($_POST['nonce']), 'merchatura_v41_upload' ) ) {
        wp_send_json_error( array( 'message' => 'Invalid security token.' ), 403 );
    }
    if ( empty($_FILES['file']) || ! isset($_FILES['file']['tmp_name']) ) {
        wp_send_json_error( array( 'message' => 'No file received.' ), 400 );
    }
    require_once ABSPATH . 'wp-admin/includes/file.php';
    require_once ABSPATH . 'wp-admin/includes/media.php';
    require_once ABSPATH . 'wp-admin/includes/image.php';
    $file = $_FILES['file'];
    if ( ! empty($file['size']) && (int)$file['size'] > 20 * 1024 * 1024 ) {
        wp_send_json_error( array( 'message' => 'File too large.' ), 400 );
    }
    $overrides = array( 'test_form' => false );
    $uploaded = wp_handle_upload( $file, $overrides );
    if ( isset($uploaded['error']) ) {
        wp_send_json_error( array( 'message' => $uploaded['error'] ), 400 );
    }
    $attachment = array(
        'post_mime_type' => $uploaded['type'],
        'post_title'     => sanitize_file_name( $file['name'] ),
        'post_content'   => '',
        'post_status'    => 'inherit'
    );
    $attach_id = wp_insert_attachment( $attachment, $uploaded['file'] );
    if ( is_wp_error($attach_id) ) {
        wp_send_json_error( array( 'message' => 'Could not create attachment.' ), 500 );
    }
    $attach_data = wp_generate_attachment_metadata( $attach_id, $uploaded['file'] );
    wp_update_attachment_metadata( $attach_id, $attach_data );
    wp_send_json_success( array(
        'attachment_id' => $attach_id,
        'url'           => $uploaded['url'],
        'original_name' => $file['name'],
    ) );
}
/* =========================================================
 * Validation (required + email + required file: at least 1)
 * ✅ Updated: hidden fields are ignored based on conditions
 * ========================================================= */
add_filter( 'woocommerce_add_to_cart_validation', function( $passed, $product_id, $quantity ) {
    $fields = merchatura_v41_get_fields( $product_id );
    if ( empty($fields) ) return $passed;
    foreach ( $fields as $f ) {
        $type = $f['type'] ?? 'text';
        $name = $f['name'] ?? '';
        $key  = $f['key'] ?? '';
        $required = ( ($f['required'] ?? 'no') === 'yes' );
        if ( ! $name || ! $key ) continue;
        // If field is hidden due to conditional logic, ignore it (including required)
        if ( ! merchatura_v42_field_is_visible_from_post( $product_id, $f, $fields ) ) {
            continue;
        }
        $field_name = 'merchatura_cf_' . $key;
        $raw = isset($_POST[$field_name]) ? trim((string) wp_unslash($_POST[$field_name])) : '';
        if ( $type === 'file' ) {
            $filesArr = array();
            if ($raw !== '') {
                $decoded = json_decode($raw, true);
                if ( is_array($decoded) ) $filesArr = $decoded;
            }
            if ( $required && empty($filesArr) ) {
                wc_add_notice( sprintf( __( 'Please upload at least one file for: %s', 'merchatura' ), $name ), 'error' );
                return false;
            }
            continue;
        }
        if ( $required && $raw === '' ) {
            wc_add_notice( sprintf( __( 'Please complete: %s', 'merchatura' ), $name ), 'error' );
            return false;
        }
        if ( $raw !== '' && $type === 'email' && ! is_email($raw) ) {
            wc_add_notice( sprintf( __( 'Please enter a valid email for: %s', 'merchatura' ), $name ), 'error' );
            return false;
        }
    }
    return $passed;
}, 20, 3 );
/* =========================================================
 * Save into cart item data
 * ✅ Updated: hidden fields are not saved
 * ========================================================= */
add_filter( 'woocommerce_add_cart_item_data', function( $cart_item_data, $product_id ) {
    $fields = merchatura_v41_get_fields( $product_id );
    if ( empty($fields) ) return $cart_item_data;
    $values = array();
    foreach ( $fields as $f ) {
        $type = $f['type'] ?? 'text';
        $name = $f['name'] ?? '';
        $key  = $f['key'] ?? '';
        if ( ! $name || ! $key ) continue;
        // If field hidden, skip saving
        if ( ! merchatura_v42_field_is_visible_from_post( $product_id, $f, $fields ) ) {
            continue;
        }
        $field_name = 'merchatura_cf_' . $key;
        if ( ! isset($_POST[$field_name]) ) continue;
        $raw = trim((string) wp_unslash($_POST[$field_name]));
        if ( $raw === '' ) continue;
        if ( $type === 'file' ) {
            $decoded = json_decode($raw, true);
            if ( ! is_array($decoded) || empty($decoded) ) continue;
            $links = array();
            foreach ( $decoded as $file ) {
                $url = isset($file['url']) ? esc_url_raw($file['url']) : '';
                $nm  = isset($file['name']) ? sanitize_text_field($file['name']) : 'File';
                if ( $url ) $links[] = array('url' => $url, 'name' => $nm);
            }
            if ( empty($links) ) continue;
            $values[] = array(
                'key'   => $key,
                'label' => $name,
                'type'  => 'file',
                'value' => $links,
            );
        } else {
            $val = ( $type === 'email' ) ? sanitize_email($raw) : sanitize_text_field($raw);
            $values[] = array(
                'key'   => $key,
                'label' => $name,
                'type'  => $type,
                'value' => $val,
            );
        }
    }
    if ( ! empty($values) ) {
        $cart_item_data['merchatura_custom_fields'] = $values;
        $cart_item_data['merchatura_custom_fields_key'] = md5( wp_json_encode($values) );
    }
    return $cart_item_data;
}, 20, 2 );
/* =========================================================
 * Display in cart + checkout (unchanged)
 * ========================================================= */
add_filter( 'woocommerce_get_item_data', function( $item_data, $cart_item ) {
    if ( empty($cart_item['merchatura_custom_fields']) || ! is_array($cart_item['merchatura_custom_fields']) ) return $item_data;
    foreach ( $cart_item['merchatura_custom_fields'] as $f ) {
        $label = $f['label'] ?? '';
        $type  = $f['type'] ?? '';
        $value = $f['value'] ?? '';
        if ( $type === 'file' && is_array($value) ) {
            $links = array();
            foreach ($value as $file) {
                $url = isset($file['url']) ? $file['url'] : '';
                $nm  = isset($file['name']) ? $file['name'] : 'File';
                if($url) $links[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener">' . esc_html($nm) . '</a>';
            }
            $item_data[] = array(
                'name'  => $label,
                'value' => implode('<br>', $links),
            );
        } else {
            $item_data[] = array(
                'name'  => $label,
                'value' => esc_html($value),
            );
        }
    }
    return $item_data;
}, 20, 2 );
/* =========================================================
 * Save to order item meta (unchanged)
 * ========================================================= */
add_action( 'woocommerce_checkout_create_order_line_item', function( $item, $cart_item_key, $values, $order ) {
    if ( empty($values['merchatura_custom_fields']) || ! is_array($values['merchatura_custom_fields']) ) return;
    foreach ( $values['merchatura_custom_fields'] as $f ) {
        $label = $f['label'] ?? '';
        $type  = $f['type'] ?? '';
        $value = $f['value'] ?? '';
        if ( $type === 'file' && is_array($value) ) {
            $urls = array();
            foreach ($value as $file) {
                if ( ! empty($file['url']) ) $urls[] = esc_url_raw($file['url']);
            }
            if ( $label && ! empty($urls) ) {
                $item->add_meta_data( $label, implode("\n", $urls), true );
            }
        } elseif ( $label && $value ) {
            $item->add_meta_data( $label, sanitize_text_field($value), true );
        }
    }
}, 20, 4 );
/* =========================================================
 * v4.1 ADD-ON: Export / Import / Copy field settings
 * ✅ Updated: conditions included automatically (already part of field array)
 * ========================================================= */
add_action('admin_footer', function () {
    $screen = function_exists('get_current_screen') ? get_current_screen() : null;
    if (!$screen || $screen->id !== 'product') return;
    global $post;
    if (!$post || $post->post_type !== 'product') return;
    $nonce = wp_create_nonce('merchatura_cf_tools');
    ?>
    <style>
      .mcf-tools-wrap { margin-top: 14px; padding-top: 14px; border-top: 1px solid #e5e5e5; }
      .mcf-tools-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
      .mcf-tools-card { border: 1px solid #e5e5e5; border-radius: 10px; background: #fff; padding: 12px; }
      .mcf-tools-card h4 { margin: 0 0 8px; font-size: 13px; }
      .mcf-tools-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
      .mcf-tools-card textarea { width: 100%; min-height: 120px; }
      .mcf-tools-note { margin-top: 8px; color:#666; font-size: 12px; }
      .mcf-tools-search { width: 100%; max-width: 420px; }
      .mcf-tools-results { margin-top: 8px; border: 1px solid #e5e5e5; border-radius: 10px; overflow: hidden; max-height: 220px; overflow-y: auto; display:none; }
      .mcf-tools-results button { width:100%; text-align:left; padding:10px 12px; border:0; background:#fff; cursor:pointer; }
      .mcf-tools-results button:hover { background:#f6f6f6; }
      @media (max-width: 1100px){ .mcf-tools-grid{ grid-template-columns: 1fr; } }
    </style>
    <script>
    (function(){
        const panel = document.getElementById('merchatura_custom_fields_panel');
        if(!panel) return;
        const productId = <?php echo (int) $post->ID; ?>;
        const ajaxUrl   = "<?php echo esc_js(admin_url('admin-ajax.php')); ?>";
        const nonce     = "<?php echo esc_js($nonce); ?>";
        const accordion = document.getElementById('merchatura-cf-accordion');
        if(!accordion) return;
        const wrap = document.createElement('div');
        wrap.className = 'mcf-tools-wrap';
        wrap.innerHTML = `
          <div class="mcf-tools-grid">
            <div class="mcf-tools-card">
              <h4>Export fields</h4>
              <div class="mcf-tools-row">
                <button type="button" class="button" id="mcf-export-btn">Export JSON</button>
              </div>
              <div class="mcf-tools-note">Downloads this product’s field setup as a .json file.</div>
            </div>
            <div class="mcf-tools-card">
              <h4>Import fields</h4>
              <textarea id="mcf-import-json" placeholder='Paste JSON here (or copy from a previous export)…'></textarea>
              <div class="mcf-tools-row" style="margin-top:10px;">
                <button type="button" class="button button-primary" id="mcf-import-btn">Import (overwrite)</button>
              </div>
              <div class="mcf-tools-note">This overwrites the current product’s fields.</div>
            </div>
            <div class="mcf-tools-card" style="grid-column: 1 / -1;">
              <h4>Copy fields from another product</h4>
              <input class="mcf-tools-search" type="text" id="mcf-copy-search" placeholder="Search product by name…">
              <div class="mcf-tools-results" id="mcf-copy-results"></div>
              <div class="mcf-tools-note">Search and click a product to copy its field setup onto this product (overwrite).</div>
            </div>
          </div>
        `;
        accordion.parentNode.appendChild(wrap);
        function post(action, data){
            return fetch(ajaxUrl, {
                method: 'POST',
                headers: {'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'},
                body: new URLSearchParams(Object.assign({action, nonce, product_id: productId}, data||{})).toString()
            }).then(r => r.json());
        }
        function download(filename, text){
            const blob = new Blob([text], {type: 'application/json'});
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            a.remove();
            URL.revokeObjectURL(url);
        }
        document.getElementById('mcf-export-btn').addEventListener('click', async function(){
            const res = await post('merchatura_cf_export_fields', {});
            if(!res || !res.success){
                alert(res?.data?.message || 'Export failed.');
                return;
            }
            download(`product-${productId}-custom-fields.json`, JSON.stringify(res.data.fields, null, 2));
        });
        document.getElementById('mcf-import-btn').addEventListener('click', async function(){
            const json = (document.getElementById('mcf-import-json').value || '').trim();
            if(!json){
                alert('Paste JSON first.');
                return;
            }
            const res = await post('merchatura_cf_import_fields', {json});
            if(!res || !res.success){
                alert(res?.data?.message || 'Import failed.');
                return;
            }
            alert('Imported! Reloading…');
            window.location.reload();
        });
        const search = document.getElementById('mcf-copy-search');
        const results = document.getElementById('mcf-copy-results');
        let t = null;
        search.addEventListener('input', function(){
            clearTimeout(t);
            const q = (search.value || '').trim();
            if(q.length < 2){
                results.style.display = 'none';
                results.innerHTML = '';
                return;
            }
            t = setTimeout(async () => {
                const res = await post('merchatura_cf_search_products', {q});
                if(!res || !res.success){
                    results.style.display = 'none';
                    results.innerHTML = '';
                    return;
                }
                const items = res.data.items || [];
                if(!items.length){
                    results.style.display = 'none';
                    results.innerHTML = '';
                    return;
                }
                results.innerHTML = items.map(i => `<button type="button" data-id="${i.id}">${i.text}</button>`).join('');
                results.style.display = 'block';
                results.querySelectorAll('button').forEach(btn => {
                    btn.addEventListener('click', async function(){
                        const fromId = btn.getAttribute('data-id');
                        if(!fromId) return;
                        if(!confirm('Copy fields from this product and overwrite current fields?')) return;
                        const res2 = await post('merchatura_cf_copy_fields', {from_product_id: fromId});
                        if(!res2 || !res2.success){
                            alert(res2?.data?.message || 'Copy failed.');
                            return;
                        }
                        alert('Copied! Reloading…');
                        window.location.reload();
                    });
                });
            }, 250);
        });
    })();
    </script>
    <?php
});
/* ---------------------------
 * AJAX: Export
 * --------------------------- */
add_action('wp_ajax_merchatura_cf_export_fields', function(){
    if ( ! current_user_can('edit_products') ) {
        wp_send_json_error(array('message' => 'Permission denied.'), 403);
    }
    if ( ! isset($_POST['nonce']) || ! wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'merchatura_cf_tools') ) {
        wp_send_json_error(array('message' => 'Bad nonce.'), 403);
    }
    $product_id = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
    if ( $product_id <= 0 ) wp_send_json_error(array('message' => 'Invalid product.'), 400);
    $fields = get_post_meta($product_id, MERCHATURA_FIELDS_META_KEY_V4, true);
    if ( ! is_array($fields) ) $fields = array();
    wp_send_json_success(array('fields' => $fields));
});
/* ---------------------------
 * AJAX: Import (overwrite)
 * --------------------------- */
add_action('wp_ajax_merchatura_cf_import_fields', function(){
    if ( ! current_user_can('edit_products') ) {
        wp_send_json_error(array('message' => 'Permission denied.'), 403);
    }
    if ( ! isset($_POST['nonce']) || ! wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'merchatura_cf_tools') ) {
        wp_send_json_error(array('message' => 'Bad nonce.'), 403);
    }
    $product_id = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
    $json = isset($_POST['json']) ? wp_unslash($_POST['json']) : '';
    if ( $product_id <= 0 ) wp_send_json_error(array('message' => 'Invalid product.'), 400);
    if ( trim($json) === '' ) wp_send_json_error(array('message' => 'No JSON provided.'), 400);
    $decoded = json_decode($json, true);
    if ( ! is_array($decoded) ) {
        wp_send_json_error(array('message' => 'Invalid JSON.'), 400);
    }
    $allowed = array('text','textarea','email','select','radio','file');
    $allowed_ops = array( 'is','is_not','contains','has_file','no_file' );
    $allowed_actions = array( 'show','hide' );
    $clean = array();
    $seen = array();
    foreach ($decoded as $f) {
        $type = isset($f['type']) ? sanitize_key($f['type']) : 'text';
        if ( ! in_array($type, $allowed, true) ) $type = 'text';
        $name = isset($f['name']) ? sanitize_text_field($f['name']) : '';
        if ($name === '') continue;
        $key = isset($f['key']) ? sanitize_key($f['key']) : '';
        if ($key === '') $key = sanitize_key(strtolower(preg_replace('/\s+/', '_', $name)));
        if ($key === '') continue;
        $base = $key; $i = 2;
        while (isset($seen[$key])) { $key = $base . '_' . $i; $i++; }
        $seen[$key] = true;
        $box3 = isset($f['box3']) ? trim((string)$f['box3']) : '';
        $info = isset($f['info']) ? trim((string)$f['info']) : '';
        $required = (isset($f['required']) && $f['required'] === 'yes') ? 'yes' : 'no';
        if ($type === 'select' || $type === 'radio') {
            $box3 = str_replace(array("\r\n","\r"), "\n", $box3);
            $lines = array_filter(array_map('trim', explode("\n", $box3)));
            $box3 = implode("\n", $lines);
        }
        if ($type === 'file') {
            $box3 = strtolower($box3);
            $box3 = preg_replace('/[^a-z0-9,\s]/', '', $box3);
            $box3 = preg_replace('/\s+/', '', $box3);
        }
        $conds_clean = array();
        if ( isset($f['conditions']) && is_array($f['conditions']) ) {
            foreach ( $f['conditions'] as $c ) {
                $action = isset($c['action']) ? sanitize_key($c['action']) : 'show';
                if ( ! in_array($action, $allowed_actions, true) ) $action = 'show';
                $source = isset($c['source']) ? sanitize_text_field($c['source']) : '';
                $source = trim($source);
                $op = isset($c['op']) ? sanitize_key($c['op']) : 'is';
                if ( ! in_array($op, $allowed_ops, true) ) $op = 'is';
                $value = isset($c['value']) ? sanitize_text_field($c['value']) : '';
                $value = trim($value);
                if ( $op === 'has_file' || $op === 'no_file' ) $value = '';
                if ( $source === '' ) continue;
                $conds_clean[] = array(
                    'action' => $action,
                    'source' => $source,
                    'op'     => $op,
                    'value'  => $value,
                );
            }
        }
        $clean[] = array(
            'type'       => $type,
            'name'       => $name,
            'box3'       => $box3,
            'required'   => $required,
            'info'       => $info,
            'key'        => $key,
            'conditions' => $conds_clean,
        );
    }
    update_post_meta($product_id, MERCHATURA_FIELDS_META_KEY_V4, $clean);
    wp_send_json_success(array('message' => 'Imported.'));
});
/* ---------------------------
 * AJAX: Search products by title
 * --------------------------- */
add_action('wp_ajax_merchatura_cf_search_products', function(){
    if ( ! current_user_can('edit_products') ) {
        wp_send_json_error(array('message' => 'Permission denied.'), 403);
    }
    if ( ! isset($_POST['nonce']) || ! wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'merchatura_cf_tools') ) {
        wp_send_json_error(array('message' => 'Bad nonce.'), 403);
    }
    $q = isset($_POST['q']) ? sanitize_text_field($_POST['q']) : '';
    $q = trim($q);
    if ( strlen($q) < 2 ) wp_send_json_success(array('items' => array()));
    $args = array(
        'post_type'      => 'product',
        'post_status'    => array('publish','private','draft'),
        's'              => $q,
        'posts_per_page' => 15,
        'fields'         => 'ids',
    );
    $ids = get_posts($args);
    $items = array();
    foreach ($ids as $id) {
        $title = get_the_title($id);
        if (!$title) continue;
        $items[] = array(
            'id'   => (int) $id,
            'text' => $title . ' (ID: ' . (int)$id . ')'
        );
    }
    wp_send_json_success(array('items' => $items));
});
/* ---------------------------
 * AJAX: Copy fields from another product (overwrite)
 * --------------------------- */
add_action('wp_ajax_merchatura_cf_copy_fields', function(){
    if ( ! current_user_can('edit_products') ) {
        wp_send_json_error(array('message' => 'Permission denied.'), 403);
    }
    if ( ! isset($_POST['nonce']) || ! wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'merchatura_cf_tools') ) {
        wp_send_json_error(array('message' => 'Bad nonce.'), 403);
    }
    $product_id = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
    $from_id    = isset($_POST['from_product_id']) ? (int) $_POST['from_product_id'] : 0;
    if ($product_id <= 0 || $from_id <= 0) {
        wp_send_json_error(array('message' => 'Invalid product IDs.'), 400);
    }
    $fields = get_post_meta($from_id, MERCHATURA_FIELDS_META_KEY_V4, true);
    if ( ! is_array($fields) ) $fields = array();
    update_post_meta($product_id, MERCHATURA_FIELDS_META_KEY_V4, $fields);
    wp_send_json_success(array('message' => 'Copied.'));
});
`; }).join(''); bind(); } function bind(){ acc.querySelectorAll('.merchatura-cf-edit').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10); state[idx]._open = !state[idx]._open; render(); }); }); acc.querySelectorAll('.merchatura-cf-copy').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10); const clone = JSON.parse(JSON.stringify(state[idx])); clone._open = false; clone.key = ''; state.splice(idx+1, 0, clone); render(); }); }); acc.querySelectorAll('.merchatura-cf-delete').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10); state.splice(idx, 1); render(); }); }); acc.querySelectorAll('.merchatura-cf-moveup').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10); if (idx <= 0) return; const tmp = state[idx-1]; state[idx-1] = state[idx]; state[idx] = tmp; render(); }); }); acc.querySelectorAll('.merchatura-cf-movedown').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const idx = parseInt(btn.closest('.merchatura-cf-item').dataset.idx, 10); if (idx >= state.length - 1) return; const tmp = state[idx+1]; state[idx+1] = state[idx]; state[idx] = tmp; render(); }); }); // Field inputs acc.querySelectorAll('.merchatura-cf-item').forEach(item => { const idx = parseInt(item.dataset.idx, 10); const type = item.querySelector('.merchatura-cf-type'); const name = item.querySelector('.merchatura-cf-name'); const box3 = item.querySelector('.merchatura-cf-box3'); const info = item.querySelector('.merchatura-cf-info'); const req = item.querySelector('.merchatura-cf-required'); if(type) type.addEventListener('change', () => { state[idx].type = type.value; serializeOnly(); render(); }); if(req) req.addEventListener('change', () => { state[idx].required = req.value; serializeOnly(); updateHeaderInline(idx); }); if(name) name.addEventListener('input', () => { state[idx].name = name.value; serializeOnly(); updateHeaderInline(idx); }); if(box3) box3.addEventListener('input', () => { state[idx].box3 = box3.value; serializeOnly(); }); if(info) info.addEventListener('input', () => { state[idx].info = info.value; serializeOnly(); }); // Add rule const addRule = item.querySelector('.mcf-cond-add'); if(addRule){ addRule.addEventListener('click', (e) => { e.preventDefault(); state[idx].conditions = Array.isArray(state[idx].conditions) ? state[idx].conditions : []; state[idx].conditions.push({ action:'show', source:'', op:'is', value:'' }); render(); }); } // Bind condition rows item.querySelectorAll('.mcf-cond-row').forEach(row => { const cIdx = parseInt(row.getAttribute('data-cidx'), 10); const action = row.querySelector('.mcf-cond-action'); const source = row.querySelector('.mcf-cond-source'); const op = row.querySelector('.mcf-cond-op'); const valEl = row.querySelector('.mcf-cond-value'); const del = row.querySelector('.mcf-cond-del'); function sync(){ const c = state[idx].conditions[cIdx]; if(!c) return; if(action) c.action = action.value === 'hide' ? 'hide' : 'show'; if(source) c.source = source.value || ''; if(op) c.op = op.value || 'is'; if(valEl && !valEl.disabled){ c.value = (valEl.value ?? '').toString(); } else { c.value = ''; } serializeOnly(); } if(action) action.addEventListener('change', sync); if(source) source.addEventListener('change', function(){ // when source changes, re-render the row so operator/value UI adapts (file / dropdown / text) const c = state[idx].conditions[cIdx]; if(!c) return; c.source = source.value || ''; // default operator depending on source type const src = c.source ? getSourceById(c.source) : null; if(src && src.type === 'file'){ c.op = 'has_file'; c.value = ''; } else { c.op = 'is'; c.value = ''; } render(); }); if(op) op.addEventListener('change', function(){ const c = state[idx].conditions[cIdx]; if(!c) return; c.op = op.value || 'is'; serializeOnly(); }); if(valEl) valEl.addEventListener('input', sync); if(valEl) valEl.addEventListener('change', sync); if(del) del.addEventListener('click', (e) => { e.preventDefault(); state[idx].conditions.splice(cIdx, 1); render(); }); }); }); } addBtn.addEventListener('click', (e) => { e.preventDefault(); state.push({ type:'text', name:'', box3:'', required:'no', info:'', key:'', conditions:[], _open:false }); render(); }); render(); })(); update_meta_data( MERCHATURA_FIELDS_META_KEY_V4, array() ); return; } $allowed = array( 'text','textarea','email','select','radio','file' ); $allowed_ops = array( 'is','is_not','contains','has_file','no_file' ); $allowed_actions = array( 'show','hide' ); $clean = array(); $seen = array(); foreach ( $decoded as $f ) { $type = isset($f['type']) ? sanitize_key($f['type']) : 'text'; if ( ! in_array($type, $allowed, true) ) $type = 'text'; $name = isset($f['name']) ? sanitize_text_field($f['name']) : ''; if ( $name === '' ) continue; $key = isset($f['key']) ? sanitize_key($f['key']) : ''; if ( $key === '' ) $key = sanitize_key( strtolower( preg_replace('/\s+/', '_', $name ) ) ); if ( $key === '' ) continue; $base = $key; $i=2; while ( isset($seen[$key]) ) { $key = $base . '_' . $i; $i++; } $seen[$key] = true; $box3 = isset($f['box3']) ? trim((string)$f['box3']) : ''; $info = isset($f['info']) ? trim((string)$f['info']) : ''; $required = ( isset($f['required']) && $f['required'] === 'yes' ) ? 'yes' : 'no'; if ( $type === 'select' || $type === 'radio' ) { $box3 = str_replace( array("\r\n","\r"), "\n", $box3 ); $lines = array_filter( array_map('trim', explode("\n", $box3)) ); $box3 = implode("\n", $lines ); } if ( $type === 'file' ) { $box3 = strtolower($box3); $box3 = preg_replace('/[^a-z0-9,\s]/', '', $box3); $box3 = preg_replace('/\s+/', '', $box3); } // Conditions $conds_clean = array(); if ( isset($f['conditions']) && is_array($f['conditions']) ) { foreach ( $f['conditions'] as $c ) { $action = isset($c['action']) ? sanitize_key($c['action']) : 'show'; if ( ! in_array($action, $allowed_actions, true) ) $action = 'show'; $source = isset($c['source']) ? sanitize_text_field($c['source']) : ''; $source = trim($source); $op = isset($c['op']) ? sanitize_key($c['op']) : 'is'; if ( ! in_array($op, $allowed_ops, true) ) $op = 'is'; $value = isset($c['value']) ? sanitize_text_field($c['value']) : ''; $value = trim($value); // allow "file operators" without value if ( $op === 'has_file' || $op === 'no_file' ) $value = ''; // if no source, skip if ( $source === '' ) continue; $conds_clean[] = array( 'action' => $action, 'source' => $source, 'op' => $op, 'value' => $value, ); } } $clean[] = array( 'type' => $type, 'name' => $name, 'box3' => $box3, 'required' => $required, 'info' => $info, 'key' => $key, 'conditions' => $conds_clean, ); } $product->update_meta_data( MERCHATURA_FIELDS_META_KEY_V4, $clean ); } ); /* ========================================================= * Helpers * ========================================================= */ function merchatura_v41_get_fields( $product_id ) { $fields = get_post_meta( $product_id, MERCHATURA_FIELDS_META_KEY_V4, true ); return is_array($fields) ? $fields : array(); } function merchatura_v42_get_attr_post_value( $product_id, $source_id ) { // source_id looks like "attr_pa_color" OR "attr_custom" // match it to posted attribute_* $product = wc_get_product( $product_id ); if ( ! $product ) return ''; $attributes = $product->get_attributes(); foreach ( $attributes as $attr ) { if ( ! is_a( $attr, 'WC_Product_Attribute' ) ) continue; $name = $attr->get_name(); $expected = $attr->is_taxonomy() ? 'attr_' . wc_sanitize_taxonomy_name( $name ) : 'attr_' . sanitize_key( $name ); if ( $expected !== $source_id ) continue; // Posted field name: $posted_name = $attr->is_taxonomy() ? 'attribute_' . wc_sanitize_taxonomy_name( $name ) // e.g. attribute_pa_color : 'attribute_' . sanitize_key( $name ); $val = isset($_POST[$posted_name]) ? (string) wp_unslash($_POST[$posted_name]) : ''; $val = trim($val); // If the post value is a slug for taxonomy terms, Woo will post the term slug. // But in our admin we stored term NAMES for options. Many sites use names; some use slugs. // We'll attempt to compare both by also translating slug->name where possible. if ( $val !== '' && $attr->is_taxonomy() ) { $term = get_term_by( 'slug', $val, $name ); if ( $term && ! is_wp_error($term) && ! empty($term->name) ) { // Return both possibilities via a marker; evaluation will check exact and contains against the returned string, // so we return the NAME primarily. return (string) $term->name; } } return $val; } return ''; } function merchatura_v42_condition_matches( $product_id, $all_fields, $cond ) { $source = isset($cond['source']) ? (string) $cond['source'] : ''; $op = isset($cond['op']) ? (string) $cond['op'] : 'is'; $value = isset($cond['value']) ? (string) $cond['value'] : ''; $source = trim($source); $value = trim($value); // Source can be: // - attr_* (product attribute) // - field_* (custom field key) if ( strpos($source, 'attr_') === 0 ) { $left = merchatura_v42_get_attr_post_value( $product_id, $source ); } elseif ( strpos($source, 'field_') === 0 ) { $key = substr($source, 6); $field_name = 'merchatura_cf_' . sanitize_key($key); // Find the field type (for file ops) $srcType = 'text'; foreach ( $all_fields as $f ) { if ( ! empty($f['key']) && $f['key'] === $key ) { $srcType = $f['type'] ?? 'text'; break; } } if ( $srcType === 'file' ) { $raw = isset($_POST[$field_name]) ? (string) wp_unslash($_POST[$field_name]) : ''; $arr = json_decode( $raw, true ); $has = ( is_array($arr) && ! empty($arr) ); if ( $op === 'has_file' ) return $has; if ( $op === 'no_file' ) return ! $has; // fallback to text comparisons if someone configured wrong op $left = $has ? '1' : ''; } else { $left = isset($_POST[$field_name]) ? (string) wp_unslash($_POST[$field_name]) : ''; $left = trim($left); } } else { return false; } // Operator evaluation if ( $op === 'has_file' ) return ( trim($left) !== '' ); if ( $op === 'no_file' ) return ( trim($left) === '' ); if ( $op === 'contains' ) { if ( $value === '' ) return false; return ( stripos( (string) $left, (string) $value ) !== false ); } if ( $op === 'is_not' ) { return ( (string) $left !== (string) $value ); } // default: is return ( (string) $left === (string) $value ); } function merchatura_v42_field_is_visible_from_post( $product_id, $field, $all_fields ) { $conds = isset($field['conditions']) && is_array($field['conditions']) ? $field['conditions'] : array(); if ( empty($conds) ) return true; // AND logic $allMatch = true; foreach ( $conds as $c ) { if ( ! merchatura_v42_condition_matches( $product_id, $all_fields, $c ) ) { $allMatch = false; break; } } // Apply action using FIRST rule's action as the field's action (consistent with UI "Show/Hide if …") // BUT your UI allows action per rule, so we apply each rule's action individually: // - If any rule is "hide" and that rule matches, hide wins. // - Else if there is any "show" rule, field only shows if ALL show-rules match (already in $allMatch). $anyHideMatch = false; $hasShowRule = false; $allShowMatch = true; foreach ( $conds as $c ) { $action = ($c['action'] ?? 'show') === 'hide' ? 'hide' : 'show'; $match = merchatura_v42_condition_matches( $product_id, $all_fields, $c ); if ( $action === 'hide' && $match ) $anyHideMatch = true; if ( $action === 'show' ) { $hasShowRule = true; if ( ! $match ) $allShowMatch = false; } } if ( $anyHideMatch ) return false; if ( $hasShowRule ) return $allShowMatch; // only hide rules exist and none matched return true; } /* ========================================================= * Front-end uploader JS (product only, only if file field exists) * (your existing v4.1 uploader remains, unchanged) * ========================================================= */ add_action( 'wp_footer', function() { if ( ! is_product() ) return; global $product; if ( ! $product ) return; $fields = merchatura_v41_get_fields( $product->get_id() ); $has_file = false; foreach ( $fields as $f ) { if ( ($f['type'] ?? '') === 'file' ) { $has_file = true; break; } } if ( ! $has_file ) return; $nonce = wp_create_nonce( 'merchatura_v41_upload' ); ?> get_id() ); if ( empty($fields) ) return; echo '
'; foreach ( $fields as $f ) { $type = $f['type'] ?? 'text'; $name = $f['name'] ?? ''; $key = $f['key'] ?? ''; $box3 = $f['box3'] ?? ''; $info = $f['info'] ?? ''; $required = ( (($f['required'] ?? 'no') === 'yes') ); $conditions = ( isset($f['conditions']) && is_array($f['conditions']) ) ? $f['conditions'] : array(); if ( ! $name || ! $key ) continue; $field_name = 'merchatura_cf_' . $key; $field_id = 'merchatura_cf_' . $key; $conds_attr = ! empty($conditions) ? esc_attr( wp_json_encode( $conditions ) ) : ''; echo '
'; echo ''; if ( $type === 'textarea' ) { echo ''; } elseif ( $type === 'select' ) { $options = array_filter( array_map('trim', explode("\n", str_replace(array("\r\n","\r"), "\n", (string)$box3))) ); echo ''; } elseif ( $type === 'radio' ) { $options = array_filter( array_map('trim', explode("\n", str_replace(array("\r\n","\r"), "\n", (string)$box3))) ); echo '
'; foreach ($options as $i => $opt) { $rid = $field_id . '_opt_' . $i; echo ''; } echo '
'; } elseif ( $type === 'file' ) { $hidden_id = 'mcf_file_hidden_' . $key; echo ''; $accept = ''; if ( ! empty($box3) ) { $parts = array_filter(array_map('trim', explode(',', $box3))); $parts = array_map(function($p){ return '.' . ltrim($p, '.'); }, $parts); $accept = implode(',', $parts); } echo '
'; echo '
'; echo '
'; echo '
'; echo '
Drop Files Here, Or
'; echo ''; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; } else { $input_type = ( $type === 'email' ) ? 'email' : 'text'; echo ''; } echo '
'; // .mcf-row } echo '
'; } ); /* ========================================================= * Front-end: Conditional logic runner (attributes + fields) * ========================================================= */ add_action( 'wp_footer', function() { if ( ! is_product() ) return; ?> 'Invalid security token.' ), 403 ); } if ( empty($_FILES['file']) || ! isset($_FILES['file']['tmp_name']) ) { wp_send_json_error( array( 'message' => 'No file received.' ), 400 ); } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $file = $_FILES['file']; if ( ! empty($file['size']) && (int)$file['size'] > 20 * 1024 * 1024 ) { wp_send_json_error( array( 'message' => 'File too large.' ), 400 ); } $overrides = array( 'test_form' => false ); $uploaded = wp_handle_upload( $file, $overrides ); if ( isset($uploaded['error']) ) { wp_send_json_error( array( 'message' => $uploaded['error'] ), 400 ); } $attachment = array( 'post_mime_type' => $uploaded['type'], 'post_title' => sanitize_file_name( $file['name'] ), 'post_content' => '', 'post_status' => 'inherit' ); $attach_id = wp_insert_attachment( $attachment, $uploaded['file'] ); if ( is_wp_error($attach_id) ) { wp_send_json_error( array( 'message' => 'Could not create attachment.' ), 500 ); } $attach_data = wp_generate_attachment_metadata( $attach_id, $uploaded['file'] ); wp_update_attachment_metadata( $attach_id, $attach_data ); wp_send_json_success( array( 'attachment_id' => $attach_id, 'url' => $uploaded['url'], 'original_name' => $file['name'], ) ); } /* ========================================================= * Validation (required + email + required file: at least 1) * ✅ Updated: hidden fields are ignored based on conditions * ========================================================= */ add_filter( 'woocommerce_add_to_cart_validation', function( $passed, $product_id, $quantity ) { $fields = merchatura_v41_get_fields( $product_id ); if ( empty($fields) ) return $passed; foreach ( $fields as $f ) { $type = $f['type'] ?? 'text'; $name = $f['name'] ?? ''; $key = $f['key'] ?? ''; $required = ( ($f['required'] ?? 'no') === 'yes' ); if ( ! $name || ! $key ) continue; // If field is hidden due to conditional logic, ignore it (including required) if ( ! merchatura_v42_field_is_visible_from_post( $product_id, $f, $fields ) ) { continue; } $field_name = 'merchatura_cf_' . $key; $raw = isset($_POST[$field_name]) ? trim((string) wp_unslash($_POST[$field_name])) : ''; if ( $type === 'file' ) { $filesArr = array(); if ($raw !== '') { $decoded = json_decode($raw, true); if ( is_array($decoded) ) $filesArr = $decoded; } if ( $required && empty($filesArr) ) { wc_add_notice( sprintf( __( 'Please upload at least one file for: %s', 'merchatura' ), $name ), 'error' ); return false; } continue; } if ( $required && $raw === '' ) { wc_add_notice( sprintf( __( 'Please complete: %s', 'merchatura' ), $name ), 'error' ); return false; } if ( $raw !== '' && $type === 'email' && ! is_email($raw) ) { wc_add_notice( sprintf( __( 'Please enter a valid email for: %s', 'merchatura' ), $name ), 'error' ); return false; } } return $passed; }, 20, 3 ); /* ========================================================= * Save into cart item data * ✅ Updated: hidden fields are not saved * ========================================================= */ add_filter( 'woocommerce_add_cart_item_data', function( $cart_item_data, $product_id ) { $fields = merchatura_v41_get_fields( $product_id ); if ( empty($fields) ) return $cart_item_data; $values = array(); foreach ( $fields as $f ) { $type = $f['type'] ?? 'text'; $name = $f['name'] ?? ''; $key = $f['key'] ?? ''; if ( ! $name || ! $key ) continue; // If field hidden, skip saving if ( ! merchatura_v42_field_is_visible_from_post( $product_id, $f, $fields ) ) { continue; } $field_name = 'merchatura_cf_' . $key; if ( ! isset($_POST[$field_name]) ) continue; $raw = trim((string) wp_unslash($_POST[$field_name])); if ( $raw === '' ) continue; if ( $type === 'file' ) { $decoded = json_decode($raw, true); if ( ! is_array($decoded) || empty($decoded) ) continue; $links = array(); foreach ( $decoded as $file ) { $url = isset($file['url']) ? esc_url_raw($file['url']) : ''; $nm = isset($file['name']) ? sanitize_text_field($file['name']) : 'File'; if ( $url ) $links[] = array('url' => $url, 'name' => $nm); } if ( empty($links) ) continue; $values[] = array( 'key' => $key, 'label' => $name, 'type' => 'file', 'value' => $links, ); } else { $val = ( $type === 'email' ) ? sanitize_email($raw) : sanitize_text_field($raw); $values[] = array( 'key' => $key, 'label' => $name, 'type' => $type, 'value' => $val, ); } } if ( ! empty($values) ) { $cart_item_data['merchatura_custom_fields'] = $values; $cart_item_data['merchatura_custom_fields_key'] = md5( wp_json_encode($values) ); } return $cart_item_data; }, 20, 2 ); /* ========================================================= * Display in cart + checkout (unchanged) * ========================================================= */ add_filter( 'woocommerce_get_item_data', function( $item_data, $cart_item ) { if ( empty($cart_item['merchatura_custom_fields']) || ! is_array($cart_item['merchatura_custom_fields']) ) return $item_data; foreach ( $cart_item['merchatura_custom_fields'] as $f ) { $label = $f['label'] ?? ''; $type = $f['type'] ?? ''; $value = $f['value'] ?? ''; if ( $type === 'file' && is_array($value) ) { $links = array(); foreach ($value as $file) { $url = isset($file['url']) ? $file['url'] : ''; $nm = isset($file['name']) ? $file['name'] : 'File'; if($url) $links[] = '' . esc_html($nm) . ''; } $item_data[] = array( 'name' => $label, 'value' => implode('
', $links), ); } else { $item_data[] = array( 'name' => $label, 'value' => esc_html($value), ); } } return $item_data; }, 20, 2 ); /* ========================================================= * Save to order item meta (unchanged) * ========================================================= */ add_action( 'woocommerce_checkout_create_order_line_item', function( $item, $cart_item_key, $values, $order ) { if ( empty($values['merchatura_custom_fields']) || ! is_array($values['merchatura_custom_fields']) ) return; foreach ( $values['merchatura_custom_fields'] as $f ) { $label = $f['label'] ?? ''; $type = $f['type'] ?? ''; $value = $f['value'] ?? ''; if ( $type === 'file' && is_array($value) ) { $urls = array(); foreach ($value as $file) { if ( ! empty($file['url']) ) $urls[] = esc_url_raw($file['url']); } if ( $label && ! empty($urls) ) { $item->add_meta_data( $label, implode("\n", $urls), true ); } } elseif ( $label && $value ) { $item->add_meta_data( $label, sanitize_text_field($value), true ); } } }, 20, 4 ); /* ========================================================= * v4.1 ADD-ON: Export / Import / Copy field settings * ✅ Updated: conditions included automatically (already part of field array) * ========================================================= */ add_action('admin_footer', function () { $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen || $screen->id !== 'product') return; global $post; if (!$post || $post->post_type !== 'product') return; $nonce = wp_create_nonce('merchatura_cf_tools'); ?> 'Permission denied.'), 403); } if ( ! isset($_POST['nonce']) || ! wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'merchatura_cf_tools') ) { wp_send_json_error(array('message' => 'Bad nonce.'), 403); } $product_id = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0; if ( $product_id <= 0 ) wp_send_json_error(array('message' => 'Invalid product.'), 400); $fields = get_post_meta($product_id, MERCHATURA_FIELDS_META_KEY_V4, true); if ( ! is_array($fields) ) $fields = array(); wp_send_json_success(array('fields' => $fields)); }); /* --------------------------- * AJAX: Import (overwrite) * --------------------------- */ add_action('wp_ajax_merchatura_cf_import_fields', function(){ if ( ! current_user_can('edit_products') ) { wp_send_json_error(array('message' => 'Permission denied.'), 403); } if ( ! isset($_POST['nonce']) || ! wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'merchatura_cf_tools') ) { wp_send_json_error(array('message' => 'Bad nonce.'), 403); } $product_id = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0; $json = isset($_POST['json']) ? wp_unslash($_POST['json']) : ''; if ( $product_id <= 0 ) wp_send_json_error(array('message' => 'Invalid product.'), 400); if ( trim($json) === '' ) wp_send_json_error(array('message' => 'No JSON provided.'), 400); $decoded = json_decode($json, true); if ( ! is_array($decoded) ) { wp_send_json_error(array('message' => 'Invalid JSON.'), 400); } $allowed = array('text','textarea','email','select','radio','file'); $allowed_ops = array( 'is','is_not','contains','has_file','no_file' ); $allowed_actions = array( 'show','hide' ); $clean = array(); $seen = array(); foreach ($decoded as $f) { $type = isset($f['type']) ? sanitize_key($f['type']) : 'text'; if ( ! in_array($type, $allowed, true) ) $type = 'text'; $name = isset($f['name']) ? sanitize_text_field($f['name']) : ''; if ($name === '') continue; $key = isset($f['key']) ? sanitize_key($f['key']) : ''; if ($key === '') $key = sanitize_key(strtolower(preg_replace('/\s+/', '_', $name))); if ($key === '') continue; $base = $key; $i = 2; while (isset($seen[$key])) { $key = $base . '_' . $i; $i++; } $seen[$key] = true; $box3 = isset($f['box3']) ? trim((string)$f['box3']) : ''; $info = isset($f['info']) ? trim((string)$f['info']) : ''; $required = (isset($f['required']) && $f['required'] === 'yes') ? 'yes' : 'no'; if ($type === 'select' || $type === 'radio') { $box3 = str_replace(array("\r\n","\r"), "\n", $box3); $lines = array_filter(array_map('trim', explode("\n", $box3))); $box3 = implode("\n", $lines); } if ($type === 'file') { $box3 = strtolower($box3); $box3 = preg_replace('/[^a-z0-9,\s]/', '', $box3); $box3 = preg_replace('/\s+/', '', $box3); } $conds_clean = array(); if ( isset($f['conditions']) && is_array($f['conditions']) ) { foreach ( $f['conditions'] as $c ) { $action = isset($c['action']) ? sanitize_key($c['action']) : 'show'; if ( ! in_array($action, $allowed_actions, true) ) $action = 'show'; $source = isset($c['source']) ? sanitize_text_field($c['source']) : ''; $source = trim($source); $op = isset($c['op']) ? sanitize_key($c['op']) : 'is'; if ( ! in_array($op, $allowed_ops, true) ) $op = 'is'; $value = isset($c['value']) ? sanitize_text_field($c['value']) : ''; $value = trim($value); if ( $op === 'has_file' || $op === 'no_file' ) $value = ''; if ( $source === '' ) continue; $conds_clean[] = array( 'action' => $action, 'source' => $source, 'op' => $op, 'value' => $value, ); } } $clean[] = array( 'type' => $type, 'name' => $name, 'box3' => $box3, 'required' => $required, 'info' => $info, 'key' => $key, 'conditions' => $conds_clean, ); } update_post_meta($product_id, MERCHATURA_FIELDS_META_KEY_V4, $clean); wp_send_json_success(array('message' => 'Imported.')); }); /* --------------------------- * AJAX: Search products by title * --------------------------- */ add_action('wp_ajax_merchatura_cf_search_products', function(){ if ( ! current_user_can('edit_products') ) { wp_send_json_error(array('message' => 'Permission denied.'), 403); } if ( ! isset($_POST['nonce']) || ! wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'merchatura_cf_tools') ) { wp_send_json_error(array('message' => 'Bad nonce.'), 403); } $q = isset($_POST['q']) ? sanitize_text_field($_POST['q']) : ''; $q = trim($q); if ( strlen($q) < 2 ) wp_send_json_success(array('items' => array())); $args = array( 'post_type' => 'product', 'post_status' => array('publish','private','draft'), 's' => $q, 'posts_per_page' => 15, 'fields' => 'ids', ); $ids = get_posts($args); $items = array(); foreach ($ids as $id) { $title = get_the_title($id); if (!$title) continue; $items[] = array( 'id' => (int) $id, 'text' => $title . ' (ID: ' . (int)$id . ')' ); } wp_send_json_success(array('items' => $items)); }); /* --------------------------- * AJAX: Copy fields from another product (overwrite) * --------------------------- */ add_action('wp_ajax_merchatura_cf_copy_fields', function(){ if ( ! current_user_can('edit_products') ) { wp_send_json_error(array('message' => 'Permission denied.'), 403); } if ( ! isset($_POST['nonce']) || ! wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'merchatura_cf_tools') ) { wp_send_json_error(array('message' => 'Bad nonce.'), 403); } $product_id = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0; $from_id = isset($_POST['from_product_id']) ? (int) $_POST['from_product_id'] : 0; if ($product_id <= 0 || $from_id <= 0) { wp_send_json_error(array('message' => 'Invalid product IDs.'), 400); } $fields = get_post_meta($from_id, MERCHATURA_FIELDS_META_KEY_V4, true); if ( ! is_array($fields) ) $fields = array(); update_post_meta($product_id, MERCHATURA_FIELDS_META_KEY_V4, $fields); wp_send_json_success(array('message' => 'Copied.')); });

Comments

Add a Comment