<?php
declare(strict_types=1);
session_start();
$configFile = dirname(__DIR__).'/config.php';
if (!is_file($configFile)) { http_response_code(503); exit('Not installed. Copy config.example.php to config.php and update database details.'); }
$config = require $configFile;
function contentPlatformInsightDetails(): void {
 if(($_GET['r']??'')!=='content'||empty($_GET['id']))return;
 $q=db()->prepare("SELECT platform_key,metric_date,reach,impressions,views,likes,comments,shares,saves,clicks FROM performance_entries WHERE content_item_id=? ORDER BY metric_date DESC,platform_key");
 $q->execute([(int)$_GET['id']]);$rows=$q->fetchAll();if(!$rows)return;
 ?><section class="card" style="margin-top:18px"><h2>Platform Insight Details</h2><p class="muted">Automatically synced and manually entered results, separated by platform.</p><table><tr><th>Date</th><th>Platform</th><th>Video views</th><th>Likes</th><th>Comments</th><th>Shares</th><th>Reach</th><th>Impressions</th><th>Clicks</th></tr><?php foreach($rows as $x):?><tr><td><?=h($x['metric_date'])?></td><td><span class="badge"><?=h(ucwords($x['platform_key']?:'manual'))?></span></td><td><?=number_format((int)$x['views'])?></td><td><?=number_format((int)$x['likes'])?></td><td><?=number_format((int)$x['comments'])?></td><td><?=number_format((int)$x['shares'])?></td><td><?=number_format((int)$x['reach'])?></td><td><?=number_format((int)$x['impressions'])?></td><td><?=number_format((int)$x['clicks'])?></td></tr><?php endforeach;?></table></section><?php
}
function tiktokIntegrationStatus(): void {
 if(($_GET['r']??'')!=='meta_connections')return;$ready=(string)cfg('tiktok.client_key','')!==''&&(string)cfg('tiktok.client_secret','')!=='';
 ?><section class="card" style="margin-top:18px"><div class="toolbar"><div style="flex:1"><h2>TikTok Content Insights</h2><p class="muted">TikTok requires a Developer app and one-time account authorization before link-level views, likes, comments and shares can be read.</p></div><span class="badge"><?=$ready?'Configured':'Awaiting API credentials'?></span></div><p><b>Redirect URL:</b> <code><?=h(route('tiktok_callback'))?></code></p><?php if(!$ready):?><p class="muted">Add the TikTok Client Key and Client Secret in the private server config, then the Connect TikTok button will become available.</p><?php else:?><a class="btn" href="<?=h(route('tiktok_connect'))?>">Connect TikTok account</a><?php endif;?></section><?php
}
function workspaceScheduleV2(): void { if(($_GET['r']??'')!=='project'||empty($_GET['id']))return;$id=(int)$_GET['id'];$q=db()->prepare("SELECT ci.* FROM content_items ci WHERE ci.project_id=? AND ci.published_at IS NULL ORDER BY CASE WHEN ci.sort_order>0 THEN 0 ELSE 1 END,ci.sort_order,COALESCE(ci.scheduled_at,ci.created_at)");$q->execute([$id]);$rows=$q->fetchAll();?><section class="card" style="margin-top:18px"><div class="toolbar"><div style="flex:1"><h2>Marketing Planning Schedule</h2><p class="muted">Scheduled content stays here until the actual Published date is filled.</p></div><?php if(user()['role']!=='client'&&canDo('create')):?><a class="btn" href="<?=h(route('item_edit',['project_id'=>$id,'kind'=>'posting']))?>">+ Plan content</a><?php endif;?></div><?php if(!$rows):?><p class="muted">No unpublished content in the schedule.</p><?php else:?><table><tr><th>No.</th><th>Planned date</th><th>Title</th><th>Content type</th><th>Planned platforms</th><th>Caption / copy</th><th>Status</th><th></th></tr><?php foreach($rows as $i=>$x):?><tr><td><?=number_format($i+1)?></td><td><?=h($x['scheduled_at']?:'To be confirmed')?></td><td><a href="<?=h(route('item_edit', ['id'=>$x['id']]))?>"><b><?=h($x['title'])?></b></a></td><td><?=h(ucwords(str_replace('_',' ',$x['content_type_key']?:$x['item_kind'])))?></td><td><?=h(implode(', ',json_decode($x['platforms_json']??'[]',true)?:[])?:'To be confirmed')?></td><td><?=nl2br(h($x['caption']?:'—'))?></td><td><span class="badge"><?=h($x['status_key']?:'Planning')?></span></td><td><?php if(user()['role']!=='client'&&canDo('edit')):?><a class="btn small" href="<?=h(route('item_edit',['id'=>$x['id']]))?>">Update publication</a><?php endif;?></td></tr><?php endforeach;?></table><?php endif;?></section><?php }
function dashboardTodaySchedule(): void { if(($_GET['r']??'')!=='dashboard_v3')return;$clientId=user()['role']==='client'?(int)user()['client_id']:(int)($_GET['client_id']??0);$sql="SELECT ci.id,ci.title,ci.scheduled_at,ci.published_at,c.name client_name FROM content_items ci JOIN projects p ON p.id=ci.project_id JOIN clients c ON c.id=p.client_id WHERE DATE(ci.scheduled_at) BETWEEN CURDATE() AND DATE_ADD(CURDATE(),INTERVAL 3 DAY)";$args=[];if($clientId){$sql.=' AND p.client_id=?';$args[]=$clientId;}$q=db()->prepare($sql.' ORDER BY ci.scheduled_at,c.name');$q->execute($args);$rows=$q->fetchAll();?><section id="upcomingSchedule" class="card" style="margin-bottom:18px"><div class="toolbar"><div style="flex:1"><h2>Today & Next 3 Days Content Schedule</h2><p class="muted"><?=number_format(count($rows))?> scheduled item(s), including published confirmations.</p></div><?php if(user()['role']==='admin'):?><a class="btn gray" href="<?=h(route('reminder_settings'))?>">Email reminder settings</a><?php endif;?></div><?php if(!$rows):?><p>No content scheduled within the next 3 days.</p><?php else:?><table><tr><th>Date / time</th><th>Client</th><th>Content</th><th>Status</th></tr><?php foreach($rows as $x):?><tr><td><?=h($x['scheduled_at'])?></td><td><?=h($x['client_name'])?></td><td><a href="<?=h(route('item_edit', ['id'=>$x['id']]))?>"><?=h($x['title'])?></a></td><td><span class="badge"><?=h($x['published_at']?'Published':'Upcoming')?></span></td></tr><?php endforeach;?></table><?php endif;?></section><script>(function(){let s=document.getElementById('upcomingSchedule'),m=document.querySelector('main');if(s&&m)m.insertBefore(s,m.firstChild);})();</script><?php }
function itemEditEnhancements(): void { if(($_GET['r']??'')!=='item_edit')return;$id=(int)($_GET['id']??0);$item=[];if($id){$q=db()->prepare('SELECT scheduled_at,caption,sort_order FROM content_items WHERE id=?');$q->execute([$id]);$item=$q->fetch()?:[];}?><script>(function(){let f=document.querySelector('main form.card');if(!f)return;let save=f.querySelector('button.btn');function add(label,name,type,value){let d=document.createElement('div');d.className='field';d.innerHTML='<label>'+label+'</label>'+(type==='textarea'?'<textarea name="'+name+'"></textarea>':'<input type="'+type+'" name="'+name+'">');d.querySelector('[name]').value=value||'';f.insertBefore(d,save)}add('Scheduled date/time','scheduled_at','datetime-local',<?=json_encode(!empty($item['scheduled_at'])?date('Y-m-d\TH:i',strtotime($item['scheduled_at'])):'')?>);add('Caption / copy','caption','textarea',<?=json_encode($item['caption']??'',JSON_UNESCAPED_UNICODE)?>);add('Manual order (smaller number appears first)','sort_order','number',<?=json_encode($item['sort_order']??0)?>);})();</script><?php }
function clientPortalEnhancements(): void { if(($_GET['r']??'')!=='client_portal')return;?><style>.caption-wrap{max-width:680px}.caption-text.collapsed{display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.caption-toggle{border:0;background:none;color:#1769e0;padding:5px 0;cursor:pointer;font-weight:700}</style><script>(function(){let sections=document.querySelectorAll('main section.card');if(sections.length>1&&sections[0].querySelector('h2')?.textContent.includes('Planning')&&sections[1].querySelector('h2')?.textContent.includes('Posted'))sections[0].parentNode.insertBefore(sections[1],sections[0]);document.querySelectorAll('table').forEach(t=>{let hs=[...t.querySelectorAll('th')].map(x=>x.textContent.trim().toLowerCase()),i=hs.findIndex(x=>x.includes('caption'));if(i<0)return;[...t.rows].slice(1).forEach(r=>{let c=r.cells[i];if(!c||c.innerText.trim()==='—')return;c.classList.add('caption-wrap');let d=document.createElement('div');d.className='caption-text collapsed';while(c.firstChild)d.appendChild(c.firstChild);c.appendChild(d);requestAnimationFrame(()=>{if(d.scrollHeight<=d.clientHeight+3)return;let b=document.createElement('button');b.type='button';b.className='caption-toggle';b.textContent='Show more';b.onclick=()=>{let closed=d.classList.toggle('collapsed');b.textContent=closed?'Show more':'Show less'};c.appendChild(b)})})})})();</script><?php }
function dragOrderEnhancements(): void { if(!user()||user()['role']==='client'||!canDo('edit')||!in_array($_GET['r']??'',['projects','project'],true))return;?><style>.drag-handle{cursor:grab;font-size:20px;margin-right:8px;color:#66758c}.dragging{opacity:.45}.order-saving{color:#1769e0;font-size:13px;margin-left:8px}</style><script>(function(){const endpoint=<?=json_encode(route('item_reorder'))?>,csrf=<?=json_encode(csrf())?>;document.querySelectorAll('table').forEach(table=>{let headers=[...table.querySelectorAll('th')].map(x=>x.textContent.trim().toLowerCase());if(!headers.includes('no.'))return;let rows=[...table.querySelectorAll('tr')].slice(1),projectLink=table.closest('details')?.querySelector('a[href*="r=project&id="]'),projectId=projectLink?new URL(projectLink.href).searchParams.get('id'):new URL(location.href).searchParams.get('id');if(!projectId||!rows.length)return;let status=document.createElement('span');status.className='order-saving';table.parentNode.insertBefore(status,table);function itemId(row){let a=row.querySelector('a[href*="r=content&id="],a[href*="r=item_edit&id="]');return a?new URL(a.href).searchParams.get('id'):null}function renumber(){[...table.querySelectorAll('tr')].slice(1).forEach((r,i)=>{let cell=r.cells[0];let h=cell.querySelector('.drag-handle');cell.innerHTML='';cell.append(h||document.createTextNode(''));cell.append(document.createTextNode(String(i+1)))})}rows.forEach(row=>{if(!itemId(row))return;row.draggable=true;let h=document.createElement('span');h.className='drag-handle';h.textContent='☰';row.cells[0].prepend(h);row.addEventListener('dragstart',()=>row.classList.add('dragging'));row.addEventListener('dragend',async()=>{row.classList.remove('dragging');renumber();let ids=[...table.querySelectorAll('tr')].slice(1).map(itemId).filter(Boolean);status.textContent='Saving order…';let data=new URLSearchParams();data.append('_csrf',csrf);data.append('project_id',projectId);data.append('order',ids.join(','));let res=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:data.toString()});let responseText=await res.text();status.textContent=res.ok?'Order saved':'Could not save order ('+res.status+'): '+responseText.slice(0,120);setTimeout(()=>status.textContent='',1800)});row.addEventListener('dragover',e=>{e.preventDefault();let moving=table.querySelector('.dragging');if(!moving||moving===row)return;let box=row.getBoundingClientRect();row.parentNode.insertBefore(moving,e.clientY<box.top+box.height/2?row:row.nextSibling)})});renumber()})})();</script><?php }
function clientReportHistory(): void { if(($_GET['r']??'')!=='client_portal')return;$slug=strtolower(trim($_GET['slug']??''));$q=db()->prepare('SELECT id FROM clients WHERE LOWER(code)=?');$q->execute([$slug]);$clientId=(int)$q->fetchColumn();if(!$clientId)return;$q=db()->prepare('SELECT token FROM report_shares WHERE client_id=? ORDER BY id DESC LIMIT 1');$q->execute([$clientId]);$token=$q->fetchColumn();if(!$token)return;$q=db()->prepare('SELECT date_from,date_to,created_at FROM report_history WHERE client_id=? ORDER BY date_to DESC,date_from DESC');$q->execute([$clientId]);$history=$q->fetchAll();?><section class="card" style="margin-top:18px"><h2>Past Marketing Reports</h2><?php if(!$history):?><p class="muted">No saved report periods yet.</p><?php else:?><div class="field"><label>Select report period</label><select onchange="if(this.value)location.href=this.value"><option value="">Choose a report…</option><?php foreach($history as $x):$link=route('shared_report',['token'=>$token,'from'=>$x['date_from'],'to'=>$x['date_to']]);?><option value="<?=h($link)?>"><?=h($x['date_from'].' to '.$x['date_to'])?></option><?php endforeach;?></select></div><?php endif;?></section><?php }
if(!function_exists('str_starts_with')){function str_starts_with(string $haystack,string $needle):bool{return $needle===''||strpos($haystack,$needle)===0;}}
if(!function_exists('str_contains')){function str_contains(string $haystack,string $needle):bool{return $needle===''||strpos($haystack,$needle)!==false;}}
if(!defined('FILEINFO_MIME_TYPE'))define('FILEINFO_MIME_TYPE',16);
if(!class_exists('finfo')){class finfo{public function __construct($flags=0){}public function file($path){$img=@getimagesize($path);if(!empty($img['mime']))return $img['mime'];$head=@file_get_contents($path,false,null,0,8);return substr((string)$head,0,4)==='%PDF'?'application/pdf':'application/octet-stream';}}}
date_default_timezone_set($config['timezone'] ?? 'Asia/Kuala_Lumpur');
try {
    $d = $config['db'];
    $pdo = new PDO("mysql:host={$d['host']};port=".($d['port'] ?? 3306).";dbname={$d['name']};charset=".($d['charset'] ?? 'utf8mb4'), $d['user'], $d['pass'], [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);
} catch (Throwable $e) { http_response_code(503); exit('Database connection failed. Check config.php.'); }

function cfg(string $key, $default=null) { global $config; $v=$config; foreach(explode('.',$key) as $p){if(!isset($v[$p]))return $default;$v=$v[$p];}return $v; }
function appName(): string { return 'Bright Marketing Solutions'; }
function db(): PDO { global $pdo; return $pdo; }
function h($v): string { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); }
function url(string $path=''): string { return rtrim((string)cfg('base_url'),'\/').'/'.ltrim($path,'/'); }
function route(string $r, array $q=[]): string { return url('index.php?'.http_build_query(array_merge(['r'=>$r],$q))); }
function go(string $r, array $q=[]): never { header('Location: '.route($r,$q)); exit; }
function flash(string $msg, string $type='ok'): void { $_SESSION['flash']=[$msg,$type]; }
function csrf(): string { if(empty($_SESSION['csrf']))$_SESSION['csrf']=bin2hex(random_bytes(24)); return $_SESSION['csrf']; }
function verify_csrf(): void { if(!hash_equals($_SESSION['csrf']??'',$_POST['_csrf']??'')){http_response_code(419);exit('Session expired. Please go back and try again.');} }
function user(): ?array { return $_SESSION['user']??null; }
function rememberSecret(): string { return (string)cfg('privacy.ip_hash_salt','bright-marketing-tracker'); }
function setRememberCookie(int $userId): void { $expires=time()+2592000;$payload=$userId.'.'.$expires;$value=$payload.'.'.hash_hmac('sha256',$payload,rememberSecret());setcookie('bms_remember',$value,['expires'=>$expires,'path'=>'/','secure'=>!empty($_SERVER['HTTPS']),'httponly'=>true,'samesite'=>'Lax']); }
function clearRememberCookie(): void { setcookie('bms_remember','',['expires'=>time()-3600,'path'=>'/','secure'=>!empty($_SERVER['HTTPS']),'httponly'=>true,'samesite'=>'Lax']); }
function restoreRememberedUser(): void { if(user()||empty($_COOKIE['bms_remember']))return;$p=explode('.',$_COOKIE['bms_remember']);if(count($p)!==3)return;[$id,$expires,$signature]=$p;$payload=$id.'.'.$expires;if((int)$expires<time()||!hash_equals(hash_hmac('sha256',$payload,rememberSecret()),$signature)){clearRememberCookie();return;}$q=db()->prepare('SELECT * FROM users WHERE id=? AND active=1');$q->execute([(int)$id]);if($u=$q->fetch()){unset($u['password_hash']);$_SESSION['user']=$u;} }
restoreRememberedUser();
function require_login(): void { if(!user()) go('login'); }
function require_role(array $roles): void { require_login(); if(!in_array(user()['role'],$roles,true)){http_response_code(403);exit('Access denied');} }
function canDo(string $permission): bool { $u=user();if(!$u)return false;if($u['role']==='admin')return true;if($u['role']==='client')return false;$defaults=['create'=>true,'edit'=>true,'delete'=>false,'manage_clients'=>true,'manage_projects'=>true,'manage_reports'=>true,'manage_tracking'=>false,'manage_contracts'=>false];$saved=json_decode($u['permissions_json']??'{}',true)?:[];return array_key_exists($permission,$saved)?(bool)$saved[$permission]:($defaults[$permission]??false); }
function scopeSql(string $alias='p'): array {
 $u=user();
 if(!$u)return ['',[]];
 if($u['role']==='client')return [" AND {$alias}.client_id = ?",[(int)$u['client_id']]];
 if($u['role']==='staff')return [" AND NOT EXISTS (SELECT 1 FROM user_client_access uca WHERE uca.user_id=? AND uca.client_id={$alias}.client_id AND uca.can_access=0)",[(int)$u['id']]];
 return ['',[]];
}
function clientScopeSql(string $alias='c'): array { $u=user(); return $u&&$u['role']==='staff' ? [" WHERE NOT EXISTS (SELECT 1 FROM user_client_access uca WHERE uca.user_id=? AND uca.client_id={$alias}.id AND uca.can_access=0)",[(int)$u['id']]] : ['',[]]; }
function settingOptions(string $group): array { $s=db()->prepare('SELECT setting_key,label,meta_json FROM settings WHERE setting_group=? AND active=1 ORDER BY sort_order,label');$s->execute([$group]);return $s->fetchAll(); }
function randomSlug(): string { return strtolower(substr(strtr(base64_encode(random_bytes(7)),'+/=','xyz'),0,8)); }
function validUrl(string $v): bool { return (bool)filter_var($v,FILTER_VALIDATE_URL) && in_array(strtolower((string)parse_url($v,PHP_URL_SCHEME)),['http','https'],true); }
function device(string $ua): string { return preg_match('/ipad|tablet/i',$ua)?'Tablet':(preg_match('/mobile|iphone|android/i',$ua)?'Mobile':'Desktop'); }
function browser(string $ua): string { foreach(['Edg'=>'Edge','OPR'=>'Opera','Chrome'=>'Chrome','Firefox'=>'Firefox','Safari'=>'Safari'] as $n=>$v)if(stripos($ua,$n)!==false)return $v;return 'Other'; }
function uploadMime(string $tmp,string $original=''): string { if(class_exists('finfo')){$f=new finfo(FILEINFO_MIME_TYPE);$m=$f->file($tmp);if($m)return $m;}if(function_exists('mime_content_type')){$m=@mime_content_type($tmp);if($m)return $m;}$img=@getimagesize($tmp);if(!empty($img['mime']))return $img['mime'];$ext=strtolower(pathinfo($original,PATHINFO_EXTENSION));return ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','webp'=>'image/webp','heic'=>'image/heic','heif'=>'image/heif','pdf'=>'application/pdf'][$ext]??'application/octet-stream'; }
function deleteProjectData(int $projectId): void { $q=db()->prepare('SELECT stored_name FROM attachments WHERE project_id=?');$q->execute([$projectId]);foreach($q->fetchAll(PDO::FETCH_COLUMN) as $name){$path=dirname(__DIR__).'/uploads/'.basename($name);if(is_file($path))@unlink($path);}$q=db()->prepare('SELECT id FROM tracking_links WHERE project_id=?');$q->execute([$projectId]);$linkIds=$q->fetchAll(PDO::FETCH_COLUMN);if($linkIds){$marks=implode(',',array_fill(0,count($linkIds),'?'));db()->prepare("DELETE FROM clicks WHERE tracking_link_id IN ($marks)")->execute($linkIds);}db()->prepare('DELETE FROM tracking_links WHERE project_id=?')->execute([$projectId]);db()->prepare('DELETE FROM attachments WHERE project_id=?')->execute([$projectId]);db()->prepare('DELETE FROM performance_entries WHERE project_id=?')->execute([$projectId]);db()->prepare('DELETE cpl FROM content_platform_links cpl JOIN content_items ci ON ci.id=cpl.content_item_id WHERE ci.project_id=?')->execute([$projectId]);db()->prepare('DELETE FROM content_items WHERE project_id=?')->execute([$projectId]);db()->prepare('DELETE FROM projects WHERE id=?')->execute([$projectId]); }
function deleteClientData(int $clientId,bool $deleteClient=false): void { $q=db()->prepare('SELECT id FROM projects WHERE client_id=?');$q->execute([$clientId]);foreach($q->fetchAll(PDO::FETCH_COLUMN) as $pid)deleteProjectData((int)$pid);foreach(['daily_platform_stats','content_updates','report_shares','user_client_access'] as $table)db()->prepare("DELETE FROM $table WHERE client_id=?")->execute([$clientId]);if($deleteClient){db()->prepare('UPDATE users SET active=0,client_id=NULL WHERE client_id=?')->execute([$clientId]);db()->prepare('DELETE FROM clients WHERE id=?')->execute([$clientId]);} }
function selectOptions(array $rows, ?string $selected=null): string { $o='';foreach($rows as $r)$o.='<option value="'.h($r['setting_key']).'" '.($selected===$r['setting_key']?'selected':'').'>'.h($r['label']).'</option>';return $o; }
function reportAttachments(): void { $r=$_GET['r']??'';if(!in_array($r,['report','report_v2','report_v3','shared_report'],true))return;$clientId=0;if($r==='shared_report'&&!empty($_GET['token'])){$q=db()->prepare('SELECT client_id FROM report_shares WHERE token=?');$q->execute([$_GET['token']]);$clientId=(int)$q->fetchColumn();}else $clientId=user()&&user()['role']==='client'?(int)user()['client_id']:(int)($_POST['client_id']??$_GET['client_id']??0);if(!$clientId)return;$q=db()->prepare('SELECT a.*,ci.title item_title,ci.item_kind,p.campaign FROM attachments a JOIN content_items ci ON ci.id=a.content_item_id JOIN projects p ON p.id=ci.project_id WHERE p.client_id=? ORDER BY ci.title,a.created_at');$q->execute([$clientId]);$files=$q->fetchAll();if(!$files)return;$groups=[];foreach($files as $f)$groups[$f['item_title']][]=$f;?><section class="card" style="margin-top:18px"><h2>Ads & Content Insight Attachments</h2><?php foreach($groups as $title=>$items):?><h3><?=h($title)?></h3><div class="grid"><?php foreach($items as $f):?><div class="card"><?php if(strpos($f['mime_type'],'image/')===0):?><a href="<?=h(url('uploads/'.$f['stored_name']))?>" target="_blank"><img src="<?=h(url('uploads/'.$f['stored_name']))?>" alt="<?=h($f['original_name'])?>" style="width:100%;max-height:420px;object-fit:contain"></a><?php else:?><a href="<?=h(url('uploads/'.$f['stored_name']))?>" target="_blank"><?=h($f['original_name'])?></a><?php endif;?></div><?php endforeach;?></div><?php endforeach;?></section><?php }
function uiEnhancements(): void { if(!user()||user()['role']==='client')return;$r=$_GET['r']??'';$token=csrf();if($r==='report_v2'){?><script>document.querySelectorAll('.flash.ok a[href*="shared_report"]').forEach(a=>{let b=document.createElement('button');b.type='button';b.className='btn small';b.textContent='Copy link';b.onclick=async()=>{await navigator.clipboard.writeText(a.href);b.textContent='Copied';setTimeout(()=>b.textContent='Copy link',1500)};a.parentNode.append(' ',b)});document.querySelectorAll('.metric b').forEach(b=>{let n=Number(b.textContent.replace(/,/g,''));if(Number.isFinite(n)&&!b.textContent.includes('RM'))b.textContent=n.toLocaleString('en-MY')});</script><?php }if(!canDo('delete')){?><style>form:has(input[name="type"][value="item"]),form:has(input[name="type"][value="project"]),form:has(input[name="type"][value="client"]),form:has(input[name="type"][value="clear_client"]){display:none!important}</style><?php }if($r==='daily_updates_v3'){?><script>
let rangeForm=document.querySelector('form.toolbar input[name="r"][value="daily_updates_v3"]')?.form;if(rangeForm&&!rangeForm.querySelector('[name="manual_from"]')){let m=document.createElement('input');m.type='hidden';m.name='manual_from';m.value='1';rangeForm.appendChild(m);}
document.querySelectorAll('input[name="action"][value="delete_content"]').forEach((h,i)=>{let f=h.form,b=document.createElement('button');b.type='button';b.className='btn small gray';b.textContent='Edit';b.onclick=()=>{let row=document.querySelectorAll('#contentCard table tr')[i+1],entry=document.querySelector('input[name="action"][value="content"]').form;if(!row||!entry)return;let cells=row.cells;entry.querySelector('[name="update_date"]').value=cells[0].innerText.split('/').reverse().join('-');entry.querySelector('[name="content_kind"]').value=cells[1].innerText.includes('Video')?'video':'graphic';entry.querySelectorAll('[name="content_platforms[]"]').forEach((x,j)=>x.checked=(cells[j+2]&&cells[j+2].innerText.includes('☑')));entry.scrollIntoView({behavior:'smooth'});};f.parentNode.insertBefore(b,f);});
let wa=document.querySelector('a[href^="https://wa.me/"]');if(wa){let b=document.createElement('button');b.type='button';b.className='btn';b.textContent='Share text + content image';b.onclick=async()=>{let table=document.querySelector('#contentCard table'),rows=[...table.rows].map(r=>[...r.cells].map(c=>c.innerText)),card=document.querySelector('#contentCard'),c=document.createElement('canvas');c.width=1400;c.height=Math.max(500,230+rows.length*70);let x=c.getContext('2d');x.fillStyle='#fff';x.fillRect(0,0,c.width,c.height);x.fillStyle='#fff500';x.fillRect(0,0,c.width,160);x.fillStyle='#111';x.textAlign='center';x.font='bold 42px Arial';x.fillText(card.querySelector('h2').innerText,700,65);x.font='bold 30px Arial';x.fillText(card.querySelector('h3').innerText,700,115);let cw=c.width/rows[0].length,y=210;x.font='bold 25px Arial';rows.forEach(row=>{row.forEach((v,ci)=>{x.strokeStyle='#bbb';x.strokeRect(ci*cw,y-38,cw,62);x.fillText(v,ci*cw+cw/2,y)});y+=62});let blob=await new Promise(ok=>c.toBlob(ok,'image/png')),file=new File([blob],'content-update.png',{type:'image/png'}),text=document.getElementById('dailyText').innerText;if(navigator.share&&navigator.canShare&&navigator.canShare({files:[file]})){await navigator.share({text,files:[file]});}else{let a=document.createElement('a');a.download='content-update.png';a.href=URL.createObjectURL(blob);a.click();window.open('https://wa.me/?text='+encodeURIComponent(text),'_blank');alert('Image downloaded. Attach it in the opened WhatsApp chat.');}};wa.parentNode.appendChild(b);}
</script><?php }if(in_array($r,['project','item_edit','content'],true)){?><script>document.querySelectorAll('a.btn[href*="r=item_edit&id="]').forEach(a=>{let id=new URL(a.href).searchParams.get('id');if(!id||a.dataset.deleteAdded)return;a.dataset.deleteAdded='1';let f=document.createElement('form');f.method='post';f.action=<?=json_encode(route('record_delete'))?>;f.style.display='inline';f.onsubmit=()=>confirm('Delete this item and its related insights and attachments? This cannot be undone.');f.innerHTML='<input type="hidden" name="_csrf" value="<?=h($token)?>"><input type="hidden" name="type" value="item"><input type="hidden" name="id" value="'+id+'"><button class="btn small gray">Delete</button>';a.parentNode.appendChild(document.createTextNode(' '));a.parentNode.appendChild(f);});</script><?php }if($r==='content'){?><script>(function(){var iid=new URL(location.href).searchParams.get('id');var tables=Array.from(document.querySelectorAll('table'));var perf=tables.find(function(t){return t.textContent.indexOf('Reach')>=0&&t.textContent.indexOf('Impressions')>=0;});if(!perf)return;Array.from(perf.rows).slice(1).forEach(function(row){var d=row.cells[0].textContent.trim();var c=row.insertCell();var a=document.createElement('a');a.className='btn small gray';a.textContent='Edit / Delete';a.href=<?=json_encode(route('performance_manage'))?>+'&content_id='+iid+'&date='+encodeURIComponent(d);c.appendChild(a);});})();</script><?php }if($r==='clients'){?><script>document.querySelectorAll('a[href*="r=clients&edit="]:not(.client-settings)').forEach(a=>{let id=new URL(a.href).searchParams.get('edit');function add(type,label,message){let f=document.createElement('form');f.method='post';f.action=<?=json_encode(route('record_delete'))?>;f.style.display='inline';f.onsubmit=()=>confirm(message);f.innerHTML='<input type="hidden" name="_csrf" value="<?=h($token)?>"><input type="hidden" name="type" value="'+type+'"><input type="hidden" name="id" value="'+id+'"><button class="btn small gray">'+label+'</button>';a.parentNode.appendChild(document.createTextNode(' '));a.parentNode.appendChild(f)}add('clear_client','Clear data','Clear all data for this client but keep the client name? This cannot be undone.');add('client','Delete','Delete this client and ALL related projects, insights, files, daily updates and tracking data? This cannot be undone.');});</script><?php }if($r==='projects'){?><script>document.querySelectorAll('a[href*="r=project&id="]').forEach(a=>{if(a.textContent.trim()!=='Open workspace')return;let id=new URL(a.href).searchParams.get('id'),f=document.createElement('form');f.method='post';f.action=<?=json_encode(route('record_delete'))?>;f.style.display='inline';f.onsubmit=()=>confirm('Delete this Client Project and ALL content, insights, files and tracking data inside it? This cannot be undone.');f.innerHTML='<input type="hidden" name="_csrf" value="<?=h($token)?>"><input type="hidden" name="type" value="project"><input type="hidden" name="id" value="'+id+'"><button class="btn small gray">Delete project</button>';a.parentNode.appendChild(document.createTextNode(' '));a.parentNode.appendChild(f);});</script><?php }}
function compactHeaderNav(): void { if(!user())return;$token=csrf();?><style>header{gap:20px}header nav{display:flex;align-items:center;gap:8px;flex-wrap:nowrap}header nav>a{white-space:nowrap}.nav-more{position:relative}.nav-more summary{list-style:none;cursor:pointer;padding:10px 13px;color:#fff;font-weight:600}.nav-more summary::-webkit-details-marker{display:none}.nav-more[open] summary{background:rgba(255,255,255,.12);border-radius:8px}.nav-menu{position:absolute;right:0;top:44px;z-index:1000;min-width:230px;padding:8px;background:#102a46;border:1px solid rgba(255,255,255,.18);border-radius:10px;box-shadow:0 12px 35px rgba(0,0,0,.25)}.nav-menu a{display:block!important;padding:10px 12px!important;color:#fff!important;text-decoration:none;border-radius:7px}.nav-menu a:hover{background:rgba(255,255,255,.12)}@media(max-width:850px){header{align-items:flex-start}header nav{flex-wrap:wrap}.who{display:none}}</style><script>(function(){let nav=document.querySelector('header nav');if(!nav)return;let keep=['Dashboard','Projects','Daily Updates','Reports'],links=[...nav.querySelectorAll(':scope > a')],more=links.filter(a=>!keep.includes(a.textContent.trim())&&a.textContent.trim()!=='Sign out');let sign=links.find(a=>a.textContent.trim()==='Sign out');if(more.length){let d=document.createElement('details');d.className='nav-more';d.innerHTML='<summary>More ▾</summary><div class="nav-menu"></div>';more.forEach(a=>d.querySelector('.nav-menu').appendChild(a));if(sign)d.querySelector('.nav-menu').appendChild(sign);nav.appendChild(d)}if(location.search.includes('r=meta_connections')){let bar=document.querySelector('main .toolbar:nth-of-type(2)');if(bar&&!bar.querySelector('[value="sync_linked_posts"]')){let f=document.createElement('form');f.method='post';f.innerHTML='<input type="hidden" name="_csrf" value="<?=h($token)?>"><button class="btn" name="action" value="sync_linked_posts">Sync linked post Insights</button>';bar.prepend(f)}}})();</script><?php }
function render(string $title, callable $body): void { $u=user(); $flash=$_SESSION['flash']??null;unset($_SESSION['flash']); ?><!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><?=h($title)?> · <?=h(appName())?></title><link rel="stylesheet" href="<?=h(url('assets/app.css'))?>"></head><body>
<?php if($u): ?><header><a class="brand" href="<?=h(route('dashboard'))?>">Bright Marketing Solutions</a><nav><a href="<?=h(route('dashboard'))?>">Dashboard</a><a href="<?=h(route('projects'))?>">Projects</a><a href="<?=h(route('daily_updates'))?>">Daily Updates</a><?php if($u['role']!=='client'): ?><?php if(canDo('manage_tracking')):?><a href="<?=h(route('tracking_hub'))?>">Tracking Hub</a><?php endif;?><?php if($u['role']==='admin'):?><a href="<?=h(route('meta_connections'))?>">Meta Connections</a><?php endif;?><?php if(canDo('manage_contracts')):?><a href="<?=h(route('contracts'))?>">Contracts</a><?php endif;?><a href="<?=h(route('clients'))?>">Clients</a><a href="<?=h(route('users'))?>">Users</a><?php endif;?><?php if($u['role']==='admin'):?><a href="<?=h(route('settings'))?>">Settings</a><?php endif;?><a href="<?=h(route('report'))?>">Reports</a><a href="<?=h(route('logout'))?>">Sign out</a></nav><span class="who"><?=h($u['name'])?> · <?=h($u['job_title']?:ucfirst($u['role']))?></span></header><?php endif;?>
<main class="container"><?php if($flash):?><div class="flash <?=h($flash[1])?>"><?=h($flash[0])?></div><?php endif;?><?php $body();workspaceScheduleV2();dashboardPublishedSummary();dashboardTodaySchedule();itemEditEnhancements();clientPortalEnhancements();clientReportHistory();dragOrderEnhancements();reportAttachments();contentPlatformInsightDetails();tiktokIntegrationStatus();uiEnhancements();reportNumberEnhancements();compactHeaderNav();?></main><footer>Bright Marketing Solutions Tracker Portal · <?=date('Y')?></footer></body></html><?php }
function metricTotals(string $where, array $args): array { $q=db()->prepare("SELECT COALESCE(SUM(pe.reach),0) reach,COALESCE(SUM(pe.impressions),0) impressions,COALESCE(SUM(pe.views),0) views,COALESCE(SUM(pe.likes+pe.comments+pe.shares+pe.saves),0) engagement,COALESCE(SUM(pe.leads),0) leads,COALESCE(SUM(pe.sales),0) sales,COALESCE(SUM(pe.ad_spend),0) spend FROM performance_entries pe JOIN projects p ON p.id=pe.project_id LEFT JOIN content_items ci ON ci.id=pe.content_item_id WHERE 1=1 $where");$q->execute($args);return $q->fetch(); }
function dailyComparison(int $clientId, string $from, string $to): array { $q=db()->prepare('SELECT d.platform_key,d.metric_key,MIN(CASE WHEN d.stat_date=? THEN d.metric_value END) start_value,MIN(CASE WHEN d.stat_date=? THEN d.metric_value END) end_value FROM daily_platform_stats d WHERE d.client_id=? AND d.stat_date IN (?,?) GROUP BY d.platform_key,d.metric_key ORDER BY d.platform_key');$q->execute([$from,$to,$clientId,$from,$to]);return $q->fetchAll(); }
function reportContentRows(int $clientId,string $from,string $to): array { if(!$clientId)return [];$q=db()->prepare("SELECT ci.id,ci.title,ci.platforms_json,ci.status_key,ci.published_at,c.name client_name,COALESCE(SUM(pe.reach),0) reach,COALESCE(SUM(pe.impressions),0) impressions,COALESCE(SUM(pe.views),0) views,COALESCE(SUM(pe.likes+pe.comments+pe.shares+pe.saves),0) engagement,COALESCE(SUM(pe.clicks),0) clicks,COALESCE(SUM(pe.leads),0) leads,(SELECT GROUP_CONCAT(CONCAT(cpl.platform_key,'|',cpl.post_url) SEPARATOR ';;') FROM content_platform_links cpl WHERE cpl.content_item_id=ci.id) platform_links FROM content_items ci JOIN projects p ON p.id=ci.project_id JOIN clients c ON c.id=p.client_id LEFT JOIN performance_entries pe ON pe.content_item_id=ci.id WHERE p.client_id=? AND DATE(ci.published_at) BETWEEN ? AND ? AND ci.published_at IS NOT NULL GROUP BY ci.id,ci.title,ci.platforms_json,ci.status_key,ci.published_at,c.name ORDER BY ci.published_at,ci.sort_order,ci.title");$q->execute([$clientId,$from,$to]);return $q->fetchAll(); }
function platformLinksHtml(array $row): string { $parts=[];foreach(explode(';;',$row['platform_links']??'') as $pair){if(!$pair||!str_contains($pair,'|'))continue;[$platform,$link]=explode('|',$pair,2);if(validUrl($link))$parts[]='<a href="'.h($link).'" target="_blank" rel="noopener">'.h(strtoupper($platform)).'</a>';}$plain=implode(', ',json_decode($row['platforms_json']??'[]',true)?:[]);return $parts?implode(' · ',$parts):h($plain?:'—'); }
function workspaceSchedule(): void { if(($_GET['r']??'')!=='project'||empty($_GET['id']))return;$id=(int)$_GET['id'];$q=db()->prepare("SELECT ci.*,(SELECT GROUP_CONCAT(CONCAT(cpl.platform_key,'|',cpl.post_url) SEPARATOR ';;') FROM content_platform_links cpl WHERE cpl.content_item_id=ci.id) platform_links FROM content_items ci WHERE ci.project_id=? ORDER BY COALESCE(ci.scheduled_at,ci.published_at,ci.created_at)");$q->execute([$id]);$rows=$q->fetchAll();?><section class="card" style="margin-top:18px"><div class="toolbar"><div style="flex:1"><h2>Marketing Planning Schedule</h2><p class="muted">Plan upcoming posts, then update the same record with published status, platforms, links, Insights and attachments.</p></div><?php if(user()['role']!=='client'&&canDo('create')):?><a class="btn" href="<?=h(route('item_edit',['project_id'=>$id,'kind'=>'posting']))?>">+ Plan content</a><?php endif;?></div><?php if(!$rows):?><p class="muted">No scheduled content yet.</p><?php else:?><table><tr><th>Planned date</th><th>Content</th><th>Type</th><th>Status</th><th>Published</th><th>Live links</th><th></th></tr><?php foreach($rows as $x):?><tr><td><?=h($x['scheduled_at']?:'Not scheduled')?></td><td><b><?=h($x['title'])?></b></td><td><?=h(ucwords(str_replace('_',' ',$x['content_type_key']?:$x['item_kind'])))?></td><td><span class="badge"><?=h($x['status_key']?:'Planning')?></span></td><td><?=h($x['published_at']?:'Not published')?></td><td><?=platformLinksHtml($x)?></td><td><a class="btn small gray" href="<?=h(route('item_links',['id'=>$x['id']]))?>"><?=user()['role']==='client'?'View':'Links'?></a><?php if(user()['role']!=='client'&&canDo('edit')):?> <a class="btn small gray" href="<?=h(route('item_edit',['id'=>$x['id']]))?>">Update</a><?php endif;?></td></tr><?php endforeach;?></table><?php endif;?></section><?php }
function dashboardPublishedSummary(): void { if(($_GET['r']??'')!=='dashboard_v3')return;$days=$_GET['days']??'30';$from=$_GET['from']??($days==='1'?date('Y-m-d'):date('Y-m-d',strtotime("-$days days")));$to=$_GET['to']??date('Y-m-d');$clientId=user()['role']==='client'?(int)user()['client_id']:(int)($_GET['client_id']??0);$sql='SELECT COUNT(*) total,SUM(ci.published_at IS NOT NULL) published,SUM(ci.updated_at>ci.created_at) updated FROM content_items ci JOIN projects p ON p.id=ci.project_id WHERE DATE(COALESCE(ci.published_at,ci.scheduled_at,ci.created_at)) BETWEEN ? AND ?';$args=[$from,$to];if($clientId){$sql.=' AND p.client_id=?';$args[]=$clientId;}$q=db()->prepare($sql);$q->execute($args);$x=$q->fetch();?><section style="margin-top:18px"><h2>Content Progress</h2><div class="grid"><div class="card metric"><span>Scheduled / Added</span><b><?=number_format((int)$x['total'])?></b></div><div class="card metric"><span>Published</span><b><?=number_format((int)$x['published'])?></b></div><div class="card metric"><span>Updated records</span><b><?=number_format((int)$x['updated'])?></b></div></div></section><?php }
function reportNumberEnhancements(): void { ?><script>document.querySelectorAll('.metric b,[data-number]').forEach(b=>{let raw=b.textContent.trim(),prefix=raw.startsWith('RM')?'RM ':'';let cleaned=raw.replace(/^RM\s*/,'').replace(/,/g,'');let n=Number(cleaned);if(Number.isFinite(n))b.textContent=prefix+n.toLocaleString('en-MY',{minimumFractionDigits:prefix?2:0,maximumFractionDigits:2})});document.querySelectorAll('select[name="days"] option[value="1"]').forEach(o=>o.textContent='Today / Single day');if(new URL(location.href).searchParams.get('r')==='project'){let top=document.querySelector('main .toolbar');if(top&&!top.querySelector('[data-project-back]')){let a=document.createElement('a');a.href=<?=json_encode(route('projects'))?>;a.className='btn gray';a.dataset.projectBack='1';a.textContent='Back to Client Projects';top.appendChild(a)}document.querySelectorAll('a[href*="r=content&id="]').forEach(a=>{let row=a.closest('tr');if(!row||!/published|completed/i.test(row.innerText))return;let id=new URL(a.href).searchParams.get('id'),b=document.createElement('a');b.href=<?=json_encode(route('item_links'))?>+'&id='+id;b.className='btn small gray';b.textContent='Update links';a.parentNode.append(' ',b)})}</script><?php }
