proStatus = $proStatus ?? new ProStatus();
$this->proFeatures = $proFeatures ?? new ProFeatures();
$this->dismissals = $dismissals ?? new Dismissals();
}
/**
* Enqueue the bundle on the SPA page only.
*
* @since 1.0.0
*
* @return void
*/
public function hooks(): void {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
}
/**
* Render the SPA root and noscript notice.
*
* @since 1.0.0
*
* @return void
*/
public function render(): void {
/*
* App.js adds the shell inside this node. Do not duplicate its class.
*/
echo '
';
echo '';
}
/**
* Enqueue the SPA bundle and boot data when a build exists.
*
* @since 1.0.0
*
* @param string $hook The current admin screen hook.
* @return void
*/
public function enqueue( string $hook ): void {
if ( self::PAGE_HOOK !== $hook ) {
return;
}
$asset = $this->buildAsset();
if ( null !== $asset ) {
$version = (string) ( $asset['version'] ?? RELEASECART_VERSION );
wp_enqueue_script(
self::HANDLE,
RELEASECART_URL . 'assets/admin/build/index.js',
// The classic-editor handles the rich text control drives are added
// to the dependency list so they load, and so the bundle evaluates
// with window.wp.editor present. Readiness (the default settings,
// printed later at footer priority 45) is handled in the control.
array_merge( $asset['dependencies'] ?? array(), array( 'editor', 'quicktags' ) ),
$version,
true
);
/*
* Load translations for the SPA bundle.
*/
wp_set_script_translations( self::HANDLE, 'releasecart', RELEASECART_PATH . 'languages' );
/*
* Product image controls use the WordPress media library.
*/
wp_enqueue_media();
/*
* Product rich text controls use the WordPress classic editor.
*/
wp_enqueue_editor();
// The SPA renders @wordpress/components; their stylesheet ships with core.
wp_enqueue_style( 'wp-components' );
/*
* The stylesheet can be absent from older asset builds.
*/
if ( $this->styleBuilt() ) {
wp_enqueue_style(
self::HANDLE,
RELEASECART_URL . 'assets/admin/build/index.css',
array(),
$version
);
/*
* Use the generated RTL stylesheet when WordPress selects it.
*/
wp_style_add_data( self::HANDLE, 'rtl', 'replace' );
}
wp_add_inline_script(
self::HANDLE,
// The hex flags keep the payload safe inside a script element even
// if a future boot field ever carries markup-like characters.
'window.releaseCart = ' . wp_json_encode(
$this->bootData(),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) . ';',
'before'
);
}
/**
* Fires after the core admin bundle enqueue.
*
* @since 1.0.0
*
* @param string $handle The core SPA bundle script handle.
*/
do_action( 'releasecart_admin_enqueue', self::HANDLE );
}
/**
* Read the built asset manifest, or null when absent.
*
* @since 1.0.0
*
* @return array|null The manifest, or null when absent.
*/
protected function buildAsset(): ?array {
$assetFile = RELEASECART_PATH . 'assets/admin/build/index.asset.php';
if ( ! file_exists( $assetFile ) ) {
return null;
}
$asset = include $assetFile;
// A manifest that is not an array is as unusable as a missing one, but
// an empty array still enqueues: the defaults below cover both fields.
return is_array( $asset ) ? $asset : array();
}
/**
* Whether the build emitted the SPA stylesheet.
*
* @since 1.0.0
*
* @return bool True when `assets/admin/build/index.css` exists.
*/
protected function styleBuilt(): bool {
return file_exists( RELEASECART_PATH . 'assets/admin/build/index.css' );
}
/**
* The boot data printed before the bundle.
*
* @since 1.0.0
*
* @return array The boot payload.
*/
public function bootData(): array {
$settings = get_option( 'releasecart_settings' );
$settings = is_array( $settings ) ? $settings : array();
$caps = array();
foreach ( Capabilities::all() as $cap ) {
$caps[ $cap ] = current_user_can( $cap );
}
// A WordPress capability rather than one of ours, because the lists link
// registered customers straight into the user editor. A shop manager runs the
// store without it, and for them the link would land on a permission wall, so
// the screens print plain text instead.
$caps['edit_users'] = current_user_can( 'edit_users' );
$fields = new ProductFieldRegistry( new CoreProductFields() );
$data = array(
'apiRoot' => esc_url_raw( rest_url( 'releasecart/v1/' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'caps' => $caps,
'settings' => array(
'currency' => isset( $settings['currency'] ) ? (string) $settings['currency'] : 'USD',
),
// What to put between the items of a list the admin screens print in
// a sentence or a table cell. WordPress translates it per locale, and
// a hardcoded comma and space is wrong in several of them, so the
// bundles read it from here rather than each writing their own.
'listSeparator' => wp_get_list_item_separator(),
'pro' => $this->proStatus->isActive(),
// The product editor renders these fields and their panels; the same
// registry sanitizes them on save, so the two never drift.
'productFields' => array(
'fields' => $fields->forClient(),
'panels' => $fields->panels(),
),
// The settings dropdowns (currency, country, page) read these here so
// the screen fills them without a second request. The same lists are
// served publicly by CatalogController for logged-out consumers. The
// page list is gated on the settings capability, matching that
// endpoint: it discloses site structure, and only the settings screen
// (which needs that capability) renders a page picker, so it stays out
// of the payload for staff without it, which also spares them the
// get_pages() query on every screen.
'catalog' => array(
'countries' => $this->countryList(),
'currencies' => $this->currencyList(),
'pages' => ! empty( $caps[ Capabilities::MANAGE_SETTINGS ] ) ? $this->pageList() : array(),
),
'admin' => array(
'routes' => array(),
),
);
// The upsell copy and dismissals ride the boot object only while Pro is
// absent, so the SPA surfaces need no round-trip and cannot render when
// Pro is active.
if ( ! $this->proStatus->isActive() ) {
$data['upsell'] = array(
'url' => $this->proStatus->upgradeUrl(),
'intro' => $this->proFeatures->intro(),
'notice' => $this->proFeatures->notice(),
'linkLabel' => $this->proFeatures->linkLabel(),
'ctaLabel' => $this->proFeatures->ctaLabel(),
'features' => $this->proFeatures->all(),
'dismissed' => $this->dismissals->topics(),
);
}
return $data;
}
/**
* Return the country catalog for SPA dropdowns.
*
* @since 1.0.0
*
* @return array
*/
private function countryList(): array {
$out = array();
foreach ( Countries::all() as $code => $name ) {
$out[] = array(
'code' => (string) $code,
'name' => (string) $name,
);
}
return $out;
}
/**
* The currency catalog as an ordered list of `{code, label, symbol, decimals}`.
*
* @since 1.0.0
*
* @return array
*/
private function currencyList(): array {
$out = array();
foreach ( Currencies::all() as $code => $entry ) {
$out[] = array(
'code' => (string) $code,
'label' => (string) $entry['label'],
'symbol' => (string) $entry['symbol'],
'decimals' => (int) $entry['decimals'],
);
}
return $out;
}
/**
* Return published pages for settings page pickers.
*
* @since 1.0.0
*
* @return array
*/
private function pageList(): array {
$pages = get_pages();
if ( ! is_array( $pages ) ) {
$pages = array();
}
$out = array();
foreach ( $pages as $page ) {
$title = trim( (string) $page->post_title );
$out[] = array(
'id' => (int) $page->ID,
'title' => '' !== $title ? $title : __( '(no title)', 'releasecart' ),
);
}
return $out;
}
}