,areas:list,tables:list,shifts:list,front:array{show_on_front:string,page_on_front:int,page:int}|null,menu:int} * @phpstan-type Kept array{type:string,id:int,name:string,reason:string} */ final class StarterContent { public const OPTION = 'fxtr_starter'; /** Post meta on each sample page: which page it is. */ public const META = '_fxtr_starter'; /** The block the booking page carries. */ public const FORM_BLOCK = 'fxtr/reservation-form'; /** * What the Setup tab shows. * * @return array */ public static function status(): array { $record = self::record(); $pages = []; foreach ( null === $record ? [] : $record['pages'] as $page ) { $post = get_post( $page['id'] ); if ( null === $post || 'trash' === $post->post_status ) { continue; } $pages[] = [ 'key' => $page['key'], 'id' => $page['id'], 'title' => get_the_title( $post ), 'link' => (string) get_permalink( $post ), 'edit_link' => (string) get_edit_post_link( $post->ID, 'raw' ), 'status' => $post->post_status, 'edited' => self::edited( $post, $page['modified'] ), ]; } $floor = self::floor_counts(); $book = self::booking_page(); return [ 'created' => null !== $record, 'created_at' => null === $record ? null : $record['created_at'], 'pages' => $pages, 'floor' => null === $record ? [ 'areas' => 0, 'tables' => 0, 'shifts' => 0, ] : [ 'areas' => count( self::existing( new Areas(), $record['areas'] ) ), 'tables' => count( self::existing( new Tables(), $record['tables'] ) ), 'shifts' => count( self::existing( new Shifts(), $record['shifts'] ) ), ], 'front_page' => null !== $record && null !== $record['front'] && self::is_front( $record['front']['page'] ), 'menu' => null !== $record && $record['menu'] > 0 && false !== wp_get_nav_menu_object( $record['menu'] ), 'can' => [ 'floor' => 0 === $floor['areas'] + $floor['tables'] + $floor['shifts'], 'menu' => self::menu_location() !== '', ], 'checklist' => [ 'tables' => $floor['tables'] > 0, 'shifts' => $floor['shifts'] > 0, 'booking_page' => '' !== $book, ], 'booking_url' => $book, ]; } /** * Makes the pages and, if asked and the floor is empty, the sample floor. * Once only: what was made has to be removed before it can be made again. * * @param array{floor?:bool,front_page?:bool,menu?:bool} $options * @return array|WP_Error The status after. */ public static function create( array $options ): array|WP_Error { if ( null !== self::record() ) { return new WP_Error( 'fxtr_starter_exists', __( 'The sample content is already there. Remove it first to make it again.', 'flexatech-table-reservations' ), [ 'status' => 409 ] ); } $record = [ 'created_at' => time(), 'pages' => [], 'areas' => [], 'tables' => [], 'shifts' => [], 'front' => null, 'menu' => 0, ]; // The floor first, so the contact page can list its opening hours. $floor = self::floor_counts(); if ( ! empty( $options['floor'] ) && 0 === $floor['areas'] + $floor['tables'] + $floor['shifts'] ) { $made = self::make_floor(); if ( is_wp_error( $made ) ) { return $made; } $record = array_merge( $record, $made ); } $shifts = array_values( array_filter( ( new Shifts() )->all(), static fn( array $shift ): bool => (bool) $shift['active'] ) ); $links = []; foreach ( StarterPages::KEYS as $key ) { $id = wp_insert_post( [ 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => StarterPages::title( $key ), 'post_content' => StarterPages::content( $key, $links, $shifts, (int) Settings::get( 'party_max', 10 ) ), 'meta_input' => [ self::META => $key ], ], true ); if ( is_wp_error( $id ) ) { // Keep what was made so far, so Remove can still take it away. self::save( $record ); $id->add_data( [ 'status' => 500 ] ); return $id; } $links[ $key ] = (string) get_permalink( $id ); $record['pages'][] = [ 'key' => $key, 'id' => $id, 'modified' => (string) get_post_field( 'post_modified_gmt', $id ), ]; } $pages = array_column( $record['pages'], 'id', 'key' ); if ( ! empty( $options['front_page'] ) ) { $record['front'] = [ 'show_on_front' => (string) get_option( 'show_on_front', 'posts' ), 'page_on_front' => (int) get_option( 'page_on_front', 0 ), 'page' => (int) $pages['booking'], ]; update_option( 'show_on_front', 'page' ); update_option( 'page_on_front', (int) $pages['booking'] ); } if ( ! empty( $options['menu'] ) ) { $record['menu'] = self::make_menu( $pages ); } self::save( $record ); return self::status(); } /** * Gives the front page back to what it was before create() put the * booking page there, and keeps the pages. When that earlier page is * gone, the front page shows the latest posts. * * @return array The status after. */ public static function release_front(): array { $record = self::record(); if ( null === $record || null === $record['front'] ) { return self::status(); } $front = $record['front']; if ( self::is_front( $front['page'] ) ) { $page = 'page' === $front['show_on_front'] && 'publish' === get_post_status( $front['page_on_front'] ) ? $front['page_on_front'] : 0; update_option( 'show_on_front', $page > 0 ? 'page' : 'posts' ); update_option( 'page_on_front', $page ); } $record['front'] = null; self::save( $record ); return self::status(); } /** * Takes away what create() made and is still there. * * @return array{removed:array{pages:int,areas:int,tables:int,shifts:int},kept:list,status:array} */ public static function remove( bool $include_edited = false ): array { $record = self::record(); $removed = [ 'pages' => 0, 'areas' => 0, 'tables' => 0, 'shifts' => 0, ]; $kept = []; if ( null === $record ) { return [ 'removed' => $removed, 'kept' => $kept, 'status' => self::status(), ]; } $left = []; $trash = []; foreach ( $record['pages'] as $page ) { $post = get_post( $page['id'] ); if ( null === $post || 'trash' === $post->post_status || ! get_post_meta( $post->ID, self::META, true ) ) { continue; } if ( ! $include_edited && self::edited( $post, $page['modified'] ) ) { $left[] = $page; $kept[] = self::kept( 'page', $post->ID, get_the_title( $post ), 'edited' ); continue; } $trash[] = $page; } // Put the old front page back when the sample one goes, unless the // owner has picked another since. Before the trash: WordPress resets // the front page to the latest posts when the page shown there goes. $front = $record['front']; if ( null !== $front && ( in_array( $front['page'], array_column( $trash, 'id' ), true ) || 'publish' !== get_post_status( $front['page'] ) ) ) { if ( self::is_front( $front['page'] ) ) { update_option( 'show_on_front', $front['show_on_front'] ); update_option( 'page_on_front', $front['page_on_front'] ); } $record['front'] = null; } foreach ( $trash as $page ) { // A post, or false or null when it could not be trashed. if ( wp_trash_post( $page['id'] ) ) { ++$removed['pages']; } else { $left[] = $page; } } $record['pages'] = $left; // The menu links to the pages, so it goes with the last of them. if ( $record['menu'] > 0 && [] === $record['pages'] ) { if ( false !== wp_get_nav_menu_object( $record['menu'] ) ) { wp_delete_nav_menu( $record['menu'] ); } $record['menu'] = 0; } // Tables before their areas, since an area with tables stays. foreach ( [ 'tables' => new Tables(), 'areas' => new Areas(), 'shifts' => new Shifts(), ] as $type => $store ) { $left = []; foreach ( self::existing( $store, $record[ $type ] ) as $id => $item ) { $blocker = 'shifts' === $type ? self::shift_blocker( $id ) : $store->blocker( $id ); if ( null === $blocker && $store->delete( $id ) ) { ++$removed[ $type ]; continue; } $left[] = $id; $kept[] = self::kept( rtrim( $type, 's' ), $id, (string) $item['name'], null === $blocker ? 'failed' : (string) $blocker->get_error_code() ); } $record[ $type ] = $left; } if ( [] === $record['pages'] && [] === $record['areas'] && [] === $record['tables'] && [] === $record['shifts'] && null === $record['front'] && 0 === $record['menu'] ) { delete_option( self::OPTION ); } else { self::save( $record ); } return [ 'removed' => $removed, 'kept' => $kept, 'status' => self::status(), ]; } /** * Two areas, eight tables and a lunch and a dinner shift every day. The * rows go through each store's own checks, as if the owner typed them. * * @return array{areas:list,tables:list,shifts:list}|WP_Error */ private static function make_floor(): array|WP_Error { $made = [ 'areas' => [], 'tables' => [], 'shifts' => [], ]; $areas = [ 'inside' => [ 'name' => __( 'Inside', 'flexatech-table-reservations' ), 'tables' => [ [ '1', 1, 2, '' ], [ '2', 1, 2, '' ], [ '3', 2, 4, 'inside' ], [ '4', 2, 4, 'inside' ], [ '5', 3, 6, '' ], ], ], 'terrace' => [ 'name' => __( 'Terrace', 'flexatech-table-reservations' ), 'tables' => [ [ 'T1', 1, 2, 'terrace' ], [ 'T2', 1, 2, 'terrace' ], [ 'T3', 2, 4, '' ], ], ], ]; $sort = 0; foreach ( $areas as $area ) { $id = self::insert( new Areas(), [ 'name' => $area['name'], 'sort' => count( $made['areas'] ), ] ); if ( is_wp_error( $id ) ) { return $id; } $made['areas'][] = $id; foreach ( $area['tables'] as [ $name, $min, $max, $group ] ) { $table = self::insert( new Tables(), [ 'name' => $name, 'area_id' => $id, 'min_party' => $min, 'max_party' => $max, 'combine_group' => $group, 'sort' => $sort++, ] ); if ( is_wp_error( $table ) ) { return $table; } $made['tables'][] = $table; } } $turns = [ [ 'party' => 2, 'minutes' => 90, ], [ 'party' => 4, 'minutes' => 105, ], ]; foreach ( [ [ __( 'Lunch', 'flexatech-table-reservations' ), '11:30', '14:00', 90 ], [ __( 'Dinner', 'flexatech-table-reservations' ), '17:30', '21:30', 120 ], ] as [ $name, $opens, $last, $turn ] ) { $shift = self::insert( new Shifts(), [ 'name' => $name, 'days' => [ 0, 1, 2, 3, 4, 5, 6 ], 'opens' => $opens, 'last_seating' => $last, 'slot_interval' => 15, 'max_covers' => 20, 'turn_default' => $turn, 'turn_times' => $turns, ] ); if ( is_wp_error( $shift ) ) { return $shift; } $made['shifts'][] = $shift; } return $made; } /** * @param array $input */ private static function insert( Store $store, array $input ): int|WP_Error { [ $row, $errors ] = $store::clean( $input + $store->defaults() ); $conflict = [] === $errors ? $store->conflict( $row, 0 ) : null; $id = [] === $errors && null === $conflict ? $store->insert( $row ) : 0; if ( $id > 0 ) { return $id; } return $conflict ?? new WP_Error( 'fxtr_starter_floor', __( 'The sample floor could not be made.', 'flexatech-table-reservations' ), [ 'status' => 500 ] ); } /** * A new menu with the sample pages, in the theme's first menu place that * has none. 0 when the theme has no such place. * * @param array $pages Page key to id. */ private static function make_menu( array $pages ): int { $location = self::menu_location(); if ( '' === $location ) { return 0; } $name = __( 'Restaurant', 'flexatech-table-reservations' ); $menu = wp_create_nav_menu( $name ); if ( is_wp_error( $menu ) ) { // The name is taken: an owner's own menu is never reused. /* translators: %s: a number that makes the menu name unique */ $menu = wp_create_nav_menu( sprintf( __( 'Restaurant %s', 'flexatech-table-reservations' ), wp_generate_password( 4, false ) ) ); } if ( is_wp_error( $menu ) ) { return 0; } foreach ( [ 'menu', 'about', 'contact', 'booking' ] as $position => $key ) { wp_update_nav_menu_item( $menu, 0, [ 'menu-item-object-id' => $pages[ $key ], 'menu-item-object' => 'page', 'menu-item-type' => 'post_type', 'menu-item-status' => 'publish', 'menu-item-position' => $position + 1, ] ); } $locations = get_nav_menu_locations(); $locations[ $location ] = $menu; set_theme_mod( 'nav_menu_locations', $locations ); return $menu; } /** * The first menu place of a classic theme with no menu in it yet. A * block theme keeps its menus in the Navigation block, which is left to * the owner. */ private static function menu_location(): string { if ( wp_is_block_theme() ) { return ''; } $set = get_nav_menu_locations(); foreach ( array_keys( get_registered_nav_menus() ) as $location ) { if ( empty( $set[ $location ] ) ) { return (string) $location; } } return ''; } /** * A shift with guests still to come stays, or they would lose the times * the book shows them under. */ private static function shift_blocker( int $id ): ?WP_Error { global $wpdb; $upcoming = (int) $wpdb->get_var( // The statuses are Tables::DONE, written out so the query stays fixed SQL. $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE shift_id = %d AND reservation_date >= %s AND status NOT IN ('cancelled', 'no_show', 'completed')", $wpdb->prefix . 'fxtr_reservations', $id, current_time( 'Y-m-d' ) ) ); return 0 === $upcoming ? null : new WP_Error( 'fxtr_shift_booked', '', [ 'status' => 409 ] ); } /** * @return array{areas:int,tables:int,shifts:int} */ private static function floor_counts(): array { global $wpdb; $count = static fn( string $table ): int => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . 'fxtr_' . $table ) ); return [ 'areas' => $count( 'areas' ), 'tables' => $count( 'tables' ), 'shifts' => $count( 'shifts' ), ]; } /** Whether the site has any area, table or shift: a site set up already. */ public static function has_floor(): bool { return 0 < array_sum( self::floor_counts() ); } /** * A published page with the reservation form on it (block or shortcode), * sample or not, or ''. */ public static function booking_page(): string { $id = BlockRegistry::page_with( self::FORM_BLOCK ); return $id > 0 ? (string) get_permalink( $id ) : ''; } /** * The items of a store still there, by id. * * @param list $ids * @return array> */ private static function existing( Store $store, array $ids ): array { $out = []; foreach ( $ids as $id ) { $item = $store->find( $id ); if ( null !== $item ) { $out[ $id ] = $item; } } return $out; } /** * Changed since it was made. Saving in the editor moves the modified * time; setting it as the front page or adding it to a menu does not. */ private static function edited( \WP_Post $post, string $modified ): bool { return $post->post_modified_gmt !== $modified; } private static function is_front( int $page ): bool { return 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_on_front' ) === $page; } /** * @return Kept */ private static function kept( string $type, int $id, string $name, string $reason ): array { return [ 'type' => $type, 'id' => $id, 'name' => $name, 'reason' => $reason, ]; } /** * @return Record|null */ private static function record(): ?array { $stored = get_option( self::OPTION, null ); if ( ! is_array( $stored ) ) { return null; } $ids = static fn( mixed $values ): array => is_array( $values ) ? array_values( array_filter( array_map( 'intval', $values ) ) ) : []; $pages = []; foreach ( is_array( $stored['pages'] ?? null ) ? $stored['pages'] : [] as $page ) { if ( is_array( $page ) && (int) ( $page['id'] ?? 0 ) > 0 ) { $pages[] = [ 'key' => (string) ( $page['key'] ?? '' ), 'id' => (int) $page['id'], 'modified' => (string) ( $page['modified'] ?? '' ), ]; } } $front = $stored['front'] ?? null; return [ 'created_at' => (int) ( $stored['created_at'] ?? 0 ), 'pages' => $pages, 'areas' => $ids( $stored['areas'] ?? [] ), 'tables' => $ids( $stored['tables'] ?? [] ), 'shifts' => $ids( $stored['shifts'] ?? [] ), 'front' => is_array( $front ) ? [ 'show_on_front' => (string) ( $front['show_on_front'] ?? 'posts' ), 'page_on_front' => (int) ( $front['page_on_front'] ?? 0 ), 'page' => (int) ( $front['page'] ?? 0 ), ] : null, 'menu' => (int) ( $stored['menu'] ?? 0 ), ]; } /** * @param Record $record */ private static function save( array $record ): void { update_option( self::OPTION, $record, false ); } }