Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions build-dev/index-rtl.css
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,9 @@
.lw-harbor-ui .w-6 {
width: calc(var(--spacing) * 6) !important;
}
.lw-harbor-ui .w-7 {
width: calc(var(--spacing) * 7) !important;
}
.lw-harbor-ui .w-8 {
width: calc(var(--spacing) * 8) !important;
}
Expand All @@ -584,6 +587,9 @@
.lw-harbor-ui .w-36 {
width: calc(var(--spacing) * 36) !important;
}
.lw-harbor-ui .w-48 {
width: calc(var(--spacing) * 48) !important;
}
.lw-harbor-ui .w-\[130px\] {
width: 130px !important;
}
Expand Down Expand Up @@ -951,6 +957,12 @@
.lw-harbor-ui .bg-white {
background-color: var(--color-white) !important;
}
.lw-harbor-ui .bg-white\/20 {
background-color: color-mix(in srgb, #fff 20%, transparent) !important;
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-white) 20%, transparent) !important;
}
}
.lw-harbor-ui .bg-gradient-to-r {
--lw-harbor-tw-gradient-position: to right in oklab;
background-image: linear-gradient(var(--lw-harbor-tw-gradient-stops)) !important;
Expand Down Expand Up @@ -1602,6 +1614,16 @@
opacity: 50% !important;
}
}
.lw-harbor-ui .data-\[highlighted\]\:bg-accent {
.lw-harbor-ui &[data-highlighted] {
background-color: oklch(0.965 0 0) !important;
}
}
.lw-harbor-ui .data-\[highlighted\]\:text-accent-foreground {
.lw-harbor-ui &[data-highlighted] {
color: oklch(0.205 0 0) !important;
}
}
.lw-harbor-ui .data-\[side\=bottom\]\:translate-y-1 {
.lw-harbor-ui &[data-side="bottom"] {
--lw-harbor-tw-translate-y: calc(var(--spacing) * 1);
Expand Down
2 changes: 1 addition & 1 deletion build-dev/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '15d9bcfeaaf63bae053a');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '764567460341e48adbdf');
22 changes: 22 additions & 0 deletions build-dev/index.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build-dev/index.css.map

Large diffs are not rendered by default.

2,386 changes: 2,080 additions & 306 deletions build-dev/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build-dev/index.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions build/index-rtl.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '24a939985ffe55bc7ead');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '26a51bca626b83694e68');
4 changes: 2 additions & 2 deletions build/index.css

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions build/index.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
significance: patch
type: tweak
entry: Added an Activate control beside the Unactivated badge in the product header, with a tier picker when a license covers multiple unactivated tiers.
timestamp: 2026-07-14T17:35:16.508Z
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
significance: patch
type: tweak
entry: Split the Feature Manager product list into Installed Features and
Available Features sections.
timestamp: 2026-07-14T18:22:38.225Z
92 changes: 84 additions & 8 deletions resources/js/components/organisms/ProductSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,42 +9,58 @@
*
* @package LiquidWeb\Harbor
*/
import { useState } from 'react';
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { ExternalLink, ChevronDown, ChevronUp } from 'lucide-react';
import { LicenseBadge } from '@/components/atoms/LicenseBadge';
import { ProductLogo } from '@/components/atoms/ProductLogo';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { FeatureRow } from '@/components/molecules/FeatureRow';
import { TierGroup } from '@/components/molecules/TierGroup';
import { store as harborStore } from '@/store';
import { useFilter } from '@/context/filter-context';
import { useProductFeatureGroups } from '@/hooks/useProductFeatureGroups';
import { buildUpgradeUrl } from '@/lib/upgrade-url';
import { buildActivationUrl } from '@/lib/activation-url';
import { getHarborDataValue } from '@/lib/harbor-data';
import type { Product } from '@/types/api';

interface ProductSectionProps {
product: Product;
hideLicenseBadge?: boolean;
}

