PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); return $pdo; } function e($v): string { return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function url(string $path=''): string { return APP_BASE . ($path ? '/'.ltrim($path,'/') : ''); } function redirect(string $to): never { header('Location: '.$to); exit; } function csrf_token(): string { if (empty($_SESSION['_csrf'])) { $_SESSION['_csrf'] = bin2hex(random_bytes(32)); } return $_SESSION['_csrf']; } function csrf_check(): void { $token = $_POST['_csrf'] ?? ''; if (!is_string($token) || !hash_equals(csrf_token(), $token)) { http_response_code(419); exit('Solicitud expirada o token de seguridad inválido. Recarga la página.'); } } function flash(string $msg, string $type='ok'): void { $_SESSION['_flash'] = [$msg,$type]; } function show_flash(): void { if (!empty($_SESSION['_flash'])) { [$m,$t] = $_SESSION['_flash']; unset($_SESSION['_flash']); echo '
'.e($m).'
'; } } function setting(string $key, $default='') { static $row = null; if ($row === null) { $row = db()->query("SELECT * FROM settings WHERE id=1")->fetch() ?: []; } return $row[$key] ?? $default; } function require_login(?string $role=null): array { $u = $_SESSION['user'] ?? null; if (!$u || ($role && $u['role'] !== $role)) { redirect(url(basename(__FILE__))); } return $u; } function login_user(array $u): void { session_regenerate_id(true); $_SESSION['user'] = [ 'id'=>(int)$u['id'], 'name'=>$u['name'], 'role'=>$u['role'], 'barber_id'=>$u['barber_id'] ]; db()->prepare("UPDATE users SET last_login=NOW() WHERE id=?") ->execute([$u['id']]); } function upload_image(string $field): ?string { if (empty($_FILES[$field]['name'])) return null; if ( $_FILES[$field]['error'] !== UPLOAD_ERR_OK || $_FILES[$field]['size'] > 3*1024*1024 ) { throw new RuntimeException('La imagen supera 3 MB o no pudo subirse.'); } $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($_FILES[$field]['tmp_name']); $exts = [ 'image/jpeg'=>'jpg', 'image/png'=>'png', 'image/webp'=>'webp' ]; if (!isset($exts[$mime])) { throw new RuntimeException('Formato no permitido. Usa JPG, PNG o WEBP.'); } if (!is_dir(UPLOAD_DIR) && !mkdir(UPLOAD_DIR,0755,true)) { throw new RuntimeException('No se pudo crear la carpeta uploads.'); } $name = bin2hex(random_bytes(16)).'.'.$exts[$mime]; if (!move_uploaded_file($_FILES[$field]['tmp_name'], UPLOAD_DIR.'/'.$name)) { throw new RuntimeException('No se pudo guardar la imagen.'); } return UPLOAD_URL.'/'.$name; } function fmt_date(string $dt): string { return date('d/m/Y H:i', strtotime($dt)); } function wa_link(string $phone, string $message): string { $digits = preg_replace('/\D+/', '', $phone); return 'https://wa.me/'.$digits.'?text='.rawurlencode($message); } function page_head(string $title): void { $brand = setting('business_name','OLIMPO BARBER STUDIO'); echo ' '.e($title).' | '.e($brand).' '; echo '
'.e($brand).'
'; } function page_foot(): void { echo ''; } function status_label(string $s): string { return [ 'pending'=>'Pendiente', 'confirmed'=>'Confirmada', 'completed'=>'Atendida', 'cancelled'=>'Cancelada', 'no_show'=>'No asistió' ][$s] ?? $s; } function get_slots(int $barberId, int $serviceId, string $date): array { $pdo=db(); $d=DateTime::createFromFormat('Y-m-d',$date); if (!$d || $d->format('Y-m-d')!==$date) return []; $today=new DateTime('today'); $max=(int)setting('max_days_ahead',45); if ($d<$today || $d>(clone $today)->modify("+$max days")) { return []; } $svc=$pdo->prepare( "SELECT duration_minutes FROM services WHERE id=? AND active=1" ); $svc->execute([$serviceId]); $service=$svc->fetch(); if(!$service) return []; $weekday=(int)$d->format('N'); $ex=$pdo->prepare( "SELECT * FROM schedule_exceptions WHERE exception_date=?" ); $ex->execute([$date]); $exception=$ex->fetch(); if($exception){ if((int)$exception['closed']) return []; $opens=$exception['opens']; $closes=$exception['closes']; } else { $h=$pdo->prepare("SELECT * FROM business_hours WHERE weekday=?"); $h->execute([$weekday]); $hours=$h->fetch(); if(!$hours || (int)$hours['closed']) return []; $opens=$hours['opens']; $closes=$hours['closes']; } $bh=$pdo->prepare( "SELECT * FROM barber_hours WHERE barber_id=? AND weekday=?" ); $bh->execute([$barberId,$weekday]); $barberHours=$bh->fetch(); if($barberHours){ if((int)$barberHours['day_off']) return []; $opens=max((string)$opens,(string)$barberHours['opens']); $closes=min((string)$closes,(string)$barberHours['closes']); } if(!$opens || !$closes || $opens>=$closes) return []; $duration=(int)$service['duration_minutes']; $interval=max(5,(int)setting('slot_interval',15)); $start=new DateTime($date.' '.$opens); $end=new DateTime($date.' '.$closes); $now=(new DateTime())->modify( '+'.(int)setting('min_notice_hours',2).' hours' ); $q=$pdo->prepare( "SELECT starts_at,ends_at FROM appointments WHERE barber_id=? AND DATE(starts_at)=? AND status IN ('pending','confirmed')" ); $q->execute([$barberId,$date]); $busy=$q->fetchAll(); $b=$pdo->prepare( "SELECT starts_at,ends_at FROM barber_blocks WHERE barber_id=? AND DATE(starts_at)=?" ); $b->execute([$barberId,$date]); $blocks=$b->fetchAll(); $slots=[]; while((clone $start)->modify("+$duration minutes") <= $end){ $slotStart=clone $start; $slotEnd=(clone $start)->modify("+$duration minutes"); $available=true; if($slotStart<$now) $available=false; foreach(array_merge($busy,$blocks) as $item){ if( $slotStart < new DateTime($item['ends_at']) && $slotEnd > new DateTime($item['starts_at']) ){ $available=false; break; } } if($available) $slots[]=$slotStart->format('H:i'); $start->modify("+$interval minutes"); } return $slots; } session_start(); try { $pdo=db(); $count=(int)$pdo->query( "SELECT COUNT(*) FROM users WHERE role='admin'" )->fetchColumn(); } catch(Throwable $ex) { http_response_code(500); exit( 'No se pudo conectar a MySQL. Revisa DB_HOST, DB_NAME, DB_USER y DB_PASS en este archivo, e importa schema.sql. Detalle: '. e($ex->getMessage()) ); } if(isset($_GET['logout'])){ $_SESSION=[]; session_destroy(); redirect(url('admin.php')); } if($count===0){ if( $_SERVER['REQUEST_METHOD']==='POST' && ($_POST['action']??'')==='install' ){ csrf_check(); $name=trim($_POST['name']??''); $email=strtolower(trim($_POST['email']??'')); $pass=$_POST['password']??''; if( $name==='' || !filter_var($email,FILTER_VALIDATE_EMAIL) || strlen($pass)<12 ){ $error='Completa nombre, correo válido y contraseña de al menos 12 caracteres.'; } else { $st=$pdo->prepare( "INSERT INTO users(name,email,password_hash,role) VALUES(?,?,?,'admin')" ); $st->execute([ $name, $email, password_hash($pass,PASSWORD_DEFAULT) ]); flash('Administrador creado. Inicia sesión.'); redirect(url('admin.php')); } } page_head('Instalación inicial'); echo '

Configurar administrador

Primera instalación de OLIMPO BARBER STUDIO. Crea una contraseña segura de al menos 12 caracteres.

'; show_flash(); if(!empty($error)){ echo '
'.e($error).'
'; } echo '

Después de crear la cuenta, elimina permisos de escritura innecesarios en el hosting y activa HTTPS.

'; page_foot(); exit; } if( $_SERVER['REQUEST_METHOD']==='POST' && ($_POST['action']??'')==='login' ){ csrf_check(); $q=$pdo->prepare( "SELECT * FROM users WHERE email=? AND role='admin' AND active=1" ); $q->execute([strtolower(trim($_POST['email']??''))]); $u=$q->fetch(); if($u && password_verify($_POST['password']??'',$u['password_hash'])){ login_user($u); redirect(url('admin.php')); } $error='Credenciales incorrectas.'; } $adminUser=$_SESSION['user']??null; if(!$adminUser || $adminUser['role']!=='admin'){ page_head('Acceso administrador'); echo '

Administración

Ingresa con tu cuenta de administrador.

'; show_flash(); if(!empty($error)){ echo '
'.e($error).'
'; } echo '

'; page_foot(); exit; }