if (!defined('ABSPATH')) exit; final class MVD_Core_Experience_Sheets { const VERSION = '4.19.0'; const META_SHEET = '_mvd_exp_sheet'; const META_UUID = '_mvd_exp_uuid'; const META_VERSION = '_mvd_exp_version'; const META_LAST_SYNC = '_mvd_exp_last_sync'; const META_SYNC_ERROR = '_mvd_exp_sync_error'; const CAT_SLUG = 'esperienze-mvd'; const REST_NS = 'mvd-experience/v1'; const SIGNATURE_MAX_SKEW = 600; private static $instance; private $applying_remote = false; private $updating_core_meta = false; public static function instance() { if (!self::$instance) self::$instance = new self(); return self::$instance; } private function __construct() { add_action('add_meta_boxes', [$this,'add_meta_boxes'], 30); add_action('save_post_mvd_item', [$this,'save_core_item'], 30, 2); add_action('mvd_catalog_item_upserted', [$this,'capture_core_payload'], 30, 2); add_filter('mvd_item_array_payload', [$this,'augment_core_item'], 30, 2); add_filter('template_include', [$this,'core_template'], 99); add_action('admin_notices', [$this,'admin_notices']); add_shortcode('mvd_exp', [$this,'shortcode_field']); add_shortcode('mvd_exp_badge', [$this,'shortcode_badge']); add_shortcode('mvd_exp_activity_info', [$this,'shortcode_activity_info']); add_shortcode('mvd_exp_highlights', [$this,'shortcode_highlights']); add_shortcode('mvd_exp_itinerary', [$this,'shortcode_itinerary']); add_shortcode('mvd_exp_included', [$this,'shortcode_included']); add_shortcode('mvd_exp_meeting_points', [$this,'shortcode_meeting_points']); add_shortcode('mvd_exp_food', [$this,'shortcode_food']); add_shortcode('mvd_exp_what_to_bring', [$this,'shortcode_what_to_bring']); add_shortcode('mvd_exp_pricing', [$this,'shortcode_pricing']); add_shortcode('mvd_experience_sheet', [$this,'shortcode_full_sheet']); } public static function activate() { if (!term_exists(self::CAT_SLUG, 'category')) wp_insert_term('Esperienze MVD', 'category', ['slug'=>self::CAT_SLUG]); } public function ensure_category() { if (is_admin() && current_user_can('manage_categories') && !term_exists(self::CAT_SLUG,'category')) { wp_insert_term('Esperienze MVD','category',['slug'=>self::CAT_SLUG]); } } private function is_core() { return class_exists('MVD_Network_Core'); } private function is_connector() { return class_exists('MVD_Network_Connector') || get_option('mvd_connector_settings', false) !== false; } private function default_sheet() { return [ 'schema'=>'mvd-experience-sheet/1', 'base'=>[ 'code'=>'','commercial_title'=>'','subtitle'=>'','badge'=>'','day_type'=>'', 'short_description'=>'','full_description'=>'','duration_minutes'=>'','start_time'=>'','return_time'=>'', 'languages'=>'','audio_guide'=>'','accessibility_status'=>'','accessibility_notes'=>'', 'municipality'=>'','latitude'=>'','longitude'=>'','seasonality'=>'','availability_notes'=>'', 'cancellation_free_days'=>'','payment_mode'=>'','related_experience_url'=>'','image_url'=>'','gallery_urls'=>'','video_url'=>'', 'min_participants'=>'','max_participants'=>'','age_min'=>'','age_max'=>'','notes'=>'', ], 'activity_info'=>[], 'highlights'=>[], 'itinerary'=>[], 'included'=>[], 'meeting_points'=>[], 'food'=>[], 'what_to_bring'=>[], 'pricing'=>[], 'benefits'=>[], 'rules'=>[], ]; } private function get_sheet($post_id) { $sheet = get_post_meta($post_id, self::META_SHEET, true); if (!is_array($sheet)) $sheet = []; return $this->normalize_sheet($sheet); } private function normalize_sheet($sheet) { $d=$this->default_sheet(); if (!is_array($sheet)) return $d; $out=$d; $out['schema']='mvd-experience-sheet/1'; $base=is_array($sheet['base']??null)?$sheet['base']:[]; foreach($d['base'] as $k=>$v) $out['base'][$k]=isset($base[$k])?(string)$base[$k]:''; foreach(['activity_info','highlights','itinerary','included','meeting_points','food','what_to_bring','pricing','benefits','rules'] as $group){ $out[$group]=[]; foreach((array)($sheet[$group]??[]) as $row){ if(!is_array($row))continue; $clean=[];foreach($row as $k=>$v){$clean[sanitize_key($k)]=is_scalar($v)?(string)$v:'';} if(array_filter($clean,function($v){return trim((string)$v)!=='';}))$out[$group][]=$clean; } } return $out; } private function sanitize_sheet($raw) { $raw=is_array($raw)?wp_unslash($raw):[]; $sheet=$this->normalize_sheet($raw); foreach($sheet['base'] as $k=>$v){ if(in_array($k,['short_description','full_description','accessibility_notes','availability_notes','notes'],true))$sheet['base'][$k]=wp_kses_post($v); elseif(in_array($k,['related_experience_url','image_url','video_url'],true))$sheet['base'][$k]=esc_url_raw($v); elseif($k==='gallery_urls'){ $urls=preg_split('/[\r\n,]+/',(string)$v);$urls=array_values(array_filter(array_map('esc_url_raw',$urls)));$sheet['base'][$k]=implode("\n",$urls); } else $sheet['base'][$k]=sanitize_text_field($v); } $textarea_keys=['text','note','notes','description','value','value_template','condition','services','reference']; $url_keys=['icon_url','image_url','map_url','url']; foreach(['activity_info','highlights','itinerary','included','meeting_points','food','what_to_bring','pricing','benefits','rules'] as $group){ foreach($sheet[$group] as &$row){ foreach($row as $k=>$v){ if(in_array($k,$url_keys,true))$row[$k]=esc_url_raw($v); elseif(in_array($k,$textarea_keys,true))$row[$k]=wp_kses_post($v); else $row[$k]=sanitize_text_field($v); } if(empty($row['row_id']))$row['row_id']='row_'.wp_generate_uuid4(); } unset($row); } return $sheet; } public function add_meta_boxes() { if(post_type_exists('mvd_item')) add_meta_box('mvd_exp_sheet_core','Scheda Esperienza · sorgente merchant',[$this,'core_meta_box'],'mvd_item','normal','high'); } public function merchant_meta_box($post) { $active=has_category(self::CAT_SLUG,$post); $sheet=$this->get_sheet($post->ID);$this->editor_sheet_cache=$sheet;$version=max(1,absint(get_post_meta($post->ID,self::META_VERSION,true))); wp_nonce_field('mvd_exp_sheet_save','mvd_exp_sheet_nonce'); echo '
'; echo '

Stato: '.($active?'Esperienza MVD attiva':'Aggiungi la categoria '.esc_html(self::CAT_SLUG).' per sincronizzarla con MVD.').'   Versione: '.esc_html($version).'

'; $this->render_editor($sheet,'mvd_exp_sheet'); echo '
Importa / esporta con ChatGPT, Excel o Google Sheets

Incolla un bundle TSV MVD. Se il campo non è vuoto, al prossimo salvataggio dell’articolo sostituisce la scheda corrente.

Export attuale

'; echo '
';$this->admin_assets(); } public function core_meta_box($post) { if(get_post_meta($post->ID,'_mvd_item_type',true)!=='experience'){echo '

Questo elemento non è un’Esperienza.

';return;} $sheet=$this->get_sheet($post->ID);$this->editor_sheet_cache=$sheet;if(!$this->sheet_has_data($sheet)){echo '

Il merchant non ha ancora inviato una Scheda Esperienza strutturata.

';return;} wp_nonce_field('mvd_exp_core_save','mvd_exp_core_nonce'); echo '

Merchant: '.esc_html(get_post_meta($post->ID,'_mvd_merchant_id',true)).'   Versione sorgente: '.esc_html(absint(get_post_meta($post->ID,self::META_VERSION,true))).'

'; echo '

Le modifiche salvate qui vengono inviate al merchant. Se il merchant ha già una versione più recente, l’aggiornamento viene bloccato per evitare sovrascritture.

'; $this->render_editor($sheet,'mvd_exp_sheet'); $this->admin_assets(); } private function render_editor($s,$name) { $b=$s['base']; echo ''; echo '

Identità e dati base

'; $this->input($name,'base','code','Codice esperienza',$b['code']);$this->input($name,'base','commercial_title','Titolo commerciale',$b['commercial_title']);$this->input($name,'base','subtitle','Sottotitolo',$b['subtitle']); $this->input($name,'base','badge','Badge',$b['badge']);$this->input($name,'base','day_type','Tipologia',$b['day_type']);$this->input($name,'base','duration_minutes','Durata (minuti)',$b['duration_minutes'],'number'); $this->input($name,'base','start_time','Ora partenza',$b['start_time']);$this->input($name,'base','return_time','Rientro',$b['return_time']);$this->input($name,'base','languages','Lingue (es. it,en,fr)',$b['languages']); $this->input($name,'base','audio_guide','Audioguida',$b['audio_guide']);$this->input($name,'base','accessibility_status','Accessibilità',$b['accessibility_status']);$this->input($name,'base','municipality','Comune',$b['municipality']); $this->input($name,'base','latitude','Latitudine',$b['latitude']);$this->input($name,'base','longitude','Longitudine',$b['longitude']);$this->input($name,'base','seasonality','Stagionalità',$b['seasonality']); $this->input($name,'base','cancellation_free_days','Cancellazione gratuita fino a (giorni)',$b['cancellation_free_days'],'number');$this->input($name,'base','payment_mode','Modalità pagamento',$b['payment_mode']);$this->input($name,'base','related_experience_url','Esperienza collegata URL',$b['related_experience_url'],'url'); $this->input($name,'base','min_participants','Partecipanti min',$b['min_participants'],'number');$this->input($name,'base','max_participants','Partecipanti max',$b['max_participants'],'number');$this->input($name,'base','age_min','Età min generale',$b['age_min'],'number'); $this->input($name,'base','age_max','Età max generale',$b['age_max'],'number');$this->input($name,'base','image_url','Immagine principale URL',$b['image_url'],'url');$this->input($name,'base','video_url','Video URL',$b['video_url'],'url'); $this->textarea($name,'base','gallery_urls','Gallery URL · uno per riga',$b['gallery_urls'],'wide');$this->textarea($name,'base','short_description','Descrizione breve',$b['short_description'],'wide');$this->textarea($name,'base','full_description','Descrizione generale',$b['full_description'],'wide'); $this->textarea($name,'base','accessibility_notes','Note accessibilità',$b['accessibility_notes'],'wide');$this->textarea($name,'base','availability_notes','Disponibilità / note calendario',$b['availability_notes'],'wide');$this->textarea($name,'base','notes','Note generali',$b['notes'],'wide'); echo '
'; $this->repeater($name,'activity_info','Informazioni sull’attività',['icon_url'=>'Icona URL','title'=>'Titolo','value_template'=>'Valore / frase (token consentiti)','note'=>'Nota']); $this->repeater($name,'highlights','In evidenza',['text'=>'Testo']); $this->repeater($name,'itinerary','Descrizione completa / Itinerario',['kind'=>'Tipo','icon_url'=>'Icona URL','title'=>'Titolo','location'=>'Luogo','address'=>'Indirizzo','map_url'=>'Maps URL','duration_min'=>'Durata min','distance_km'=>'Distanza km','transfer_min'=>'Transfer min','transport'=>'Mezzo','variant'=>'Variante','description'=>'Descrizione','image_url'=>'Immagine URL']); $this->repeater($name,'included','Cosa è incluso',['status'=>'Stato included/excluded','text'=>'Voce','icon_url'=>'Icona URL']); $this->repeater($name,'meeting_points','Punti di incontro',['condition'=>'Condizione/opzione','location'=>'Località','address'=>'Indirizzo','reference'=>'Riferimento','advance_min'=>'Anticipo min','latitude'=>'Lat','longitude'=>'Lng','parking'=>'Parcheggio','parking_free'=>'Gratuito sì/no','services'=>'Servizi','notes'=>'Note']); $this->repeater($name,'food','Dove mangiare',['name'=>'Nome','type'=>'Tipo','location'=>'Località','url'=>'URL','phone'=>'Telefono','booking_required'=>'Prenotazione sì/no','condition'=>'Condizione','notes'=>'Note']); $this->repeater($name,'what_to_bring','Come vestire / Cosa portare',['icon_url'=>'Icona URL','title'=>'Voce','note'=>'Nota']); $this->repeater($name,'pricing','Tariffe',['audience'=>'Pubblico','age_min'=>'Età min','age_max'=>'Età max','pax_min'=>'Pax min','pax_max'=>'Pax max','price'=>'Prezzo','unit'=>'Unità','currency'=>'Valuta','note'=>'Note']); $this->repeater($name,'benefits','Gratuità / agevolazioni',['condition'=>'Condizione','price'=>'Prezzo','discount'=>'Sconto','note'=>'Note']); $this->repeater($name,'rules','Regole / condizioni',['type'=>'Tipo','from'=>'Da','to'=>'A','value'=>'Valore','text'=>'Testo']); } private function input($name,$group,$key,$label,$value,$type='text') { echo ''; } private function textarea($name,$group,$key,$label,$value,$class='') { echo ''; } private function repeater($name,$group,$title,$cols) { $s=$this->current_editor_sheet();$rows=$s[$group]??[]; echo '
'.esc_html($title).'
'; foreach($cols as $label)echo'';echo''; foreach($rows as $i=>$row)$this->repeater_row($name,$group,$i,$row,$cols); echo '
'.esc_html($label).'
'; echo '
'; } private $editor_sheet_cache=null; private function current_editor_sheet(){return is_array($this->editor_sheet_cache)?$this->editor_sheet_cache:$this->default_sheet();} private function repeater_row($name,$group,$i,$row,$cols){echo'';foreach($cols as $key=>$label){$v=$row[$key]??'';$islong=in_array($key,['description','note','notes','text','services','condition','reference','value_template','value'],true);echo''.($islong?'':'').'';}echo'';} private function admin_assets(){ echo ''; } public function save_merchant_post($post_id,$post) { if($this->applying_remote||wp_is_post_revision($post_id)||defined('DOING_AUTOSAVE')&&DOING_AUTOSAVE)return; if(!isset($_POST['mvd_exp_sheet_nonce'])||!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['mvd_exp_sheet_nonce'])),'mvd_exp_sheet_save')||!current_user_can('edit_post',$post_id))return; $raw=$_POST['mvd_exp_sheet']??[];$import=trim((string)wp_unslash($_POST['mvd_exp_import']??'')); if($import!==''){$parsed=$this->parse_tsv($import);if(!is_wp_error($parsed))$raw=$parsed;else update_post_meta($post_id,self::META_SYNC_ERROR,'Import TSV: '.$parsed->get_error_message());} $sheet=$this->sanitize_sheet($raw);$this->editor_sheet_cache=$sheet; if(!get_post_meta($post_id,self::META_UUID,true))update_post_meta($post_id,self::META_UUID,wp_generate_uuid4()); $ver=max(0,absint(get_post_meta($post_id,self::META_VERSION,true)))+1;update_post_meta($post_id,self::META_VERSION,$ver);update_post_meta($post_id,self::META_SHEET,$sheet); if(has_category(self::CAT_SLUG,$post_id))$this->push_merchant_to_core($post_id,$sheet,$ver); } public function save_core_item($post_id,$post) { if($this->updating_core_meta)return; if(wp_is_post_revision($post_id)||defined('DOING_AUTOSAVE')&&DOING_AUTOSAVE)return; if(!isset($_POST['mvd_exp_core_nonce'])||!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['mvd_exp_core_nonce'])),'mvd_exp_core_save')||!current_user_can('edit_post',$post_id))return; if(get_post_meta($post_id,'_mvd_item_type',true)!=='experience')return; $sheet=$this->sanitize_sheet($_POST['mvd_exp_sheet']??[]);$base_version=absint(get_post_meta($post_id,self::META_VERSION,true)); $r=$this->push_core_to_merchant($post_id,$sheet,$base_version); if(is_wp_error($r)){update_post_meta($post_id,self::META_SYNC_ERROR,$r->get_error_message());update_post_meta($post_id,'_mvd_exp_pending_sheet',$sheet);return;} $sheet=$this->normalize_sheet($r['sheet']??$sheet);update_post_meta($post_id,self::META_SHEET,$sheet);update_post_meta($post_id,self::META_VERSION,absint($r['version']??($base_version+1)));delete_post_meta($post_id,self::META_SYNC_ERROR);delete_post_meta($post_id,'_mvd_exp_pending_sheet');$this->sync_standard_core_meta($post_id,$sheet); } public function admin_notices(){global $post;if(!$post||!current_user_can('edit_post',$post->ID))return;$err=get_post_meta($post->ID,self::META_SYNC_ERROR,true);if($err)echo'

Scheda Esperienza MVD: '.esc_html($err).'

';} public function register_rest(){register_rest_route(self::REST_NS,'/update',['methods'=>'POST','callback'=>[$this,'rest_update_merchant'],'permission_callback'=>'__return_true']);} private function connector_settings(){if(class_exists('MVD_Network_Connector')&&method_exists('MVD_Network_Connector','settings'))return MVD_Network_Connector::settings();$s=get_option('mvd_connector_settings',[]);return is_array($s)?$s:[];} private function verify_merchant_request(WP_REST_Request $request){$s=$this->connector_settings();$raw=$request->get_body();$d=json_decode($raw,true);if(!is_array($d))return new WP_Error('mvd_exp_json','JSON non valido',['status'=>400]);if(sanitize_key($d['merchant_id']??'')!==sanitize_key($s['merchant_id']??''))return new WP_Error('mvd_exp_merchant','Merchant non valido',['status'=>403]);$sig=(string)$request->get_header('x-mvd-signature');$ts=(string)$request->get_header('x-mvd-timestamp');$nonce=(string)$request->get_header('x-mvd-nonce');if(!$sig||!$ts||!$nonce||!ctype_digit($ts)||abs(time()-(int)$ts)>self::SIGNATURE_MAX_SKEW)return new WP_Error('mvd_exp_signature','Firma non valida o scaduta',['status'=>403]);$key='mvd_exp_nonce_'.hash('sha256',$nonce);if(get_transient($key))return new WP_Error('mvd_exp_replay','Richiesta già usata',['status'=>409]);$expected=hash_hmac('sha256',$ts.'.'.$nonce.'.'.$raw,(string)($s['secret']??''));if(!$s['secret']||!hash_equals($expected,$sig))return new WP_Error('mvd_exp_signature','Firma non valida',['status'=>403]);set_transient($key,1,self::SIGNATURE_MAX_SKEW*2);return$d;} public function rest_update_merchant(WP_REST_Request $request){$d=$this->verify_merchant_request($request);if(is_wp_error($d))return$d;$uuid=sanitize_text_field($d['experience_uuid']??'');$source_id=absint($d['source_post_id']??0);$post_id=0;if($source_id&&get_post_type($source_id)==='post'&&get_post_meta($source_id,self::META_UUID,true)===$uuid)$post_id=$source_id;if(!$post_id&&$uuid){$q=new WP_Query(['post_type'=>'post','post_status'=>['publish','draft','private','pending'],'posts_per_page'=>1,'fields'=>'ids','meta_key'=>self::META_UUID,'meta_value'=>$uuid]);$post_id=absint($q->posts[0]??0);}if(!$post_id)return new WP_Error('mvd_exp_not_found','Esperienza non trovata sul merchant',['status'=>404]);$current=max(1,absint(get_post_meta($post_id,self::META_VERSION,true)));$expected=absint($d['expected_version']??0);if($expected&&$expected!==$current)return new WP_Error('mvd_exp_conflict','Il merchant contiene una versione più recente ('.$current.'). Ricarica prima la scheda.',['status'=>409,'current_version'=>$current]);$sheet=$this->sanitize_sheet($d['sheet']??[]);$newver=$current+1;$this->applying_remote=true;update_post_meta($post_id,self::META_SHEET,$sheet);update_post_meta($post_id,self::META_VERSION,$newver);update_post_meta($post_id,self::META_LAST_SYNC,current_time('mysql'));delete_post_meta($post_id,self::META_SYNC_ERROR);$this->applying_remote=false;return new WP_REST_Response(['success'=>true,'post_id'=>$post_id,'version'=>$newver,'sheet'=>$sheet],200);} private function push_merchant_to_core($post_id,$sheet,$version){$s=$this->connector_settings();if(empty($s['network_url'])||empty($s['merchant_id'])||empty($s['secret'])){update_post_meta($post_id,self::META_SYNC_ERROR,'Scheda salvata localmente. Connector MVD non configurato: sincronizzazione non eseguita.');return false;}$item=$this->merchant_payload($post_id,$sheet,$version,$s);$payload=['merchant_id'=>sanitize_key($s['merchant_id']),'item'=>$item];$r=$this->signed_post(untrailingslashit($s['network_url']).'/wp-json/mvd/v1/catalog/upsert',$payload,$s['secret']);if(is_wp_error($r)){update_post_meta($post_id,self::META_SYNC_ERROR,$r->get_error_message());return false;}$code=wp_remote_retrieve_response_code($r);$j=json_decode(wp_remote_retrieve_body($r),true);if($code<200||$code>=300){update_post_meta($post_id,self::META_SYNC_ERROR,'Core MVD HTTP '.$code.': '.sanitize_text_field($j['message']??$j['error']??''));return false;}update_post_meta($post_id,self::META_LAST_SYNC,current_time('mysql'));delete_post_meta($post_id,self::META_SYNC_ERROR);return true;} private function merchant_payload($post_id,$sheet,$version,$s){$b=$sheet['base'];$uuid=get_post_meta($post_id,self::META_UUID,true);if(!$uuid){$uuid=wp_generate_uuid4();update_post_meta($post_id,self::META_UUID,$uuid);} $title=$b['commercial_title']?:get_the_title($post_id);$image=$b['image_url']?:get_the_post_thumbnail_url($post_id,'full');$gallery=array_values(array_filter(array_map('esc_url_raw',preg_split('/[\r\n,]+/',$b['gallery_urls']))));$price=$this->minimum_price($sheet);$meeting=$this->first_meeting_point($sheet);$languages=$this->csv($b['languages']);$item=['resource_id'=>'mvd:'.sanitize_key($s['merchant_id']).':experience:'.$uuid,'remote_product_id'=>(string)$post_id,'sku'=>$b['code'],'title'=>$title,'description'=>$b['full_description']?:$b['short_description'],'short_description'=>$b['short_description'],'item_type'=>'experience','item_category'=>'experience','owner_category'=>sanitize_key($s['merchant_category']??''),'distribution_mode'=>'normal','price'=>$price,'sale_price'=>'','currency'=>'EUR','origin_url'=>get_permalink($post_id),'image_url'=>$image,'gallery'=>$gallery,'modified_gmt'=>get_post_modified_time('c',true,$post_id),'duration_minutes'=>absint($b['duration_minutes']),'meeting_point'=>$meeting,'municipality'=>$b['municipality'],'latitude'=>$b['latitude'],'longitude'=>$b['longitude'],'languages'=>$languages,'min_participants'=>absint($b['min_participants']),'max_participants'=>absint($b['max_participants']),'accessibility'=>trim(wp_strip_all_tags($b['accessibility_status'].' '.$b['accessibility_notes'])),'age_min'=>$b['age_min'],'age_max'=>$b['age_max'],'cancellation_policy'=>$this->cancellation_text($sheet),'seasonality'=>$b['seasonality'].' '.$b['availability_notes'],'experience_enabled'=>true,'experience_sale_mode'=>'booking','experience_availability_type'=>'request','experience_price_type'=>'mixed','experience_included'=>array_values(array_map(function($r){return wp_strip_all_tags($r['text']??'');},array_filter($sheet['included'],function($r){return ($r['status']??'')==='included';}))),'experience_excluded'=>array_values(array_map(function($r){return wp_strip_all_tags($r['text']??'');},array_filter($sheet['included'],function($r){return ($r['status']??'')==='excluded';}))),'experience_what_to_bring'=>array_values(array_map(function($r){return wp_strip_all_tags($r['title']??'');},$sheet['what_to_bring'])),'experience_sheet'=>$sheet,'experience_uuid'=>$uuid,'experience_source_version'=>$version,'source_post_type'=>'post'];$hashItem=$item;unset($hashItem['payload_hash']);$item['payload_hash']=sha1(wp_json_encode($hashItem));return$item;} private function signed_post($url,$payload,$secret,$timeout=25){$payload['correlation_id']=$payload['correlation_id']??wp_generate_uuid4();$body=wp_json_encode($payload);$ts=(string)time();$nonce=wp_generate_password(24,false,false);$sig=hash_hmac('sha256',$ts.'.'.$nonce.'.'.$body,$secret);return wp_remote_post($url,['timeout'=>$timeout,'headers'=>['Content-Type'=>'application/json','X-MVD-Signature'=>$sig,'X-MVD-Timestamp'=>$ts,'X-MVD-Nonce'=>$nonce,'X-MVD-Correlation-ID'=>$payload['correlation_id']],'body'=>$body]);} public function capture_core_payload($post_id,$item){if(!is_array($item)||($item['item_type']??'')!=='experience'||empty($item['experience_sheet']))return;$sheet=$this->sanitize_sheet($item['experience_sheet']);update_post_meta($post_id,self::META_SHEET,$sheet);if(!empty($item['experience_uuid']))update_post_meta($post_id,self::META_UUID,sanitize_text_field($item['experience_uuid']));if(isset($item['experience_source_version']))update_post_meta($post_id,self::META_VERSION,absint($item['experience_source_version']));update_post_meta($post_id,self::META_LAST_SYNC,current_time('mysql'));delete_post_meta($post_id,self::META_SYNC_ERROR);$this->sync_standard_core_meta($post_id,$sheet);} public function augment_core_item($row,$post_id){$sheet=$this->get_sheet($post_id);if($this->sheet_has_data($sheet)){$row['experience_sheet']=$sheet;$row['experience_uuid']=(string)get_post_meta($post_id,self::META_UUID,true);$row['experience_source_version']=absint(get_post_meta($post_id,self::META_VERSION,true));}return$row;} private function push_core_to_merchant($post_id,$sheet,$base_version){if(!class_exists('MVD_Network_Core'))return new WP_Error('mvd_exp_no_core','MVD Network Core non disponibile.');$merchant_id=sanitize_key(get_post_meta($post_id,'_mvd_merchant_id',true));$merchant=MVD_Network_Core::get_merchant($merchant_id);if(!$merchant||empty($merchant['site_url'])||empty($merchant['secret']))return new WP_Error('mvd_exp_no_merchant','Configurazione merchant incompleta sul Core.');$payload=['merchant_id'=>$merchant_id,'experience_uuid'=>(string)get_post_meta($post_id,self::META_UUID,true),'source_post_id'=>absint(get_post_meta($post_id,'_mvd_remote_product_id',true)),'expected_version'=>$base_version,'sheet'=>$sheet];$url=untrailingslashit($merchant['site_url']).'/wp-json/'.self::REST_NS.'/update';$r=$this->signed_post($url,$payload,$merchant['secret']);if(is_wp_error($r))return$r;$code=wp_remote_retrieve_response_code($r);$j=json_decode(wp_remote_retrieve_body($r),true);if($code<200||$code>=300)return new WP_Error('mvd_exp_remote_'.$code,is_array($j)&&!empty($j['message'])?$j['message']:'Merchant HTTP '.$code,['status'=>$code,'body'=>$j]);return is_array($j)?$j:[];} private function sync_standard_core_meta($post_id,$sheet){$b=$sheet['base'];update_post_meta($post_id,'_mvd_duration_minutes',absint($b['duration_minutes']));update_post_meta($post_id,'_mvd_meeting_point',$this->first_meeting_point($sheet));update_post_meta($post_id,'_mvd_municipality',sanitize_text_field($b['municipality']));update_post_meta($post_id,'_mvd_latitude',sanitize_text_field($b['latitude']));update_post_meta($post_id,'_mvd_longitude',sanitize_text_field($b['longitude']));update_post_meta($post_id,'_mvd_languages',$this->csv($b['languages']));update_post_meta($post_id,'_mvd_min_participants',absint($b['min_participants']));update_post_meta($post_id,'_mvd_max_participants',absint($b['max_participants']));update_post_meta($post_id,'_mvd_accessibility',wp_strip_all_tags($b['accessibility_status'].' '.$b['accessibility_notes']));update_post_meta($post_id,'_mvd_age_min',absint($b['age_min']));update_post_meta($post_id,'_mvd_age_max',absint($b['age_max']));update_post_meta($post_id,'_mvd_price',$this->minimum_price($sheet));if($b['commercial_title']&&get_the_title($post_id)!==wp_strip_all_tags($b['commercial_title'])){$this->updating_core_meta=true;wp_update_post(['ID'=>$post_id,'post_title'=>sanitize_text_field($b['commercial_title'])]);$this->updating_core_meta=false;}} public function core_template($template){ if(!is_singular('mvd_item'))return $template; $id=get_queried_object_id(); if(get_post_meta($id,'_mvd_item_type',true)!=='experience')return $template; $sheet=$this->get_sheet($id);if(!$this->sheet_has_data($sheet))return $template; $custom=dirname(__DIR__).'/templates/single-mvd-experience.php'; return file_exists($custom)?$custom:$template; } public function render_core_single($content){if(!is_singular('mvd_item')||!in_the_loop()||!is_main_query())return$content;$id=get_the_ID();if(get_post_meta($id,'_mvd_item_type',true)!=='experience')return$content;$sheet=$this->get_sheet($id);if(!$this->sheet_has_data($sheet))return$content;return $this->render_full($sheet,$id);} private function sheet_has_data($sheet){return !empty($sheet['base']['code'])||!empty($sheet['base']['commercial_title'])||!empty($sheet['highlights'])||!empty($sheet['pricing']);} private function ctx_post_id($atts){$a=shortcode_atts(['post_id'=>0],$atts);return absint($a['post_id'])?:get_the_ID();} public function shortcode_field($atts){$a=shortcode_atts(['field'=>'','post_id'=>0],$atts);$s=$this->get_sheet($this->ctx_post_id($a));$key=sanitize_key($a['field']);$v=$s['base'][$key]??'';if($key==='duration_minutes')return esc_html($this->format_duration($v));if($key==='languages')return esc_html($this->format_languages($v));return wp_kses_post($v);} public function shortcode_badge($atts){$s=$this->get_sheet($this->ctx_post_id($atts));$v=$s['base']['badge'];return $this->frontend_css().($v?''.esc_html($v).'':'');} public function shortcode_activity_info($atts){$s=$this->get_sheet($this->ctx_post_id($atts));return $this->frontend_css().$this->render_activity($s);} public function shortcode_highlights($atts){$s=$this->get_sheet($this->ctx_post_id($atts));return $this->frontend_css().$this->render_highlights($s);} public function shortcode_itinerary($atts){$s=$this->get_sheet($this->ctx_post_id($atts));return $this->frontend_css().$this->render_itinerary($s);} public function shortcode_included($atts){$s=$this->get_sheet($this->ctx_post_id($atts));return $this->frontend_css().$this->render_included($s);} public function shortcode_meeting_points($atts){$s=$this->get_sheet($this->ctx_post_id($atts));return $this->frontend_css().$this->render_meetings($s);} public function shortcode_food($atts){$s=$this->get_sheet($this->ctx_post_id($atts));return $this->frontend_css().$this->render_food($s);} public function shortcode_what_to_bring($atts){$s=$this->get_sheet($this->ctx_post_id($atts));return $this->frontend_css().$this->render_bring($s);} public function shortcode_pricing($atts){$s=$this->get_sheet($this->ctx_post_id($atts));return $this->frontend_css().$this->render_pricing($s);} public function shortcode_full_sheet($atts){$id=$this->ctx_post_id($atts);return $this->render_full($this->get_sheet($id),$id);} private function token_value($token,$s){$b=$s['base'];switch($token){case'duration':return$this->format_duration($b['duration_minutes']);case'languages':return$this->format_languages($b['languages']);case'start_time':return$b['start_time'];case'return_time':return$b['return_time'];case'cancellation_free_days':return$b['cancellation_free_days'];case'audio_guide':return$b['audio_guide'];case'accessibility_status':return$b['accessibility_status'];case'payment_mode':return$b['payment_mode'];default:return isset($b[$token])?$b[$token]:'';}} private function tokens($text,$s){return preg_replace_callback('/\{([a-z0-9_]+)\}/i',function($m)use($s){return esc_html($this->token_value(sanitize_key($m[1]),$s));},(string)$text);} private function icon($url){return$url?'':'';} private function render_activity($s){if(empty($s['activity_info']))return'';$o='
';foreach($s['activity_info'] as$r){$val=$this->tokens($r['value_template']??$r['value']??'',$s);$o.='
'.$this->icon($r['icon_url']??'').'
'.esc_html($r['title']??'').''.($val!==''?'
'.$val.'
':'').(!empty($r['note'])?''.wp_kses_post($r['note']).'':'').'
';}$o.='
';return$o;} private function render_highlights($s){$o='';foreach($s['highlights'] as$r)if(!empty($r['text']))$o.='

'.wp_kses_post($r['text']).'

';return$o;} private function render_itinerary($s){$o='
';foreach($s['itinerary'] as$r){$kind=sanitize_key($r['kind']??'step');if($kind==='transfer'){$o.='
'.$this->icon($r['icon_url']??'').''.esc_html(trim(($r['distance_km']??'')!==''?'Distanza '.$r['distance_km'].' km ':'').trim(($r['transfer_min']??'')!==''?'('.$r['transfer_min'].' min)':'')).''.(!empty($r['transport'])?'
'.esc_html($r['transport']).'
':'').'
';continue;}$o.='
';if(!empty($r['variant']))$o.='
'.esc_html($r['variant']).'
';$o.='

'.$this->icon($r['icon_url']??'').esc_html($r['title']??'').'

';if(!empty($r['location'])||!empty($r['address']))$o.='
'.esc_html(trim(($r['location']??'').' '.($r['address']??''))).'
';if(!empty($r['duration_min']))$o.='('.esc_html($this->format_duration($r['duration_min'])).') ';if(!empty($r['description']))$o.='
'.wp_kses_post($r['description']).'
';if(!empty($r['map_url']))$o.='

Apri su Maps ↗

';if(!empty($r['image_url']))$o.='';$o.='
';}$o.='
';return$o;} private function render_included($s){$o='
';foreach($s['included'] as$r){$yes=($r['status']??'included')!=='excluded';$o.='
'.$this->icon($r['icon_url']??'').''.($yes?'✓':'✕').''.wp_kses_post($r['text']??'').'
';}$o.='
';return$o;} private function render_meetings($s){$o='
';foreach($s['meeting_points'] as$r){$o.='
';if(!empty($r['condition']))$o.=''.wp_kses_post($r['condition']).'
';$place=trim(($r['location']??'').(($r['address']??'')?', '.$r['address']:''));if($place)$o.='Ritrovo a '.esc_html($place).'. ';if(!empty($r['reference']))$o.=wp_kses_post($r['reference']).'. ';if(!empty($r['advance_min']))$o.='Presentarsi '.esc_html($r['advance_min']).' minuti prima. ';if(!empty($r['parking']))$o.='
🅿 '.esc_html($r['parking']).(!empty($r['parking_free'])&&in_array(strtolower($r['parking_free']),['si','sì','yes','1'],true)?' gratuito':'').'
';if(!empty($r['services']))$o.='
'.wp_kses_post($r['services']).'
';if(!empty($r['notes']))$o.='
'.wp_kses_post($r['notes']).'
';$o.='
';}$o.='
';return$o;} private function render_food($s){$o='
';foreach($s['food'] as$r){$o.='
'.esc_html($r['name']??($r['type']??'')).'';if(!empty($r['type'])&&!empty($r['name']))$o.=' · '.esc_html($r['type']);if(!empty($r['location']))$o.=' · '.esc_html($r['location']);if(!empty($r['notes']))$o.='
'.wp_kses_post($r['notes']).'
';if(!empty($r['url']))$o.='Sito ↗ ';if(!empty($r['phone']))$o.=''.esc_html($r['phone']).'';$o.='
';}$o.='
';return$o;} private function render_bring($s){$o='
';foreach($s['what_to_bring'] as$r){$o.='
'.$this->icon($r['icon_url']??'').'
'.esc_html($r['title']??'').''.(!empty($r['note'])?''.wp_kses_post($r['note']).'':'').'
';}$o.='
';return$o;} private function render_pricing($s){if(empty($s['pricing'])&&empty($s['benefits']))return'';$groups=[];foreach($s['pricing'] as$r){$aud=trim($r['audience']??'')?:'Tariffe';$groups[$aud][]=$r;}$o='
';foreach($groups as$aud=>$rows){$o.='
'.esc_html($aud).'';foreach($rows as$r){$age=$this->range_label($r['age_min']??'',$r['age_max']??'','anni');$pax=$this->range_label($r['pax_min']??'',$r['pax_max']??'','persone');$price=$r['price']===''?'—':number_format_i18n((float)str_replace(',','.',$r['price']),2).' '.esc_html($r['currency']?:'EUR');if(!empty($r['unit']))$price.=' / '.esc_html($r['unit']);$o.='';}$o.='
EtàPartecipantiPrezzoNote
'.esc_html($age?:'—').''.esc_html($pax?:'—').''.$price.''.wp_kses_post($r['note']??'').'
';} if(!empty($s['benefits'])){$o.='
Gratuità / agevolazioni
';}$o.='
';return$o;} private function render_full($s,$post_id=0){$b=$s['base'];$o=$this->frontend_css();$o.='
';$o.='
'.($b['badge']?''.esc_html($b['badge']).'':'').'

'.esc_html($b['commercial_title']?:get_the_title($post_id)).'

'.($b['short_description']?'
'.wp_kses_post($b['short_description']).'
':'').'
'; if($s['activity_info'])$o.=$this->section('Informazioni sull’attività',$this->render_activity($s));if($s['highlights'])$o.=$this->section('In evidenza',$this->render_highlights($s));if($s['itinerary'])$o.=$this->section('Descrizione completa',$this->render_itinerary($s));if($s['included'])$o.=$this->section('Cosa è incluso',$this->render_included($s));if($s['meeting_points'])$o.=$this->section('Punto di incontro',$this->render_meetings($s));if($s['food'])$o.=$this->section('Dove Mangiare',$this->render_food($s));if($s['what_to_bring'])$o.=$this->section('Come vestire',$this->render_bring($s));if($s['pricing']||$s['benefits'])$o.=$this->section('Tariffe',$this->render_pricing($s));$o.='
';return$o;} private function section($title,$content){return'

'.esc_html($title).'

'.$content.'
';} private function frontend_css(){static$done=false;if($done)return'';$done=true;return'';} private function format_duration($min){$min=absint($min);if(!$min)return'';$h=intdiv($min,60);$m=$min%60;if($h&&$m)return$h.' h '.$m.' min';if($h)return$h.' '.($h===1?'ora':'ore');return$m.' min';} private function format_languages($csv){$map=['it'=>'Italiano','en'=>'Inglese','fr'=>'Francese','de'=>'Tedesco','es'=>'Spagnolo','nl'=>'Olandese'];$a=$this->csv($csv);return implode(', ',array_map(function($v)use($map){return$map[$v]??strtoupper($v);},$a));} private function csv($v){if(is_array($v))$a=$v;else$a=preg_split('/[,;|]+/',(string)$v);return array_values(array_filter(array_map('sanitize_key',$a)));} private function range_label($min,$max,$unit){$min=trim((string)$min);$max=trim((string)$max);if($min===''&&$max==='')return'';if($min!==''&&$max!=='')return$min.'–'.$max.' '.$unit;if($min!=='')return$min.'+ '.$unit;return'0–'.$max.' '.$unit;} private function minimum_price($s){$vals=[];foreach($s['pricing'] as$r){$v=str_replace(',','.',trim($r['price']??''));if($v!==''&&is_numeric($v)&&floatval($v)>0)$vals[]=floatval($v);}return$vals?(string)min($vals):'';} private function first_meeting_point($s){$r=$s['meeting_points'][0]??[];return trim(($r['location']??'').(($r['address']??'')?', '.$r['address']:''));} private function cancellation_text($s){$d=absint($s['base']['cancellation_free_days']);return$d?'Cancellazione gratuita fino a '.$d.' giorni prima.':'';} private function export_tsv($s){$out=["# MVD-EXPERIENCE-TSV 1",'[BASE]',"KEY\tVALUE"];foreach($s['base'] as$k=>$v)$out[]=$k."\t".$this->tsv_cell($v);$defs=$this->import_defs();foreach($defs as$group=>$cols){$out[]='';$out[]='['.strtoupper($group).']';$out[]=implode("\t",array_map('strtoupper',$cols));foreach($s[$group] as$r){$line=[];foreach($cols as$c)$line[]=$this->tsv_cell($r[$c]??'');$out[]=implode("\t",$line);}}return implode("\n",$out);} private function tsv_cell($v){return str_replace(["\t","\r","\n"],[' ','','\\n'],wp_strip_all_tags((string)$v));} private function import_defs(){return['activity_info'=>['icon_url','title','value_template','note'],'highlights'=>['text'],'itinerary'=>['kind','icon_url','title','location','address','map_url','duration_min','distance_km','transfer_min','transport','variant','description','image_url'],'included'=>['status','text','icon_url'],'meeting_points'=>['condition','location','address','reference','advance_min','latitude','longitude','parking','parking_free','services','notes'],'food'=>['name','type','location','url','phone','booking_required','condition','notes'],'what_to_bring'=>['icon_url','title','note'],'pricing'=>['audience','age_min','age_max','pax_min','pax_max','price','unit','currency','note'],'benefits'=>['condition','price','discount','note'],'rules'=>['type','from','to','value','text']];} private function parse_tsv($text){$lines=preg_split('/\r\n|\r|\n/',trim($text));$s=$this->default_sheet();$section='';$headers=[];$defs=$this->import_defs();foreach($lines as$raw){$line=trim($raw);if($line===''||strpos($line,'#')===0)continue;if(preg_match('/^\[([A-Z0-9_]+)\]$/i',$line,$m)){$section=strtolower($m[1]);$headers=[];continue;}$cells=array_map(function($v){return str_replace('\\n',"\n",trim($v));},explode("\t",$raw));if($section==='base'){if(strtoupper($cells[0]??'')==='KEY')continue;$k=sanitize_key($cells[0]??'');if($k&&array_key_exists($k,$s['base']))$s['base'][$k]=$cells[1]??'';continue;}if(!isset($defs[$section]))continue;if(!$headers){$candidate=array_map('sanitize_key',$cells);if(array_intersect($candidate,$defs[$section])){$headers=$candidate;continue;}$headers=$defs[$section];} $row=[];foreach($headers as$i=>$k)if(in_array($k,$defs[$section],true))$row[$k]=$cells[$i]??'';$s[$section][]=$row;}return$this->sanitize_sheet($s);} }