> 'secondary']]; } if ($status === 'pending' && $paymentStatus !== 'paid') { return [['type' => 'payNow', 'label' => esc_html__('Pay Now', 'rox-appointment-booking'), 'variant' => 'primary']]; } // The booking card's Reschedule/Cancel buttons follow the same admin // switches as the detail drawer's (Settings > Booking). With both off // the card keeps its price and stays clickable, just without actions. return array_values(array_filter([ rox_appointment_booking_customer_can_reschedule() ? ['type' => 'reschedule', 'label' => esc_html__('Reschedule', 'rox-appointment-booking'), 'variant' => 'secondary'] : null, rox_appointment_booking_customer_can_cancel() ? ['type' => 'cancel', 'label' => esc_html__('Cancel', 'rox-appointment-booking'), 'variant' => 'secondary'] : null, ])); } /** @return array{0:string,1:string} [pillStatus, label] */ private function statusMeta(string $status): array { switch ($status) { case 'approved': return ['approved', '✓ ' . esc_html__('Approved', 'rox-appointment-booking')]; case 'pending': return ['pending', '⏱ ' . esc_html__('Pending Approval', 'rox-appointment-booking')]; case 'completed': return ['completed', '✓ ' . esc_html__('Completed', 'rox-appointment-booking')]; case 'cancelled': return ['cancelled', '✕ ' . esc_html__('Cancelled', 'rox-appointment-booking')]; case 'no_show': return ['cancelled', '✕ ' . esc_html__('No Show', 'rox-appointment-booking')]; default: return ['pending', ucfirst($status)]; } } private function paymentWord(string $paymentStatus): string { switch ($paymentStatus) { case 'paid': return esc_html__('Fully Paid', 'rox-appointment-booking'); case 'partially_paid': return esc_html__('Partially Paid', 'rox-appointment-booking'); case 'refunded': return esc_html__('Refunded', 'rox-appointment-booking'); case 'failed': return esc_html__('Payment Failed', 'rox-appointment-booking'); case 'processing': return esc_html__('Processing', 'rox-appointment-booking'); default: return esc_html__('Not Paid', 'rox-appointment-booking'); } } private function paymentColor(string $paymentStatus): string { switch ($paymentStatus) { case 'paid': return 'var(--green-text)'; case 'partially_paid': return 'var(--yellow-text)'; case 'refunded': return 'var(--teal-text)'; case 'failed': return 'var(--red-text)'; default: return 'var(--yellow-text)'; } } private function relativeLabel(string $date, string $today): ?string { if (!$date) { return null; } $days = (int) floor((strtotime($date) - strtotime($today)) / DAY_IN_SECONDS); if ($days <= 0) { return esc_html__('Today', 'rox-appointment-booking'); } if ($days === 1) { return esc_html__('Tomorrow', 'rox-appointment-booking'); } // translators: %d = number of days return sprintf(esc_html__('In %d days', 'rox-appointment-booking'), $days); } private function durationMinutes(array $appt, ?ServiceModel $service): int { if (!empty($appt['start_time']) && !empty($appt['end_time'])) { $diff = (int) round((strtotime($appt['end_time']) - strtotime($appt['start_time'])) / 60); if ($diff > 0) { return $diff; } } return $service ? (int) ($service->duration ?? 0) : 0; } private function formatDuration(int $minutes): string { if ($minutes <= 0) { return ''; } if ($minutes >= 60) { $h = intdiv($minutes, 60); $m = $minutes % 60; return $m === 0 ? $h . 'h' : $h . 'h ' . $m . 'm'; } return $minutes . 'm'; } private function money(float $amount): string { return $this->currency . number_format($amount, 2); } /** * Location title + a single-line address from the core location table. * * @param int|null $locationId * @return array{title:string,address:string} */ private function getLocation($locationId): array { if (empty($locationId)) { return ['title' => '', 'address' => '']; } global $wpdb; $table = ROX_APPOINTMENT_BOOKING_DB_PREFIX . ROX_APPOINTMENT_BOOKING_PREFIX . '_location'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- no core Location model; table created by core. $row = $wpdb->get_row($wpdb->prepare("SELECT title, address FROM $table WHERE id = %d", (int) $locationId), ARRAY_A); if (!$row) { return ['title' => '', 'address' => '']; } // Reads the location table directly rather than through a model, so it // needs the same translation the public location endpoint applies. $locationId = (int) $locationId; return [ 'title' => rox_appointment_booking_translate('location', $locationId, 'title', (string) ($row['title'] ?? '')), 'address' => rox_appointment_booking_translate('location', $locationId, 'address', (string) ($row['address'] ?? '')), ]; } /** * One appointment's own line total: its service price plus any Pro * extra-service prices. Mirrors * FrontendBookingPanel\Services\AppointmentService::calculateAppointmentLineTotal() * so the price shown here matches what was actually charged for this line. */ private function appointmentLineTotal(array $appt, ?ServiceModel $service): float { $lineTotal = $service ? (float) $service->price : 0.0; $extraServiceIds = $appt['extra_services'] ?? []; if (!empty($extraServiceIds) && is_array($extraServiceIds) && class_exists('\\RoxAppointmentBookingPro\\Modules\\ExtraService\\Data\\ExtraServiceModel')) { foreach ($extraServiceIds as $extraId) { $extraService = \RoxAppointmentBookingPro\Modules\ExtraService\Data\ExtraServiceModel::find(intval($extraId)); if ($extraService) { $lineTotal += (float) $extraService->price; } } } return $lineTotal; } private function getOrderForBooking(int $bookingId): ?OrderModel { if ($bookingId <= 0) { return null; } return OrderModel::whereRaw('JSON_CONTAINS(booking_ids, %s)', [wp_json_encode($bookingId)])->first(); } }