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
81 changes: 37 additions & 44 deletions assets/src/modules/admin/components/addon-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
* 1. An optional banner image (`CardMedia`). When the addon is
* installed + active we also paint an "Active" pill in the
* top-right corner of the banner.
* 2. A header carrying the addon title (and the "Active" pill as
* a fallback when there's no banner to overlay it on).
* 3. A description body — this is the flex grower so the footer
* stays at a fixed height regardless of description length.
* 4. A footer carrying the primary CTA (Manage license / Get it /
* Buy now) and a "Learn more" link.
* 2. A header carrying the addon title + the badge cluster.
* 3. A description body — the flex grower so the footer stays at
* a fixed height regardless of description length.
* 4. A footer carrying the primary CTA on the left and a "More
* details" link on the right. Layout matches the 404-to-301
* plugin's addon cards so the two products feel like siblings.
*
* License management lives in a sibling modal — clicking
* "Manage license" fires `onManageLicense(addon)` so the modal can
* "Manage License" fires `onManageLicense(addon)` so the modal can
* be hoisted to the tab root (keeps focus management + ARIA
* announcements clean).
*
Expand All @@ -38,14 +38,14 @@ import BannerImage from './banner-image';

const AddonCard = ( { addon, onManageLicense } ) => {
/*
* Purchase CTA shown only when the addon isn't installed
* locally. Variant flips on `is_premium` so the paid items get
* the high-emphasis primary button while the free ones use the
* lower-key secondary button.
* Purchase CTA shown when the addon is NOT installed locally.
* Variant flips on `is_premium` so paid items get the
* high-emphasis primary button while free items use the
* secondary button.
*
* Link priority: the catalogue's marketing URL (`link`) first,
* then the project homepage (`homepage`) as a fallback for
* rows that ship without a dedicated buy URL.
* then the project homepage (`homepage`) as a fallback for rows
* that ship without a dedicated buy URL.
*/
const purchaseCta = (
<Button
Expand Down Expand Up @@ -81,11 +81,10 @@ const AddonCard = ( { addon, onManageLicense } ) => {
<CardHeader>
<strong>{ addon.title }</strong>
{ /*
* Right-side badge cluster. Premium/Free always
* shown; Licensed/Unlicensed only for installed
* addons. When there's no banner the "Active" pill
* falls back here too — otherwise it lives overlaid
* on the banner above.
* Right-side badge cluster. Premium/Free always shown;
* Licensed/Unlicensed only for installed addons. When
* there's no banner the "Active" pill falls back here
* too — otherwise it lives overlaid on the banner above.
*/ }
<span className="loggedin-addon-header-badges">
{ ! addon.banner && addon.is_active && (
Expand Down Expand Up @@ -123,49 +122,43 @@ const AddonCard = ( { addon, onManageLicense } ) => {
</CardBody>

<CardFooter>
{ /*
* Explicit Flex wrapper so the slot order is locked
* regardless of `CardFooter`'s default justify. The
* primary CTA pins left, the "More details" link
* pins right — same as the 404-to-301 addon cards.
*/ }
<Flex justify="space-between" align="center">
<FlexItem>
{ addon.homepage && (
<a
href={ addon.homepage }
target="_blank"
rel="noopener noreferrer"
>
{ __( 'Learn more', 'loggedin' ) }
</a>
) }
</FlexItem>
<FlexItem>
{ /*
* Footer CTA — single slot, two states:
* - Installed: license management button.
* Label changes between "Activate" and
* "Manage" depending on the stored
* license state, but both open the
* same modal.
* - Not installed: the purchase CTA
* declared above.
*/ }
{ addon.is_active ? (
<Button
__next40pxDefaultSize
variant="secondary"
onClick={ () => onManageLicense( addon ) }
>
{ addon.is_license_active
? __(
'Manage license',
'loggedin'
)
? __( 'Manage License', 'loggedin' )
: __(
'Activate license',
'Activate License',
'loggedin'
) }
</Button>
) : (
purchaseCta
) }
</FlexItem>
{ addon.homepage && (
<FlexItem>
<Button
variant="link"
href={ addon.homepage }
target="_blank"
rel="noopener noreferrer"
>
{ __( 'More details', 'loggedin' ) }
</Button>
</FlexItem>
) }
</Flex>
</CardFooter>
</Card>
Expand Down
61 changes: 61 additions & 0 deletions assets/src/modules/admin/components/force-logout-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,61 @@ import { useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import {
Button,
Notice,
PanelBody,
PanelRow,
TextControl,
} from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { useDispatch } from '@wordpress/data';
import { applyFilters } from '@wordpress/hooks';
import { store as noticesStore } from '@wordpress/notices';

/**
* Default cross-sell Notice promoting the Active Sessions addon.
*
* Lives at the bottom of the Force Logout panel and is routed through
* the `loggedin.settings.force_logout.cross_sell` filter so the addon
* — once loaded on this same screen — returns `null` to hide it.
*
* The CTA is an inline text link in the body copy rather than the
* Notice `actions` button: the button forces the banner to a taller
* two-row layout, so a plain link keeps the notice compact. Matches
* the 404 to 301 plugin's cross-sell design.
*
* The Notice is non-dismissible: a dismissible CTA would persist its
* dismissed state in component memory only (no server round-trip), so
* it'd come straight back on the next page load. Better to render a
* stable banner the addon can swap out entirely.
*/
const DefaultCrossSell = () => (
<PanelRow className="loggedin-cross-sell">
<Notice status="info" isDismissible={ false }>
<p className="loggedin-cross-sell__title">
<strong>
{ __(
'Need to log someone out without knowing who?',
'loggedin'
) }
</strong>
</p>
<p>
{ __(
'Install the Active Sessions addon to browse every user with a live session, drill into each device they’re signed in from, and sign them out one at a time — or all at once.',
'loggedin'
) }{ ' ' }
<a
href="https://duckdev.com/addon/loggedin-active-sessions/"
target="_blank"
rel="noreferrer"
>
{ __( 'Get Active Sessions', 'loggedin' ) }
</a>
</p>
</Notice>
</PanelRow>
);

const ForceLogoutPanel = () => {
// Local input state. Plain `useState` — the panel doesn't need
// to share its value with anything else in the React tree.
Expand All @@ -35,6 +82,18 @@ const ForceLogoutPanel = () => {
const { createSuccessNotice, createErrorNotice } =
useDispatch( noticesStore );

/*
* Cross-sell slot. Defaults to <DefaultCrossSell />; the Active
* Sessions addon returns `null` here to suppress the promo once
* installed. Kept as its own filter so future addons can replace
* the banner with a different recommendation without parent-side
* changes.
*/
const crossSell = applyFilters(
'loggedin.settings.force_logout.cross_sell',
<DefaultCrossSell />
);

/**
* Submit handler — POST the identifier and dispatch a snackbar
* reflecting the result.
Expand Down Expand Up @@ -116,6 +175,8 @@ const ForceLogoutPanel = () => {
: __( 'Force Logout', 'loggedin' ) }
</Button>
</PanelRow>

{ crossSell }
</PanelBody>
);
};
Expand Down
9 changes: 7 additions & 2 deletions assets/src/modules/admin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
* resolvers are scoped to the time it's on screen (no stale
* subscriptions, no background polling for hidden tabs).
*/
import { useState } from '@wordpress/element';
import { useMemo, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Spinner } from '@wordpress/components';
import { Footer, Notices, PageBody, PageHeader, TabNav } from '../../common';
import useSettings from '../../hooks/use-settings';
import tabs from './tabs';
import { resolveTabs } from './tabs';

const AdminApp = () => {
// We block the first render of any tab on the initial settings
Expand All @@ -25,6 +25,11 @@ const AdminApp = () => {
// when the user lands on the default Settings tab.
const { hasLoaded } = useSettings();

// Resolve the tab registry on mount so addon bundles have a
// chance to register via `loggedin.admin.tabs` before the filter
// is read. Memoised so the lookup doesn't run every render.
const tabs = useMemo( () => resolveTabs(), [] );

// Ordered list of tab keys. The first entry is the default tab.
const tabKeys = Object.keys( tabs );
const [ current, setCurrent ] = useState( tabKeys[ 0 ] );
Expand Down
91 changes: 86 additions & 5 deletions assets/src/modules/admin/tabs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,107 @@
* used for grid-heavy tabs like Addons.
*
* Order in this object is the tab order in the nav.
*
* Addons inject their own tabs through the `loggedin.admin.tabs`
* filter — same pattern as `loggedin.settings.panels`. Each filter
* entry must be `{ key, label, component, wide?, before?, after? }`:
*
* - `key` Stable string id. Replaces a built-in tab with the
* same key, so addons can override Settings/Addons/
* Support if they really mean to.
* - `before` Optional — insert the new tab immediately before
* the tab with this key. Falls back to append.
* - `after` Optional — insert immediately after. Ignored when
* `before` is set.
*
* The filter is applied at import time so the result is stable across
* renders; addons that need dynamic tabs should still register here
* and feature-flag inside their component.
*/
import { applyFilters } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
import Settings from './settings';
import Addons from './addons';
import Support from './support';

const tabs = {
settings: {
const builtIn = [
{
key: 'settings',
label: __( 'Settings', 'loggedin' ),
component: Settings,
},
addons: {
{
key: 'addons',
label: __( 'Addons', 'loggedin' ),
component: Addons,
wide: true,
},
support: {
{
key: 'support',
label: __( 'Support', 'loggedin' ),
component: Support,
},
];

const isValid = ( tab ) =>
tab && typeof tab.key === 'string' && tab.key && tab.component;

const applyOrdering = ( base, extras ) => {
const list = [ ...base ];

extras.forEach( ( tab ) => {
// Replace-by-key: addon entries supersede built-ins that share
// a key, letting the addon override Settings/Addons/Support
// outright when that's the intent.
const existing = list.findIndex( ( t ) => t.key === tab.key );
if ( existing !== -1 ) {
list.splice( existing, 1 );
}

if ( tab.before ) {
const idx = list.findIndex( ( t ) => t.key === tab.before );
if ( idx !== -1 ) {
list.splice( idx, 0, tab );
return;
}
}

if ( tab.after ) {
const idx = list.findIndex( ( t ) => t.key === tab.after );
if ( idx !== -1 ) {
list.splice( idx + 1, 0, tab );
return;
}
}

list.push( tab );
} );

return list;
};

/**
* Resolve the ordered tab registry.
*
* Called from `AdminApp` on render — applying the filter at module
* import time would race against addon bundles that haven't yet had
* a chance to `addFilter`, so we defer the lookup until React asks
* for it. Same pattern as `loggedin.settings.panels`.
*
* @return {Object} Tabs keyed by `key`, preserving insertion order.
*/
export const resolveTabs = () => {
const ordered = applyOrdering(
builtIn,
applyFilters( 'loggedin.admin.tabs', [] ).filter( isValid )
);

// Preserve the legacy `{ key: tab }` shape that `AdminApp` reads —
// callers iterate `Object.keys()` for the tab order, so keep the
// insertion order from `ordered`.
return Object.fromEntries(
ordered.map( ( { key, ...rest } ) => [ key, rest ] )
);
};

export default tabs;
export default resolveTabs;
1 change: 1 addition & 0 deletions assets/src/styles/admin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
@use 'ui/footer';
@use 'ui/addons';
@use 'ui/notices';
@use 'ui/cross-sell';
23 changes: 23 additions & 0 deletions assets/src/styles/ui/_cross-sell.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Cross-sell notices — shared `.loggedin-cross-sell` wrapper used by
* settings panels that promote an addon. Mirrors the 404-to-301
* plugin's `.d404-cross-sell` styling so the spacing reads the same
* across our product family.
*
* The wrapper is a `PanelRow`; the `Notice` inside it carries the
* status/dismiss chrome. Flatten the title's top margin so it sits
* flush with the top of the notice, and drop the trailing margin on
* the last content element so the notice isn't padded taller than
* its copy needs.
*/
.loggedin-cross-sell {
.components-notice__content {
.loggedin-cross-sell__title {
margin-top: 0;
}

> :last-child {
margin-bottom: 0;
}
}
}
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "loggedin/loggedin",
"description": "Limit an account to a specific number of simultaneous logins across all devices.",
"version": "3.0.0",
"version": "3.0.1",
"homepage": "https://wordpress.org/plugins/loggedin/",
"license": "GPL-2.0+",
"type": "wordpress-plugin",
Expand Down
Loading
Loading