*/ public static function page_data( int $product_id, string $page ): array { $product = wc_get_product( $product_id ); $data = array( 'page' => $page, 'id' => $product_id, 'hasKey' => Config::has_api_key(), // Тип → id фотосессии, '' — такой фотосессии у товара нет. 'photoSessions' => Photo_Kinds::stored_sessions( $product_id ), // Строка о товаре под кнопками — готовой: «N photos in the product · дата», // '' — фото aiModelka в товар ещё не сохранялись. Число и дату отдельно скрипт // не получает: форму слова по числу и формат даты сайта он сам правильно // не построит. После сохранения свежую строку он берёт из ответа «save» // (Rest\Routes::save). На витрине строки нет, но данные общие. 'savedText' => Saved_Photos::product_box_text( $product_id ), 'preparePath' => '/aimodelka/v1/products/' . $product_id . '/prepare', 'photosPath' => '/aimodelka/v1/products/' . $product_id . '/photos', 'uploadPath' => '/aimodelka/v1/products/' . $product_id . '/photos/upload', 'statePath' => '/aimodelka/v1/products/' . $product_id . '/state', 'savePath' => '/aimodelka/v1/products/' . $product_id . '/save', // Начало имени окна — по товару и сайту; скрипт дописывает тип. Повторное нажатие той // же кнопки — и в редакторе, и на витрине — попадает в то же окно, кнопка другого // типа открывает своё, а одинаковый id товара в двух магазинах окна не делит. 'windowName' => 'aimodelka_' . substr( md5( home_url() ), 0, 8 ) . '_' . $product_id, 'siteOrigin' => Config::site_origin(), 'texts' => self::texts(), 'productName' => $product ? trim( wp_strip_all_tags( $product->get_name( 'edit' ) ) ) : '', 'imagesPath' => '/aimodelka/v1/products/' . $product_id . '/images', 'photosTexts' => self::photos_texts(), ); if ( self::PAGE_STOREFRONT === $page ) { $data['gallery'] = self::saved_gallery( $product_id ); $data['collapsePath'] = '/aimodelka/v1/storefront-block'; } return $data; } /** * Печатает кнопки и строку состояния — содержимое блока; обёртку печатает вызывающий. * * Сверху вниз: три кнопки окна в порядке меню «Создать фото» aiModelka — только при ключе * aiModelka (окну без ключа открыть нечего) — и «Упорядочить фото», которая открывает окошко * порядка фото прямо на странице и ключа не требует (Documents/WPPlugin-HLD.md §2.10). * Кнопки — во всю ширину блока на обеих страницах. Под ними строка состояния, а в редакторе * при ключе ещё строка о товаре: подсказка, что делают кнопки окна, или «N photos in the * product · дата». Id элементов одни на обе страницы — скрипт находит их по id, а страница * открыта одна; классы — свои у каждой страницы, под свои стили. * * @param int $product_id Id товара. * @param string $page PAGE_EDITOR или PAGE_STOREFRONT. * @return void */ public static function render( int $product_id, string $page ): void { $sessions = Photo_Kinds::stored_sessions( $product_id ); $prefix = self::PAGE_STOREFRONT === $page ? 'aimodelka-storefront' : 'aimodelka-product-box'; $photos_button = sprintf( '', esc_attr( $prefix ), esc_html( self::photos_texts()['button'] ) ); if ( self::PAGE_STOREFRONT === $page ) { echo '
'; } // Кнопки окна — только при ключе: окну без ключа открыть нечего. if ( Config::has_api_key() ) { foreach ( Photo_Kinds::ALL as $kind ) { echo self::kind_button( $kind, '' !== $sessions[ $kind ], $page, $prefix ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- разметка собрана в kind_button, значения экранированы там. } } echo $photos_button; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- разметка собрана выше, значения экранированы там. if ( self::PAGE_STOREFRONT === $page ) { echo '
'; } printf( '
', esc_attr( $prefix ) ); if ( self::PAGE_EDITOR === $page && Config::has_api_key() ) { printf( '

%s

', esc_html( self::summary( $product_id, array() !== array_filter( $sessions ) ) ) ); } } /** * Разметка кнопки одного типа: пиктограмма слева, название и — в редакторе — состояние. * * Класс «button» общий: в админке это кнопка WordPress, на витрине — кнопка WooCommerce, * которую оформляет тема. Основной синий вид WordPress кнопкам не нужен: три залитые кнопки * слишком выделялись бы среди блоков редактора. * Пиктограмма — та же картинка, что в меню «Создать фото» aiModelka; слова о ней говорит * название рядом, поэтому alt пустой. * * @param string $kind Тип фотосессии. * @param bool $has_session Есть ли у товара фотосессия этого типа. * @param string $page PAGE_EDITOR или PAGE_STOREFRONT. * @param string $prefix Приставка классов страницы. * @return string Разметка. */ private static function kind_button( string $kind, bool $has_session, string $page, string $prefix ): string { $texts = self::texts(); $classes = array( 'button', $prefix . '__button', 'aimodelka-kind-button' ); $storefront = self::PAGE_STOREFRONT === $page; $state = $has_session ? $texts['buttonOpen'] : $texts['buttonGenerate']; return sprintf( '', esc_attr( implode( ' ', $classes ) ), esc_attr( $kind ), esc_url( add_query_arg( 'ver', AIMODELKA_VERSION, AIMODELKA_PLUGIN_URL . 'assets/kinds/' . $kind . '.jpg' ) ), esc_html( self::kind_name( $kind ) ), $storefront ? '' : '' . esc_html( $state ) . '', $storefront ? 24 : 40 ); } /** * Название типа фотосессии на кнопке — те же слова, что в меню «Создать фото» aiModelka. * * @param string $kind Тип фотосессии. * @return string Название на языке страницы. */ private static function kind_name( string $kind ): string { switch ( $kind ) { case Photo_Kinds::GHOST_MANNEQUIN: return __( 'Ghost mannequin', 'aimodelka' ); case Photo_Kinds::PRODUCT_SCENE: return __( 'Product in scene', 'aimodelka' ); default: return __( 'Photo on an AI model', 'aimodelka' ); } } /** * Изображение товара и галерея из сохранённого товара — для витрины, где полей формы нет. * * @param int $product_id Id товара. * @return array{featuredId: int, galleryIds: int[]} Изображение товара (0 — его нет) и галерея по порядку. */ public static function saved_gallery( int $product_id ): array { $product = wc_get_product( $product_id ); if ( ! $product ) { return array( 'featuredId' => 0, 'galleryIds' => array(), ); } return array( 'featuredId' => (int) $product->get_image_id( 'edit' ), 'galleryIds' => array_values( array_map( 'intval', $product->get_gallery_image_ids( 'edit' ) ) ), ); } /** * Строка о товаре под кнопками в редакторе — та же, что выберет скрипт страницы * (aimodelka-admin.js, renderBox): ни одной фотосессии нет — подсказка, что делают кнопки; * есть хоть одна — сколько фото aiModelka в товаре по всем типам, а если их ещё нет — что * фотосессия начата. * * @param int $product_id Id товара. * @param bool $has_session Есть ли у товара хоть одна фотосессия. * @return string Строка. */ private static function summary( int $product_id, bool $has_session ): string { $texts = self::texts(); if ( ! $has_session ) { return $texts['hint']; } $saved = Saved_Photos::product_box_text( $product_id ); return '' !== $saved ? $saved : $texts['shootStarted']; } /** * Тексты блока, скрипта страницы товара и временной страницы окна — на языке страницы. * * Кнопки в советах называются «the aiModelka button in the product» или «the same button»: * кнопок окна три, и название одной из них в общем совете указало бы не на ту. * * @return array */ public static function texts(): array { return array( 'buttonGenerate' => __( 'Generate photos', 'aimodelka' ), 'buttonOpen' => __( 'Open photo shoot', 'aimodelka' ), 'hint' => __( 'Each button opens aiModelka in a new window. The photos you save there go into this product.', 'aimodelka' ), 'shootStarted' => __( 'The photo shoot is started. Uploaded photos and shots are kept.', 'aimodelka' ), 'windowTitle' => __( 'aiModelka', 'aimodelka' ), 'preparing' => __( 'Opening aiModelka…', 'aimodelka' ), 'preparingHint' => __( 'Getting the product’s photo session ready. This takes a few seconds.', 'aimodelka' ), 'prepareFailed' => __( 'Could not open aiModelka', 'aimodelka' ), 'close' => __( 'Close', 'aimodelka' ), // Окно не открылось ещё до подготовки: фотосессии может и не быть. 'popupBlocked' => __( 'The browser blocked the aiModelka window. Allow pop-up windows for this site and click the aiModelka button in the product again.', 'aimodelka' ), 'windowOpen' => __( 'The aiModelka window is open. The photos you save there will appear in this product.', 'aimodelka' ), // Подготовка удалась, но человек успел закрыть окно. 'windowClosed' => __( 'The aiModelka window was closed. Click the same button in the product to open it again.', 'aimodelka' ), 'sendingPhoto' => __( 'Sending a product photo to aiModelka…', 'aimodelka' ), 'saving' => __( 'Saving the photo to the product…', 'aimodelka' ), 'saved' => __( 'Photo saved to the product.', 'aimodelka' ), // Витрина: страница перезагружается, чтобы показать новое фото. 'savedReloading' => __( 'Photo saved. Reloading the page…', 'aimodelka' ), 'saveFailedHint' => __( 'If the photo appears later, reload the product page before you update the product.', 'aimodelka' ), // Изображения вариаций записаны, а в панели вариаций редактора есть несохранённые // правки: перезагружать её нельзя, и «Сохранить изменения» в ней записало бы // прежние изображения поверх новых. 'variationsReload' => __( 'Variation images saved. Reload the variations panel before saving changes in it, otherwise they will overwrite the new images.', 'aimodelka' ), // Сервер магазина оборвался посреди подготовки: создана фотосессия или нет — неизвестно. 'prepareCrashed' => __( 'The shop server stopped before the aiModelka window was ready — often a time limit at the hosting. Click the aiModelka button in the product again.', 'aimodelka' ), 'photosCrashed' => __( 'The shop server could not list the product photos. Close the aiModelka window and click the same button in the product again.', 'aimodelka' ), 'uploadCrashed' => __( 'The shop server stopped while sending the photo. Click “Continue” in the aiModelka window again.', 'aimodelka' ), 'saveCrashed' => __( 'The shop server stopped before the photo was saved. The shop will finish saving it in the background in a few minutes.', 'aimodelka' ), 'galleryDelete' => __( 'Delete image', 'aimodelka' ), 'galleryDeleteTip' => __( 'Delete', 'aimodelka' ), ); } /** * Тексты окошка «Упорядочить фото» (Documents/WPPlugin-HLD.md §2.10) — на языке страницы. * Отдельно от текстов окна генерации: окошко — своя часть плагина со своими строками. * * Строки с «%s» скрипт заполняет сам: номер фото в галерее, число вариаций без изображения. * Форма слова «N photos» для шапки окошка сюда не входит — её по числу считает PHP * и отдаёт в ответе на запрос фото (Media\Product_Images::state, countTexts). * * @return array */ public static function photos_texts(): array { return array( 'button' => __( 'Arrange photos', 'aimodelka' ), 'title' => __( 'Arrange photos', 'aimodelka' ), 'close' => __( 'Close', 'aimodelka' ), 'loading' => __( 'Loading…', 'aimodelka' ), 'loadFailed' => __( 'Could not load the product photos. Try again.', 'aimodelka' ), 'empty' => __( 'The product has no photos yet.', 'aimodelka' ), 'sectionGallery' => __( 'Product image and gallery', 'aimodelka' ), 'orderNote' => __( 'order as on the site', 'aimodelka' ), 'mainLabel' => __( 'Product image', 'aimodelka' ), /* translators: %s: position of the photo in the gallery, starting from 1. */ 'galleryItem' => __( 'Gallery · %s', 'aimodelka' ), 'sourceAi' => __( 'aiModelka', 'aimodelka' ), 'tagAi' => __( 'AI', 'aimodelka' ), 'tagSource' => __( 'orig.', 'aimodelka' ), 'setMain' => __( 'Set as product image', 'aimodelka' ), 'isMain' => __( 'Product image', 'aimodelka' ), 'moveUp' => __( 'Move up', 'aimodelka' ), 'moveDown' => __( 'Move down', 'aimodelka' ), 'remove' => __( 'Remove from the product', 'aimodelka' ), 'mainCannotRemove' => __( 'The product image can’t be removed — choose another product image first.', 'aimodelka' ), 'removed' => __( 'Removed from the product', 'aimodelka' ), 'restore' => __( 'Restore', 'aimodelka' ), 'galleryHint' => __( '★ — set as product image (the previous one goes first in the gallery). ✕ — remove from the product; the file stays in the Media Library.', 'aimodelka' ), 'sectionVariation' => __( 'Variation images', 'aimodelka' ), 'variationNote' => __( 'one image per variation', 'aimodelka' ), 'none' => __( '— none —', 'aimodelka' ), 'currentImage' => __( 'Current image', 'aimodelka' ), 'variationHint' => __( 'The list has the photos from the block above. “— none —” means the variation shows the product image.', 'aimodelka' ), /* translators: %s: number of variations. */ 'withoutImage' => __( 'Variations left without an image: %s', 'aimodelka' ), 'reloadNote' => __( 'The page will reload after saving.', 'aimodelka' ), // Редактор: страница не перезагружается, новый порядок встаёт в блоки «Изображение // товара» и «Галерея товара» на открытой странице, а «Обновить» его не перепишет. 'editorNote' => __( 'The product image and gallery on this page will update after saving.', 'aimodelka' ), 'saved' => __( 'Photo order saved to the product.', 'aimodelka' ), 'cancel' => __( 'Cancel', 'aimodelka' ), 'save' => __( 'Save', 'aimodelka' ), 'saving' => __( 'Saving…', 'aimodelka' ), 'saveFailed' => __( 'Could not save the photos. Try again.', 'aimodelka' ), ); } }