'unavailable', 'data' => null, 'stale' => false, ); } $base = bayle_menu_widget_resolve_base_url(); $fresh_key = bayle_menu_widget_menu_cache_key( 'fresh', $slug, $lang, $base ); $good_key = bayle_menu_widget_menu_cache_key( 'good', $slug, $lang, $base ); $fail_key = bayle_menu_widget_menu_cache_key( 'fail', $slug, $lang, $base ); $fresh = get_transient( $fresh_key ); if ( is_array( $fresh ) && isset( $fresh['status'] ) ) { return bayle_menu_widget_menu_result( $fresh['status'], $fresh['data'] ?? null, false ); } $failed = get_transient( $fail_key ); if ( false === $failed ) { $fetched = bayle_menu_widget_fetch_menu( $base, $slug, $lang ); if ( 'ok' === $fetched['status'] || 'unavailable' === $fetched['status'] ) { set_transient( $fresh_key, $fetched, bayle_menu_widget_menu_cache_ttl() ); if ( 'ok' === $fetched['status'] ) { set_transient( $good_key, $fetched['data'], 7 * DAY_IN_SECONDS ); } else { delete_transient( $good_key ); } return bayle_menu_widget_menu_result( $fetched['status'], $fetched['data'], false ); } set_transient( $fail_key, 1, MINUTE_IN_SECONDS ); } $good = get_transient( $good_key ); if ( is_array( $good ) ) { return bayle_menu_widget_menu_result( 'ok', $good, true ); } return bayle_menu_widget_menu_result( 'error', null, false ); } /** * Build a result and run the data filter. * * @param string $status ok|unavailable|error. * @param array|null $data Normalized menu. * @param bool $stale Served from the last good copy. * @return array{status:string,data:?array,stale:bool} */ function bayle_menu_widget_menu_result( string $status, $data, bool $stale ): array { $data = is_array( $data ) ? $data : null; if ( null !== $data ) { /** * Filters the normalized menu before it is rendered. * * Values are already sanitized; anything a filter adds is escaped * again on output. * * @param array $data Normalized menu. */ $data = apply_filters( 'bayle_menu_widget_menu_data', $data ); $data = is_array( $data ) ? $data : null; } return array( 'status' => null === $data && 'ok' === $status ? 'error' : $status, 'data' => $data, 'stale' => $stale, ); } /** * One GET to Bayle. * * @param string $base Menu base URL (https://*.bayle.food). * @param string $slug Slug. * @param string $lang Language. * @return array{status:string,data:?array} */ function bayle_menu_widget_fetch_menu( string $base, string $slug, string $lang ): array { $url = rtrim( $base, '/' ) . '/api/' . rawurlencode( $slug ) . '/menu?lang=' . rawurlencode( $lang ); $response = wp_safe_remote_get( $url, array( 'timeout' => 5, 'redirection' => 0, 'headers' => array( 'Accept' => 'application/json' ), 'user-agent' => 'BayleMenuWidgetWP/' . BAYLE_MENU_WIDGET_VERSION . '; ' . home_url( '/' ), ) ); if ( is_wp_error( $response ) ) { return array( 'status' => 'error', 'data' => null, ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 404 === $code ) { // Unknown slug or the menu module is off in Bayle. return array( 'status' => 'unavailable', 'data' => null, ); } if ( 200 !== $code ) { return array( 'status' => 'error', 'data' => null, ); } $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) { return array( 'status' => 'error', 'data' => null, ); } if ( empty( $body['data']['enabled'] ) ) { return array( 'status' => 'unavailable', 'data' => null, ); } return array( 'status' => 'ok', 'data' => bayle_menu_widget_normalize_menu( $body['data'], $slug ), ); } /** * Plain text from a feed value. * * @param mixed $value Value. * @param int $max Max length. * @return string */ function bayle_menu_widget_feed_text( $value, int $max = 500 ): string { if ( ! is_scalar( $value ) ) { return ''; } $text = trim( sanitize_text_field( (string) $value ) ); return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $max ) : substr( $text, 0, $max ); } /** * Multi-line text (descriptions). * * @param mixed $value Value. * @return string */ function bayle_menu_widget_feed_textarea( $value ): string { if ( ! is_scalar( $value ) ) { return ''; } $text = trim( sanitize_textarea_field( (string) $value ) ); return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, 1000 ) : substr( $text, 0, 1000 ); } /** * Integer cents or null. * * @param mixed $value Value. * @return int|null */ function bayle_menu_widget_feed_cents( $value ) { if ( null === $value || '' === $value || ! is_numeric( $value ) ) { return null; } $cents = (int) round( (float) $value ); return ( $cents >= 0 && $cents < 100000000 ) ? $cents : null; } /** * Absolute https URL or ''. * * @param mixed $value Value. * @return string */ function bayle_menu_widget_feed_https_url( $value ): string { if ( ! is_string( $value ) || '' === trim( $value ) ) { return ''; } $url = esc_url_raw( trim( $value ), array( 'https' ) ); $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return ''; } return $url; } /** * Opaque order reference (letters, digits, - _ . :) or ''. * * @param mixed $value Value. * @return string */ function bayle_menu_widget_feed_ref( $value ): string { if ( ! is_string( $value ) ) { return ''; } $value = trim( $value ); return preg_match( '/^[A-Za-z0-9._:-]{1,128}$/', $value ) ? $value : ''; } /** * Normalize one dish. * * @param mixed $raw Item. * @return array|null */ function bayle_menu_widget_normalize_item( $raw ) { if ( ! is_array( $raw ) ) { return null; } $name = bayle_menu_widget_feed_text( $raw['name'] ?? '', 200 ); if ( '' === $name ) { return null; } $price = bayle_menu_widget_feed_cents( $raw['priceCents'] ?? null ); $compare = bayle_menu_widget_feed_cents( $raw['compareAtCents'] ?? null ); $item = array( 'name' => $name, 'description' => bayle_menu_widget_feed_textarea( $raw['description'] ?? '' ), 'price' => $price, 'compareAt' => ( null !== $compare && null !== $price && $compare > $price ) ? $compare : null, 'sizes' => array(), 'addons' => array(), 'diets' => array(), 'image' => bayle_menu_widget_feed_https_url( $raw['imageUrl'] ?? '' ), 'category' => bayle_menu_widget_feed_text( $raw['category'] ?? '', 40 ), // Ordering (only when Bayle sends it): an opaque reference the Bayle // checkout resolves, and whether the dish can be ordered right now. 'orderRef' => bayle_menu_widget_feed_ref( $raw['orderRef'] ?? '' ), 'soldOut' => ! empty( $raw['soldOut'] ) || ( isset( $raw['orderable'] ) && false === $raw['orderable'] ), ); foreach ( array_slice( is_array( $raw['sizes'] ?? null ) ? $raw['sizes'] : array(), 0, 12 ) as $size ) { if ( ! is_array( $size ) ) { continue; } $label = bayle_menu_widget_feed_text( $size['label'] ?? '', 80 ); if ( '' !== $label ) { $item['sizes'][] = array( 'label' => $label, 'price' => bayle_menu_widget_feed_cents( $size['priceCents'] ?? null ), 'orderRef' => bayle_menu_widget_feed_ref( $size['orderRef'] ?? '' ), ); } } $item['addons'] = bayle_menu_widget_normalize_addons( $raw['addons'] ?? null ); foreach ( array_slice( is_array( $raw['dietaries'] ?? null ) ? $raw['dietaries'] : array(), 0, 20 ) as $diet ) { if ( ! is_array( $diet ) ) { continue; } $code = bayle_menu_widget_feed_text( $diet['code'] ?? '', 12 ); if ( '' !== $code ) { $label = bayle_menu_widget_feed_text( $diet['label'] ?? '', 80 ); $item['diets'][] = array( 'code' => $code, 'label' => '' !== $label ? $label : $code, ); } } return $item; } /** * Normalize a dish's extras (add-on groups). Bayle sends them only for dishes * that can be ordered: a group label, how many options the guest must / may * pick (min, max) and the options with their opaque ref and the price each * adds to the dish. Bayle prices the order again at checkout. * * @param mixed $raw Feed "addons". * @return array}> */ function bayle_menu_widget_normalize_addons( $raw ): array { $groups = array(); foreach ( array_slice( is_array( $raw ) ? $raw : array(), 0, 10 ) as $group ) { if ( ! is_array( $group ) ) { continue; } $options = array(); foreach ( array_slice( is_array( $group['options'] ?? null ) ? $group['options'] : array(), 0, 30 ) as $option ) { if ( ! is_array( $option ) ) { continue; } $label = bayle_menu_widget_feed_text( $option['label'] ?? '', 80 ); $ref = bayle_menu_widget_feed_ref( $option['ref'] ?? '' ); if ( '' !== $label && '' !== $ref ) { $price = bayle_menu_widget_feed_cents( $option['priceCents'] ?? 0 ); $options[] = array( 'label' => $label, 'price' => null === $price ? 0 : $price, 'ref' => $ref, ); } } if ( ! $options ) { continue; } $count = count( $options ); $min = max( 0, (int) ( $group['min'] ?? 0 ) ); if ( ! empty( $group['required'] ) ) { $min = max( 1, $min ); } $max = isset( $group['max'] ) && is_numeric( $group['max'] ) ? (int) $group['max'] : $count; $max = max( 1, min( $count, $max ) ); if ( $min > $max ) { continue; // Bayle would refuse any choice; leave the group out. } $groups[] = array( 'label' => bayle_menu_widget_feed_text( $group['label'] ?? '', 80 ), 'min' => $min, 'max' => $max, 'options' => $options, ); } return $groups; } /** * HH:MM or ''. * * @param mixed $value Value. * @return string */ function bayle_menu_widget_feed_clock( $value ): string { return ( is_string( $value ) && preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', trim( $value ) ) ) ? trim( $value ) : ''; } /** * Normalize the feed into the shape the renderer uses. * * @param array $raw Feed "data". * @param string $slug Requested slug. * @return array */ function bayle_menu_widget_normalize_menu( array $raw, string $slug ): array { $menu = is_array( $raw['menu'] ?? null ) ? $raw['menu'] : array(); $currency = strtoupper( bayle_menu_widget_feed_text( $raw['currency'] ?? 'EUR', 3 ) ); $timezone = bayle_menu_widget_feed_text( $raw['timezone'] ?? '', 64 ); $hours = is_array( $raw['hours'] ?? null ) ? $raw['hours'] : null; // Online-ordering hours (can differ from opening hours); null when Bayle does not send them. $o_hours = is_array( $raw['orderHours'] ?? null ) ? $raw['orderHours'] : null; $links = is_array( $raw['links'] ?? null ) ? $raw['links'] : array(); $out = array( 'slug' => $slug, 'name' => bayle_menu_widget_feed_text( $raw['name'] ?? '', 200 ), 'address' => bayle_menu_widget_feed_text( $raw['address'] ?? '', 300 ), 'currency' => preg_match( '/^[A-Z]{3}$/', $currency ) ? $currency : 'EUR', 'timezone' => in_array( $timezone, timezone_identifiers_list(), true ) ? $timezone : 'Europe/Helsinki', 'hourCycle' => 'h12' === ( $raw['hourCycle'] ?? '' ) ? 'h12' : 'h23', 'updatedAt' => bayle_menu_widget_feed_text( $raw['updatedAt'] ?? '', 40 ), 'openNow' => null === $hours ? null : ! empty( $hours['openNow'] ), 'nextChange' => null === $hours ? '' : bayle_menu_widget_feed_text( $hours['nextChangeAt'] ?? '', 40 ), 'orderOpen' => null === $o_hours ? null : ! empty( $o_hours['openNow'] ), 'orderNext' => null === $o_hours ? '' : bayle_menu_widget_feed_text( $o_hours['nextChangeAt'] ?? '', 40 ), // Bayle sends links.order only when online ordering is on for the // restaurant: it is the source of truth for ordering. 'orderUrl' => bayle_menu_widget_feed_https_url( $links['order'] ?? '' ), // Hosted cart hand-off (the restaurant's own storefront). Newer feeds send // links.checkout (null when there is none, e.g. links.order is the Bayle // marketplace page); older feeds only had links.order = the storefront. 'checkoutBase' => bayle_menu_widget_feed_https_url( array_key_exists( 'checkout', $links ) ? ( $links['checkout'] ?? '' ) : ( $links['order'] ?? '' ) ), 'categories' => array(), 'items' => array(), 'lunch' => null, 'notes' => array(), ); $alacarte = is_array( $menu['alacarte'] ?? null ) ? $menu['alacarte'] : array(); foreach ( is_array( $alacarte['categories'] ?? null ) ? $alacarte['categories'] : array() as $cat ) { if ( ! is_array( $cat ) ) { continue; } $key = bayle_menu_widget_feed_text( $cat['key'] ?? '', 40 ); if ( '' !== $key ) { $out['categories'][] = array( 'key' => $key, 'name' => bayle_menu_widget_feed_text( $cat['name'] ?? '', 200 ), 'description' => bayle_menu_widget_feed_textarea( $cat['description'] ?? '' ), ); } } foreach ( array_slice( is_array( $alacarte['items'] ?? null ) ? $alacarte['items'] : array(), 0, 500 ) as $raw_item ) { $item = bayle_menu_widget_normalize_item( $raw_item ); if ( null !== $item ) { $out['items'][] = $item; } } $lunch = is_array( $menu['lunch'] ?? null ) ? $menu['lunch'] : null; if ( null !== $lunch && is_array( $lunch['days'] ?? null ) ) { $days = array(); for ( $d = 1; $d <= 7; $d++ ) { $list = $lunch['days'][ (string) $d ] ?? null; if ( ! is_array( $list ) ) { continue; } foreach ( array_slice( $list, 0, 60 ) as $raw_item ) { $item = bayle_menu_widget_normalize_item( $raw_item ); if ( null !== $item ) { $days[ $d ][] = $item; } } } if ( $days ) { $range = static function ( $r ) { $start = is_array( $r ) ? bayle_menu_widget_feed_clock( $r['start'] ?? '' ) : ''; $end = is_array( $r ) ? bayle_menu_widget_feed_clock( $r['end'] ?? '' ) : ''; return ( '' !== $start && '' !== $end ) ? $start . '–' . $end : ''; }; $lunch_hours = is_array( $lunch['hours'] ?? null ) ? $lunch['hours'] : array(); $today = (int) ( $lunch['today'] ?? 0 ); $out['lunch'] = array( 'today' => ( $today >= 1 && $today <= 7 ) ? $today : 0, 'weekday' => $range( $lunch_hours['weekday'] ?? null ), 'weekend' => $range( $lunch_hours['weekend'] ?? null ), 'days' => $days, ); } } foreach ( array_slice( is_array( $menu['notes'] ?? null ) ? $menu['notes'] : array(), 0, 20 ) as $note ) { if ( ! is_array( $note ) ) { continue; } $text = bayle_menu_widget_feed_textarea( $note['text'] ?? '' ); if ( '' !== $text ) { $scope = (string) ( $note['scope'] ?? 'all' ); $out['notes'][] = array( 'text' => $text, 'scope' => in_array( $scope, array( 'lunch', 'a_la_carte' ), true ) ? $scope : 'all', ); } } return $out; }