Skip to content
Merged
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
10 changes: 10 additions & 0 deletions build-dev/index-rtl.css
Original file line number Diff line number Diff line change
Expand Up @@ -1614,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' => '4355715a309eefbedffb');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '764567460341e48adbdf');
10 changes: 10 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.

1,819 changes: 1,741 additions & 78 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.

2 changes: 1 addition & 1 deletion 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' => '4cbdb3d9e5ce657b27f7');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '26a51bca626b83694e68');
2 changes: 1 addition & 1 deletion 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
84 changes: 79 additions & 5 deletions resources/js/components/organisms/ProductSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,26 @@
*
* @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';

Expand All @@ -38,15 +48,19 @@ export function ProductSection( { product, hideLicenseBadge = false }: ProductSe
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 @@ -67,6 +81,25 @@ export function ProductSection( { product, hideLicenseBadge = false }: ProductSe
)
);

// 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 @@ -83,7 +116,48 @@ export function ProductSection( { product, hideLicenseBadge = false }: ProductSe
{ product.name }
</h2>
{ ! hideLicenseBadge && ( isNotActivated ? (
<LicenseBadge type="unactivated" />
<>
<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 ? (
Expand Down
21 changes: 10 additions & 11 deletions resources/js/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,26 @@ const buttonVariants = cva(
}
)

function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot.Root : "button"

return (
<Comp
ref={ref}
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
})
Button.displayName = "Button"

export { Button, buttonVariants }
86 changes: 86 additions & 0 deletions resources/js/components/ui/dropdown-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Radix UI DropdownMenu primitives styled to the project's design system.
*
* Intentionally NOT using DropdownMenu.Portal — portal content renders outside
* .lw-harbor-ui and would be invisible to the PostCSS scope plugin (all Tailwind
* utilities are scoped to .lw-harbor-ui). The Content renders in the DOM tree but
* Radix positions it with position:fixed so it still floats above other elements.
*
* @package LiquidWeb\Harbor
*/
import * as React from 'react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';

const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;

const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>( ( { className, sideOffset = 4, ...props }, ref ) => (
<DropdownMenuPrimitive.Content
ref={ ref }
sideOffset={ sideOffset }
className={ cn(
'z-[100000] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
'data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
className
) }
{ ...props }
/>
) );
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;

const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>( ( { className, ...props }, ref ) => (
<DropdownMenuPrimitive.Item
ref={ ref }
className={ cn(
'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
'focus:bg-accent focus:text-accent-foreground',
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
) }
{ ...props }
/>
) );
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;

const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>( ( { className, ...props }, ref ) => (
<DropdownMenuPrimitive.Label
ref={ ref }
className={ cn( 'px-2 py-1.5 text-sm font-semibold', className ) }
{ ...props }
/>
) );
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;

const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>( ( { className, ...props }, ref ) => (
<DropdownMenuPrimitive.Separator
ref={ ref }
className={ cn( '-mx-1 my-1 h-px bg-muted', className ) }
{ ...props }
/>
) );
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;

export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuGroup,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
};
29 changes: 21 additions & 8 deletions resources/js/store/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,25 +248,38 @@ export const areAllProductsNotActivated = ( state: State ): boolean => {
};

/**
* Returns the first license product for the given slug that the user owns but
* has not yet activated on this domain, or null when none exists.
* Returns every license product for the given slug that the user owns but has
* not yet activated on this domain.
*
* Matches entries where activated_here is not true and validation_status is
* not_activated or activation_required — i.e. the subscription exists but the
* current domain is not in the activations list.
* current domain is not in the activations list. A unified key can cover more
* than one tier of the same product, so this can return multiple entries.
*
* @since 1.0.1
* @since TBD
*/
export const getUnactivatedLicenseProduct = (
export const getUnactivatedLicenseProducts = (
state: State,
productSlug: string
): LicenseProduct | null =>
getWithoutCancelledProducts( state ).find(
): LicenseProduct[] =>
getWithoutCancelledProducts( state ).filter(
( p ) =>
p.product_slug === productSlug &&
p.activated_here !== true &&
UNACTIVATED_STATUSES.includes( p.validation_status as typeof UNACTIVATED_STATUSES[ number ] )
) ?? null;
);

/**
* Returns the first license product for the given slug that the user owns but
* has not yet activated on this domain, or null when none exists.
*
* @since 1.0.1
*/
export const getUnactivatedLicenseProduct = (
state: State,
productSlug: string
): LicenseProduct | null =>
getUnactivatedLicenseProducts( state, productSlug )[ 0 ] ?? null;

/**
* Returns the stored unified license key, or null. Triggers getLicenseKey resolver.
Expand Down
52 changes: 52 additions & 0 deletions tests/_data/licensing/lwsw-unified-mixed-tier-2026.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"products": [
{
"product_slug": "learndash",
"tier": "pro",
"status": "active",
"expires": "2026-12-31 23:59:59",
"activations": {
"site_limit": 3,
"active_count": 1,
"over_limit": false,
"domains": [],
"excess_activations": 0
},
"validation_status": "valid",
"is_valid": true,
"capabilities": [
"sfwd-lms",
"learndash-achievements",
"learndash-certificate-builder",
"learndash-notifications"
],
"activated_here": true
},
{
"product_slug": "learndash",
"tier": "elite",
"status": "active",
"expires": "2026-12-31 23:59:59",
"activations": {
"site_limit": 3,
"active_count": 0,
"over_limit": false,
"domains": [],
"excess_activations": 0
},
"validation_status": "not_activated",
"is_valid": false,
"capabilities": [
"sfwd-lms",
"memberdash",
"learndash-achievements",
"learndash-certificate-builder",
"learndash-notifications",
"learndash-propanel",
"learndash-groups-plus",
"instructor-role"
],
"activated_here": false
}
]
}
Loading