/**
* @since TBD Add hideLicenseBadge prop to suppress the header license badge for Available cards.
* @since 1.3.0 Read domain through the getHarborDataValue helper for upgrade URLs.
* @since 1.0.2 Route upgrade CTA to catalog upgrade_url for existing subscribers, purchase_url for new subscribers.
* @since 1.0.1 Show Unactivated badge on tier groups and product header for unactivated licenses.
* @since 1.0.0
*/
export function ProductSection( { product }: ProductSectionProps ) {
export function ProductSection( { product, hideLicenseBadge = false }: ProductSectionProps ) {
const { searchQuery } = useFilter();
const isSearching = searchQuery.trim().length > 0;

// Tracks the header tier-picker's open state so its chevron can flip.
const [ tierMenuOpen, setTierMenuOpen ] = useState( false );

// Full unfiltered set — used only for header counts so they stay stable.
const { licenseProduct, hasActiveLegacy, unactivatedLicenseProduct } = useSelect(
const { licenseProduct, hasActiveLegacy, unactivatedLicenseProduct, unactivatedLicenseProducts } = useSelect(
( select ) => {
const licenseProducts = select( harborStore ).getLicenseProducts();
const forProduct = licenseProducts.filter( ( lp ) => lp.product_slug === product.slug );
return {
licenseProduct: forProduct.find( ( lp ) => lp.activated_here === true ) ?? null,
hasActiveLegacy: select( harborStore ).hasActiveLegacyLicenseForProduct( product.slug ),
unactivatedLicenseProduct: select( harborStore ).getUnactivatedLicenseProduct( product.slug ),
licenseProduct: forProduct.find( ( lp ) => lp.activated_here === true ) ?? null,
hasActiveLegacy: select( harborStore ).hasActiveLegacyLicenseForProduct( product.slug ),
unactivatedLicenseProduct: select( harborStore ).getUnactivatedLicenseProduct( product.slug ),
unactivatedLicenseProducts: select( harborStore ).getUnactivatedLicenseProducts( product.slug ),
};
},
[ product.slug ],
Expand All @@ -65,6 +81,25 @@ export function ProductSection( { product }: ProductSectionProps ) {
)
);

// Owned-but-unactivated products get an Activate CTA beside the header badge,
// falling back to the unactivated product record when no tier is active here.
const activationUrl = getHarborDataValue( 'activationUrl' );
const effectiveLicenseProduct = licenseProduct ?? unactivatedLicenseProduct;
const showHeaderActivate = isNotActivated && !! activationUrl && !! effectiveLicenseProduct;

// A unified key can cover multiple unactivated tiers of one product (each a
// distinct SKU). When it does, offer a picker defaulting to the highest tier
// instead of silently activating an arbitrary one.
const activatableTiers = unactivatedLicenseProducts
.map( ( lp ) => {
const catalogTier = sortedCatalogTiers.find( ( t ) => t.tier_slug === lp.tier );
return { lp, rank: catalogTier?.rank ?? -1, name: catalogTier?.name ?? lp.tier };
} )
.sort( ( a, b ) => b.rank - a.rank );

const defaultActivateTier = activatableTiers[ 0 ]?.lp.tier ?? effectiveLicenseProduct?.tier;
const showTierPicker = showHeaderActivate && activatableTiers.length > 1;

const tierName = licenseProduct
? ( sortedCatalogTiers.find( ( t ) => t.tier_slug === licenseProduct.tier )?.name ?? licenseProduct.tier )
: null;
Expand All @@ -80,15 +115,56 @@ export function ProductSection( { product }: ProductSectionProps ) {
<h2 className="text-base font-semibold m-0 p-0 text-white">
{ product.name }
</h2>
{ isNotActivated ? (
<LicenseBadge type="unactivated" />
{ ! hideLicenseBadge && ( isNotActivated ? (
<>
<LicenseBadge type="unactivated" />
{ showHeaderActivate && defaultActivateTier && (
showTierPicker ? (
<DropdownMenu modal={ false } open={ tierMenuOpen } onOpenChange={ setTierMenuOpen }>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="xs" className="shrink-0">
{ __( 'Activate', '%TEXTDOMAIN%' ) }
{ tierMenuOpen
? <ChevronUp className="w-3 h-3 -translate-y-px" />
: <ChevronDown className="w-3 h-3 -translate-y-px" /> }
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{ activatableTiers.map( ( { lp, name } ) => (
<DropdownMenuItem key={ `${ lp.product_slug }:${ lp.tier }` } asChild>
<a
href={ buildActivationUrl( activationUrl, product.slug, lp.tier ) }
target="_blank"
rel="noopener noreferrer"
>
{ name }
<ExternalLink className="w-3 h-3 ml-auto" />
</a>
</DropdownMenuItem>
) ) }
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="outline" size="xs" asChild className="shrink-0">
<a
href={ buildActivationUrl( activationUrl, product.slug, defaultActivateTier ) }
target="_blank"
rel="noopener noreferrer"
>
{ __( 'Activate', '%TEXTDOMAIN%' ) }
<ExternalLink className="w-3 h-3 -translate-y-px" />
</a>
</Button>
)
) }
</>
) : tierName ? (
<LicenseBadge type="licensed" tierName={ tierName } />
) : hasActiveLegacy ? (
<LicenseBadge type="legacy" />
) : (
<LicenseBadge type="unlicensed" className="text-white border-white/40" />
) }
) ) }
<span className="ml-auto text-xs text-white/70">
{ activeCount } { __( 'active', '%TEXTDOMAIN%' ) }
{ ' · ' }
Expand Down
29 changes: 13 additions & 16 deletions resources/js/components/organisms/ProductSectionSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
* Pulse-skeleton for a single product section, shown while the Harbor data
* resolvers are in flight on the first page load.
*
* Mirrors ProductSection's DOM structure: same sticky header (with real logo
* and product name but no badge or counters) followed by a fixed number of
* skeleton feature rows.
* Mirrors ProductSection's DOM structure: same sticky header followed by a
* fixed number of skeleton feature rows. The header is intentionally nameless
* (placeholder logo and title bars, no real product) because install state and
* ownership — which decide each product's section and order — are not known
* until the data resolves. A nameless skeleton avoids named cards appearing to
* reorder when the computed layout replaces it.
*
* @package LiquidWeb\Harbor
*/
import { ProductLogo } from '@/components/atoms/ProductLogo';
import type { Product } from '@/types/api';

const SKELETON_ROW_COUNT = 3;

function SkeletonFeatureRow( { isLast }: { isLast: boolean } ) {
Expand All @@ -33,22 +33,19 @@ function SkeletonFeatureRow( { isLast }: { isLast: boolean } ) {
);
}

interface ProductSectionSkeletonProps {
product: Product;
}

/**
* @since TBD Render a nameless header instead of a real product logo/name.
* @since 1.0.0
*/
export function ProductSectionSkeleton( { product }: ProductSectionSkeletonProps ) {
export function ProductSectionSkeleton() {
return (
<section id={ product.slug } className="scroll-mt-20">
<section className="scroll-mt-20">
<div className="h-0" />
<div className="flex items-center gap-3 px-4 py-3 bg-neutral-800 text-white sticky top-0 z-10 border-x border-neutral-800 transition-[border-radius] rounded-t-lg border-t">
<ProductLogo slug={ product.slug } size={ 28 } productName={ product.name } />
<h2 className="text-base font-semibold m-0 p-0 text-white">
{ product.name }
</h2>
{ /* logo placeholder */ }
<div className="w-7 h-7 rounded shrink-0 bg-white/20 animate-pulse" />
{ /* product name placeholder */ }
<div className="h-4 w-32 rounded bg-white/20 animate-pulse" />
</div>
<div className="border border-t-0 rounded-b-lg overflow-hidden">
{ Array.from( { length: SKELETON_ROW_COUNT }, ( _, i ) => (
Expand Down
Loading
Loading