api = $api; $this->copies = $copies; $this->connection = $connection; $this->resources = $resources; $this->business = $business; $this->team = $team; } /* --------------------------------------------------------------------- * Dashboard * ------------------------------------------------------------------ */ /** * @return array|WP_Error */ public function dashboard() { $connection = $this->connection->current(); $zone = self::zone( $connection ); $today = $this->api->get_live( Razorbooking_Endpoint::today_figures() ); if ( is_wp_error( $today ) ) { return $today; } $aggregates = isset( $today['data'] ) ? $today['data'] : array(); $bookings = $this->recent_bookings( $zone ); if ( is_wp_error( $bookings ) ) { return $bookings; } $team = $this->team_today( $zone, $bookings ); return array( 'greeting' => self::greeting( $zone ), 'stats' => self::today_stats( $aggregates ), 'bookings' => $bookings, 'team' => is_wp_error( $team ) ? array() : $team, 'permissions' => self::permissions( $connection ), 'money' => $connection->money, 'currency' => $connection->currency ? $connection->currency : 'USD', ); } /** DashboardController::getGreeting(), with the WordPress user's first name. */ private static function greeting( DateTimeZone $zone ) { $now = new DateTimeImmutable( 'now', $zone ); $hour = (int) $now->format( 'G' ); $name = trim( (string) wp_get_current_user()->display_name ); $name = $name ? explode( ' ', $name )[0] : ''; return array( 'name' => '' !== $name ? $name : 'there', 'label' => wp_date( 'l', $now->getTimestamp(), $zone ) . ' ยท ' . wp_date( 'j F', $now->getTimestamp(), $zone ), 'partOfDay' => $hour < 12 ? 'morning' : ( $hour < 17 ? 'afternoon' : 'evening' ), ); } /** * DashboardController::getTodayStats(), rule for rule. * * @param array $aggregates { today: {...}, yesterday: {...} } from GET /analytics/today. */ private static function today_stats( $aggregates ) { $today = isset( $aggregates['today'] ) ? $aggregates['today'] : array(); $yesterday = isset( $aggregates['yesterday'] ) ? $aggregates['yesterday'] : array(); $read = static function ( $row, $key ) { return isset( $row[ $key ] ) ? (float) $row[ $key ] : 0.0; }; $tile = static function ( $key, $label, $now, $before, $money = false ) { $tile = array( 'key' => $key, 'label' => $label, 'value' => $now, 'delta' => self::percent_change( $now, $before ), 'dir' => $now > $before ? 'up' : ( $now < $before ? 'down' : 'flat' ), 'trend' => $now >= $before ? 'Up from yesterday' : 'Down from yesterday', 'caption' => $money ? $before : self::number( $before ) . ' yesterday', ); if ( $money ) { $tile['money'] = true; $tile['captionMoney'] = true; } return $tile; }; return array( $tile( 'bookings', "Today's bookings", $read( $today, 'no_of_bookings' ), $read( $yesterday, 'no_of_bookings' ) ), $tile( 'cancellations', "Today's cancellations", $read( $today, 'cancellation' ), $read( $yesterday, 'cancellation' ) ), $tile( 'revenue', 'Revenue today', $read( $today, 'order' ), $read( $yesterday, 'order' ), true ), ); } /** Null when there is nothing to compare against โ€” DashboardController::percentChange(). */ private static function percent_change( $now, $before ) { if ( $before <= 0 ) { return null; } return (int) round( ( ( $now - $before ) / $before ) * 100 ); } /** PHP prints 4.0 as "4" in the admin's string concatenation; so does this. */ private static function number( $value ) { return floor( $value ) == $value ? (string) (int) $value : (string) $value; // phpcs:ignore Universal.Operators.StrictComparisons } /** * DashboardController::getRecentBookings(): up to 25 bookings from the * start of the business's day a week ago. The API's `from` is a day in the * business's time zone, so that day is what is asked for. */ private function recent_bookings( DateTimeZone $zone ) { $start = ( new DateTimeImmutable( 'now', $zone ) )->setTime( 0, 0 )->modify( '-7 days' ); // Live, never stored: bookings hold customers' personal data. $response = $this->api->get_live( Razorbooking_Endpoint::bookings_from( $start->format( 'Y-m-d' ), 100 ) ); if ( is_wp_error( $response ) ) { return $response; } $rows = isset( $response['data'] ) ? (array) $response['data'] : array(); $from = $start->getTimestamp(); $out = array(); foreach ( $rows as $order ) { $at = isset( $order['booking_date'] ) ? strtotime( (string) $order['booking_date'] ) : false; if ( false === $at || $at < $from ) { continue; } $out[] = self::booking_row( $order ); if ( count( $out ) >= 25 ) { break; } } return $out; } /** Only the fields the dashboard reads โ€” never the whole order. */ private static function booking_row( $order ) { $item = isset( $order['items'][0] ) ? $order['items'][0] : array(); $customer = isset( $order['customer'] ) && is_array( $order['customer'] ) ? $order['customer'] : array(); return array( 'id' => isset( $order['id'] ) ? (int) $order['id'] : 0, 'status' => isset( $order['status'] ) ? (string) $order['status'] : 'pending', 'booking_date' => isset( $order['booking_date'] ) ? $order['booking_date'] : null, 'grand_total' => isset( $order['grand_total'] ) ? (float) $order['grand_total'] : 0, 'balance_due' => isset( $order['balance_due'] ) ? (float) $order['balance_due'] : 0, 'customer' => array( 'name' => isset( $customer['name'] ) ? $customer['name'] : null, 'primary_phone' => isset( $customer['primary_phone'] ) ? $customer['primary_phone'] : null, 'email' => isset( $customer['email'] ) ? $customer['email'] : null, ), 'items' => array( array( 'title' => isset( $item['title'] ) ? $item['title'] : null, 'booking_date' => isset( $item['booking_date'] ) ? $item['booking_date'] : null, 'staff_id' => isset( $item['staff_id'] ) ? $item['staff_id'] : null, 'service' => array( 'name' => isset( $item['service']['name'] ) ? $item['service']['name'] : null ), 'staff' => array( 'name' => isset( $item['staff']['name'] ) ? $item['staff']['name'] : null ), ), ), ); } /** * DashboardController::getTeamToday(): the first four team members, their * bookings today, and their next one. Status is busy or available only; * the API doesn't give a site key the availability history. * * @param array $bookings The rows recent_bookings() returned. */ private function team_today( DateTimeZone $zone, $bookings ) { $response = $this->copies->read( Razorbooking_Endpoint::team_list() ); if ( is_wp_error( $response ) ) { return $response; } // The first four, as Home shows them. $response['data'] = array_slice( isset( $response['data'] ) ? (array) $response['data'] : array(), 0, 4 ); $start = ( new DateTimeImmutable( 'now', $zone ) )->setTime( 0, 0 )->getTimestamp(); $end = $start + DAY_IN_SECONDS - 1; $now = time(); // OrderItems today, with a staff member, on orders that are not cancelled. $by_staff = array(); foreach ( $bookings as $order ) { if ( 'cancelled' === $order['status'] ) { continue; } foreach ( $order['items'] as $item ) { $at = $item['booking_date'] ? strtotime( (string) $item['booking_date'] ) : false; if ( empty( $item['staff_id'] ) || false === $at || $at < $start || $at > $end ) { continue; } $by_staff[ (int) $item['staff_id'] ][] = array( 'at' => $at, 'iso' => $item['booking_date'], 'with' => $order['customer']['name'], ); } } $team = array(); foreach ( isset( $response['data'] ) ? (array) $response['data'] : array() as $member ) { $id = isset( $member['id'] ) ? (int) $member['id'] : 0; $mine = isset( $by_staff[ $id ] ) ? $by_staff[ $id ] : array(); usort( $mine, static function ( $a, $b ) { return $a['at'] <=> $b['at']; } ); $next = null; foreach ( $mine as $item ) { if ( $item['at'] >= $now ) { $next = $item; break; } } $role = isset( $member['role'] ) ? (string) $member['role'] : ''; $team[] = array( 'id' => $id, 'name' => isset( $member['name'] ) ? (string) $member['name'] : '', 'role' => '' !== $role ? ucfirst( str_replace( '_', ' ', $role ) ) : null, 'status' => count( $mine ) > 0 ? 'busy' : 'available', 'bookingsToday' => count( $mine ), 'capacity' => null, 'nextAt' => $next ? gmdate( 'c', $next['at'] ) : null, 'nextWith' => $next ? $next['with'] : null, ); } return $team; } /** * Today, for Home: how many bookings, what came in, and who is next. * * "Today" is the BUSINESS's today. `/analytics/summary` takes plain dates * and applies the business timezone itself, the same boundaries * RazorBooking's own dashboard uses, so this sends the business's date. * * `/bookings?from=` is a day in the business's time zone, from its start, * so every booking still to come today is in it; those already started are * dropped here to leave only who is NEXT. A full page, so a busy day's * earlier bookings don't crowd them out. * * @return array|null Null when disconnected. */ public function today() { if ( ! $this->connection->is_connected() ) { return null; } $zone = self::zone( $this->connection->stored() ); $today = ( new DateTimeImmutable( 'now', $zone ) )->format( 'Y-m-d' ); $now = time(); $summary = $this->api->get_live( Razorbooking_Endpoint::summary( $today, $today ) ); // Live, never stored: bookings carry customers' personal data. $next = $this->api->get_live( Razorbooking_Endpoint::bookings_from( $today, Razorbooking_Endpoint::PAGE_SIZE ) ); $totals = is_wp_error( $summary ) ? Razorbooking_Raw_Data::of( array() ) : Razorbooking_Raw_Data::of( $summary )->data()->child( 'current' ); $appointments = array(); if ( ! is_wp_error( $next ) ) { foreach ( (array) ( isset( $next['data'] ) ? $next['data'] : $next ) as $row ) { if ( ! is_array( $row ) || empty( $row['booking_date'] ) ) { continue; } // A cancelled booking holds nobody's time. $status = isset( $row['status'] ) ? ( is_array( $row['status'] ) ? ( $row['status']['value'] ?? '' ) : (string) $row['status'] ) : ''; if ( 'cancelled' === $status || strtotime( $row['booking_date'] ) < $now ) { continue; } $item = isset( $row['items'][0] ) && is_array( $row['items'][0] ) ? $row['items'][0] : array(); $appointments[] = array( 'id' => isset( $row['id'] ) ? $row['id'] : null, // UTC on the wire; the browser shows it in the site's zone. 'starts_at' => $row['booking_date'], 'customer' => isset( $row['customer']['name'] ) ? $row['customer']['name'] : null, 'service' => isset( $item['service']['name'] ) ? $item['service']['name'] : ( isset( $item['name'] ) ? $item['name'] : null ), 'staff' => isset( $item['staff']['name'] ) ? $item['staff']['name'] : null, ); if ( count( $appointments ) >= 5 ) { break; } } } return array( 'bookings' => $totals->filled( 'bookings' ) ? $totals->int( 'bookings' ) : null, // What was actually taken today, not the list value of what was // booked: the one an owner asks about at 6pm is what came in. 'collected' => $totals->filled( 'collected' ) ? (float) $totals->nullable_float( 'collected' ) : null, 'appointments' => $appointments, 'unavailable' => is_wp_error( $summary ), ); } /* --------------------------------------------------------------------- * Services * ------------------------------------------------------------------ */ /** * The services page's props: every parent service with its variations, * categories and assigned team, the category tabs, and the team names the * Team column prints (GET /services returns member ids only). * * @return array|WP_Error */ public function services_table() { $services = $this->copies->read( Razorbooking_Endpoint::service_list() ); if ( is_wp_error( $services ) ) { return $services; } $categories = $this->copies->read( Razorbooking_Endpoint::category_list() ); $users = $this->copies->read( Razorbooking_Endpoint::team_list() ); $connection = $this->connection->current(); $people = array(); foreach ( is_wp_error( $users ) ? array() : (array) $users['data'] as $user ) { $people[] = array( 'id' => (int) $user['id'], 'name' => (string) $user['name'], 'avatar' => isset( $user['avatar']['original_url'] ) ? array( 'original_url' => $user['avatar']['original_url'] ) : null, ); } $tabs = array(); foreach ( is_wp_error( $categories ) ? array() : (array) $categories['data'] as $category ) { $tabs[] = array( 'id' => (int) $category['id'], 'name' => (string) $category['name'], ); } // For the quick-create drawer. An error here leaves the list empty. $resources = $this->resources->all(); return array( 'services' => array_values( (array) $services['data'] ), 'categories' => $tabs, 'team' => $people, 'resources' => is_wp_error( $resources ) ? array() : $resources, 'google_meet' => $this->business->google_meet(), 'permissions' => self::permissions( $connection ), 'currency' => $connection->currency ? $connection->currency : 'USD', 'booking_url' => $connection->booking_page_url, ); } /** * The Categories tab (as the admin's categories/index). * * @return array|WP_Error */ public function categories_table() { $categories = $this->copies->read( Razorbooking_Endpoint::category_list() ); if ( is_wp_error( $categories ) ) { return $categories; } $rows = array(); foreach ( (array) $categories['data'] as $category ) { $rows[] = array( 'id' => (int) $category['id'], 'name' => (string) $category['name'], 'description' => isset( $category['description'] ) ? (string) $category['description'] : '', 'is_active' => ! isset( $category['is_active'] ) || (bool) $category['is_active'], 'services_count' => isset( $category['services_count'] ) ? (int) $category['services_count'] : 0, 'services' => array_values( array_map( static function ( $service ) { return array( 'id' => (int) $service['id'] ); }, isset( $category['services'] ) ? (array) $category['services'] : array() ) ), ); } $connection = $this->connection->current(); $services = $this->copies->read( Razorbooking_Endpoint::service_list() ); $options = array(); foreach ( is_wp_error( $services ) ? array() : (array) $services['data'] as $service ) { $options[] = array( 'value' => (string) $service['id'], 'label' => (string) $service['name'], // Whether a Share link to a category holding it opens anything. 'active' => ! isset( $service['is_active'] ) || (bool) $service['is_active'], ); } return array( 'categories' => $rows, 'services' => $options, 'permissions' => self::permissions( $connection ), 'booking_url' => $connection->booking_page_url, ); } /** * The Resources screen (as the admin's resources/index). * * @return array|WP_Error */ public function resources_table() { $resources = $this->resources->all(); if ( is_wp_error( $resources ) ) { return $resources; } $connection = $this->connection->current(); return array( 'resources' => $resources, 'permissions' => self::permissions( $connection ), ); } /* --------------------------------------------------------------------- * Team * ------------------------------------------------------------------ */ /** * The Team screen, from the store: members with their hours, pending * invitations, and the business's hours (Razorbooking_Endpoint::team_list()). * Anything the API leaves out shows as unknown. * * @return array|WP_Error */ public function team_table() { $users = $this->copies->read( Razorbooking_Endpoint::team_list() ); if ( is_wp_error( $users ) ) { return $users; } $connection = $this->connection->current(); $fresh = $this->team->new_member_ids(); $members = array(); foreach ( (array) $users['data'] as $member ) { $member['show_invite_note'] = in_array( (string) $member['id'], $fresh, true ); $members[] = $member; } // The business's hours come with the team list: a site key may not read settings. $week = isset( $users['meta']['business_hours'] ) ? $users['meta']['business_hours'] : null; return array( 'users' => $members, 'tenantBusinessHours' => is_array( $week ) ? array_values( $week ) : null, // Pending invitations โ€” the roster's "Invited" rows, as in the admin. 'invitations' => isset( $users['meta']['invitations'] ) ? array_values( (array) $users['meta']['invitations'] ) : array(), 'permissions' => self::permissions( $connection ), // For each member's page on the booking site: {site}/{slug}. 'siteUrl' => '' !== $this->connection->booking_site() ? $this->connection->booking_site() : null, ); } /* --------------------------------------------------------------------- * Helpers * ------------------------------------------------------------------ */ /** * What the connected key may do. * * @return string[] */ private static function permissions( Razorbooking_Connection_Dto $connection ): array { return $connection->permissions; } private static function zone( Razorbooking_Connection_Dto $connection ): DateTimeZone { try { return new DateTimeZone( $connection->timezone ? $connection->timezone : 'UTC' ); } catch ( Exception $e ) { return new DateTimeZone( 'UTC' ); } } }