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 '';
}
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;
}