registry = $registry; $this->attributes = $attributes; $this->orders = $orders; $this->money = $money; } /** * Get the component slug. * * @since 1.0.0 * * @return string */ public function slug(): string { return 'account-dashboard'; } /** * Get component attribute definitions. * * @since 1.0.0 * * @return array> */ public function schema(): array { return array( 'title' => array( 'type' => 'string', 'default' => __( 'My account', 'releasecart' ), 'control' => 'text', 'label' => __( 'Heading', 'releasecart' ), 'description' => __( 'The heading shown above the dashboard. Leave empty to hide it.', 'releasecart' ), ), 'show_purchases' => array( 'type' => 'boolean', 'default' => true, 'control' => 'toggle', 'label' => __( 'Show purchases', 'releasecart' ), 'description' => __( 'Show the purchase-history panel.', 'releasecart' ), ), 'show_downloads' => array( 'type' => 'boolean', 'default' => true, 'control' => 'toggle', 'label' => __( 'Show downloads', 'releasecart' ), 'description' => __( 'Show the downloads panel.', 'releasecart' ), ), 'show_licenses' => array( 'type' => 'boolean', 'default' => true, 'control' => 'toggle', 'label' => __( 'Show licenses', 'releasecart' ), 'description' => __( 'Show the licenses panel.', 'releasecart' ), ), ); } /** * Get dashboard and composed-panel assets. * * @since 1.0.0 * * @return array */ public function assets(): array { $assets = array( 'styles' => array( 'releasecart-account-dashboard', 'releasecart-downloads-list', 'releasecart-license-manager', ), 'scripts' => array( 'releasecart-account-dashboard', 'releasecart-license-manager', ), ); // Composed extension panels also need their declared assets. foreach ( $this->contributedPanels() as $panel ) { $component = $this->registry->get( $panel['slug'] ); if ( ! $component instanceof Component ) { continue; } $declared = $component->assets(); foreach ( array( 'styles', 'scripts' ) as $kind ) { $handles = isset( $declared[ $kind ] ) && is_array( $declared[ $kind ] ) ? $declared[ $kind ] : array(); $assets[ $kind ] = array_values( array_unique( array_merge( $assets[ $kind ], array_filter( $handles, 'is_string' ) ) ) ); } } return $assets; } /** * Render the dashboard for the current context. * * @since 1.0.0 * * @param array $attrs Resolved attribute values. * @param RenderContext $ctx Per-request customer/license state. * @return string Escaped HTML. */ public function render( array $attrs, RenderContext $ctx ): string { $title = (string) $attrs['title']; // In an editor preview always render the sample dashboard, whether or not // the merchant happens to have a customer record. The preview exists to // design the page, not to show the merchant's own account, so the greeting, // the purchase row, and the composed panels are consistent sample content // end to end rather than a mix of the merchant's real shell and sample // panels. Live rendering (below) is untouched. if ( $ctx->isEditorPreview() ) { return $this->renderSample( $this->sampleDashboardData( $attrs, $title, $ctx ) ); } // A signed-in user gets a customer record with their first purchase. if ( null === $ctx->customer() && ! is_user_logged_in() ) { return $this->renderTemplate( $this->loggedOutData( $title, $ctx ) ); } return $this->renderTemplate( $this->dashboardData( $attrs, $title, $ctx ) ); } /** * Build a customer-free preview context. * * It prevents preview panels from reading store data. * * @since 1.0.0 * * @param RenderContext $ctx The current (preview) render context. * @return RenderContext */ private function sampleContext( RenderContext $ctx ): RenderContext { return new RenderContext( null, static fn (): array => array(), true, null, static fn (): ?Product => $ctx->product(), true ); } /** * Build sample dashboard data for the editor. * * @since 1.0.0 * * @param array $attrs The resolved attributes. * @param string $title The resolved heading. * @param RenderContext $ctx The current (preview) render context. * @return array */ private function sampleDashboardData( array $attrs, string $title, RenderContext $ctx ): array { $base = wp_unique_id( 'releasecart-account-dashboard-' ); // Use sample data, not store data. $sampleCtx = $this->sampleContext( $ctx ); $tabs = array(); if ( (bool) $attrs['show_purchases'] ) { $tabs[] = array_merge( $this->tab( $base, 'purchases', __( 'Purchases', 'releasecart' ) ), array( 'purchases' => $this->samplePurchaseRows() ) ); } if ( (bool) $attrs['show_downloads'] && $this->registry->has( 'downloads-list' ) ) { $tabs[] = array_merge( $this->tab( $base, 'downloads', __( 'Downloads', 'releasecart' ) ), array( 'html' => $this->composePanel( 'downloads-list', $sampleCtx ) ) ); } if ( (bool) $attrs['show_licenses'] && $this->registry->has( 'license-manager' ) ) { $tabs[] = array_merge( $this->tab( $base, 'licenses', __( 'Licenses', 'releasecart' ) ), array( 'html' => $this->composePanel( 'license-manager', $sampleCtx ) ) ); } foreach ( $this->contributedPanels() as $panel ) { $tabs[] = array_merge( $this->tab( $base, $panel['key'], $panel['label'] ), array( 'html' => $this->composePanel( $panel['slug'], $sampleCtx ) ) ); } // Preview one active tab and panel. $this->markFirstTabActive( $tabs ); return array( 'state' => 'dashboard', 'title' => $title, 'header' => array( 'avatar' => '', 'greeting' => sprintf( /* translators: %s: the customer's display name. */ __( 'Hi, %s', 'releasecart' ), __( 'Sample customer', 'releasecart' ) ), ), 'tabs' => $tabs, ); } /** * Build one sample purchase row. * * @since 1.0.0 * * @return array> */ private function samplePurchaseRows(): array { return array( array( 'reference' => '#1042', 'date' => (string) date_i18n( (string) get_option( 'date_format' ), 1_722_506_400 ), 'items' => array( array( 'name' => __( 'Sample product', 'releasecart' ), 'quantity' => 1, ), ), 'total' => $this->money->format( 4900 ), 'status_slug' => 'completed', 'status_label' => __( 'Completed', 'releasecart' ), ), ); } /** * Build the logged-out login prompt. * * @since 1.0.0 * * @param string $title The resolved heading. * @param RenderContext $ctx The current render context, threaded into the composed form. * @return array */ private function loggedOutData( string $title, RenderContext $ctx ): array { // Keep a fallback URL if the composed form is unavailable. $data = array( 'state' => 'logged_out', 'title' => $title, 'login_html' => '', 'login_url' => wp_login_url( RequestUrl::current() ), ); if ( $this->registry->has( 'login-form' ) ) { $data['login_html'] = $this->composePanel( 'login-form', $ctx ); } return $data; } /** * Build enabled dashboard tabs and panel data. * * @since 1.0.0 * * @param array $attrs The resolved attributes. * @param string $title The resolved heading. * @param RenderContext $ctx The current render context. * @return array */ private function dashboardData( array $attrs, string $title, RenderContext $ctx ): array { // Keep tab and panel IDs unique across dashboard instances. $base = wp_unique_id( 'releasecart-account-dashboard-' ); $customer = $ctx->customer(); $customerId = null !== $customer ? (int) $customer->id() : 0; $tabs = array(); if ( (bool) $attrs['show_purchases'] ) { $tabs[] = array_merge( $this->tab( $base, 'purchases', __( 'Purchases', 'releasecart' ) ), array( 'purchases' => $customerId > 0 ? $this->purchaseRows( $customerId ) : array() ) ); } if ( (bool) $attrs['show_downloads'] && $this->registry->has( 'downloads-list' ) ) { $tabs[] = array_merge( $this->tab( $base, 'downloads', __( 'Downloads', 'releasecart' ) ), array( 'html' => $this->composePanel( 'downloads-list', $ctx ) ) ); } if ( (bool) $attrs['show_licenses'] && $this->registry->has( 'license-manager' ) ) { $tabs[] = array_merge( $this->tab( $base, 'licenses', __( 'Licenses', 'releasecart' ) ), array( 'html' => $this->composePanel( 'license-manager', $ctx ) ) ); } // Render extension panels through the same safe component path. foreach ( $this->contributedPanels() as $panel ) { $tabs[] = array_merge( $this->tab( $base, $panel['key'], $panel['label'] ), array( 'html' => $this->composePanel( $panel['slug'], $ctx ) ) ); } return array( 'state' => 'dashboard', 'title' => $title, 'header' => null !== $customer ? $this->headerData( $customer ) : $this->userHeaderData(), 'tabs' => $tabs, ); } /** * Build the greeting for a signed-in user with no customer record yet. * * @since 1.0.0 * * @return array{avatar: string, greeting: string} The avatar HTML (empty when avatars are off) and the greeting text. */ private function userHeaderData(): array { $user = wp_get_current_user(); $avatar = get_avatar( (int) $user->ID, 48 ); $name = '' !== (string) $user->display_name ? (string) $user->display_name : (string) $user->user_email; return array( 'avatar' => is_string( $avatar ) ? $avatar : '', 'greeting' => sprintf( /* translators: %s: the customer's display name. */ __( 'Hi, %s', 'releasecart' ), $name ), ); } /** * Build the logged-in customer greeting. * * @since 1.0.0 * * @param Customer $customer The current customer. * @return array{avatar: string, greeting: string} The avatar HTML (empty when avatars are off) and the greeting text. */ private function headerData( Customer $customer ): array { $userId = $customer->userId(); // An email keeps the avatar available for an unlinked customer. $avatar = get_avatar( null !== $userId ? $userId : $customer->email(), 48 ); return array( 'avatar' => is_string( $avatar ) ? $avatar : '', 'greeting' => sprintf( /* translators: %s: the customer's display name. */ __( 'Hi, %s', 'releasecart' ), $this->displayName( $customer ) ), ); } /** * Get a non-empty customer display name. * * @since 1.0.0 * * @param Customer $customer The current customer. * @return string A non-empty display name. */ private function displayName( Customer $customer ): string { $userId = $customer->userId(); if ( null !== $userId ) { $user = get_userdata( $userId ); if ( false !== $user && '' !== (string) $user->display_name ) { return (string) $user->display_name; } } if ( null !== $customer->name() ) { return $customer->name(); } $full = trim( (string) $customer->firstName() . ' ' . (string) $customer->lastName() ); return '' !== $full ? $full : $customer->email(); } /** * Build one tab and its panel identifiers. * * @since 1.0.0 * * @param string $base The per-render id base. * @param string $key The panel key (purchases|downloads|licenses). * @param string $label The translated tab/panel heading. * @return array */ private function tab( string $base, string $key, string $label ): array { return array( 'key' => $key, 'tab_id' => $base . '-' . $key . '-tab', 'panel_id' => $base . '-' . $key, 'label' => $label, 'purchases' => array(), 'html' => '', // Live markup sets no tab state and leaves all panels visible without JavaScript. 'selected' => null, 'hidden' => false, ); } /** * Set preview-only state for the first tab and panel. * * @since 1.0.0 * * @param array> $tabs The prepared tabs, by reference. * @return void */ private function markFirstTabActive( array &$tabs ): void { foreach ( array_keys( $tabs ) as $index ) { $tabs[ $index ]['selected'] = 0 === $index; $tabs[ $index ]['hidden'] = 0 !== $index; } } /** * Validate dashboard panels from extensions. * * @since 1.0.0 * * @return array The usable contributions, in declaration order. */ private function contributedPanels(): array { /** * Filters extension dashboard panels. * * Each item needs a registered `slug` and translated `label`. `key` is optional. * * @since 1.0.0 * * @param array $panels The contributed panels. Default none. */ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- the constant is the literal `releasecart_account_dashboard_panels` hook name; it is a constant only because assets() applies the same filter. $declared = apply_filters( self::PANELS_FILTER, array() ); if ( ! is_array( $declared ) ) { return array(); } $panels = array(); $slugs = array(); // Reserve both generated ID forms to prevent ARIA collisions. $taken = array(); foreach ( self::RESERVED_KEYS as $reserved ) { $taken[] = $reserved; $taken[] = $reserved . '-tab'; } foreach ( $declared as $entry ) { if ( ! is_array( $entry ) ) { continue; } $slug = $entry['slug'] ?? ''; $label = $entry['label'] ?? ''; if ( ! is_string( $slug ) || '' === $slug || ! is_string( $label ) || '' === $label ) { continue; } $declaredKey = $entry['key'] ?? ''; $key = sanitize_key( is_string( $declaredKey ) && '' !== $declaredKey ? $declaredKey : $slug ); // Check both generated ID forms for collisions. if ( '' === $key || in_array( $key, $taken, true ) || in_array( $key . '-tab', $taken, true ) ) { continue; } // Each component can appear once. if ( in_array( $slug, $slugs, true ) ) { continue; } // Reject self-composition to prevent recursive rendering. if ( $slug === $this->slug() ) { continue; } // Do not show a tab for an unregistered component. if ( ! $this->registry->has( $slug ) ) { continue; } $taken[] = $key; $taken[] = $key . '-tab'; $slugs[] = $slug; $panels[] = array( 'key' => $key, 'slug' => $slug, 'label' => $label, ); } return $panels; } /** * Render a registered component as a dashboard panel. * * @since 1.0.0 * * @param string $slug The component slug to compose. * @param RenderContext $ctx The shared render context. * @return string The sub-component's fully-escaped HTML. */ private function composePanel( string $slug, RenderContext $ctx ): string { $component = $this->registry->get( $slug ); if ( ! $component instanceof Component ) { return ''; } $attrs = $this->attributes->resolve( $component->schema(), array() ); // The panel supplies the heading, so suppress the component title. if ( array_key_exists( 'title', $attrs ) ) { $attrs['title'] = ''; } return $component->render( $attrs, $ctx ); } /** * Build recent completed or partly refunded purchase rows. * * @since 1.0.0 * * @param int $customerId The customer id. * @return array> One prepared row per shown order. */ private function purchaseRows( int $customerId ): array { $orders = $this->orders->list( array( 'customer_id' => $customerId, 'per_page' => 50, ) ); $rows = array(); foreach ( $orders as $order ) { if ( OrderType::Refund === $order->type() || ! $this->orderIsOwned( $order ) ) { continue; } $rows[] = $this->purchaseRow( $order ); if ( count( $rows ) >= self::MAX_PURCHASES ) { break; } } return $rows; } /** * Check if the customer still owns part of this order. * * @since 1.0.0 * * @param Order $order The order to test. * @return bool True when the order should appear in the purchases panel. */ private function orderIsOwned( Order $order ): bool { return in_array( $order->status(), array( OrderStatus::Completed, OrderStatus::PartiallyRefunded ), true ); } /** * Build one purchase-row view model. * * @since 1.0.0 * * @param Order $order The order to present. * @return array */ private function purchaseRow( Order $order ): array { $items = array(); foreach ( $order->items() as $item ) { $items[] = array( 'name' => $item->productName(), 'quantity' => $item->quantity(), ); } return array( 'reference' => '#' . (int) $order->id(), 'date' => (string) date_i18n( (string) get_option( 'date_format' ), $this->orderTimestamp( $order ) ), 'items' => $items, 'total' => $this->money->format( $order->total(), $order->currency() ), 'status_slug' => $order->status()->value, 'status_label' => $this->statusLabel( $order->status() ), ); } /** * Get the order display timestamp from its UTC dates. * * @since 1.0.0 * * @param Order $order The order. * @return int The Unix timestamp. */ private function orderTimestamp( Order $order ): int { $stamp = $order->completedAt() ?? $order->createdAt(); if ( null === $stamp ) { return time(); } $parsed = strtotime( $stamp . ' UTC' ); return false !== $parsed ? $parsed : time(); } /** * Get the translated purchase status label. * * @since 1.0.0 * * @param OrderStatus $status The order status. * @return string */ private function statusLabel( OrderStatus $status ): string { return match ( $status ) { OrderStatus::Completed => __( 'Completed', 'releasecart' ), OrderStatus::PartiallyRefunded => __( 'Partially refunded', 'releasecart' ), default => $status->value, }; } }