diff --git a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php
index 6796ec7599..10aab7a2f1 100644
--- a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php
+++ b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php
@@ -120,6 +120,41 @@ public static function get_expiration_time() {
return apply_filters( 'newspack_group_subscription_invite_expiration_time', 30 * DAY_IN_SECONDS );
}
+ /**
+ * Get the expiration window as a human-readable label (e.g. "30 days", "1 hour").
+ *
+ * @return string Localized label.
+ */
+ public static function get_expiration_label() {
+ $seconds = (int) self::get_expiration_time();
+
+ if ( $seconds <= 0 ) {
+ $seconds = 1;
+ }
+
+ if ( $seconds >= WEEK_IN_SECONDS && 0 === $seconds % WEEK_IN_SECONDS ) {
+ $weeks = (int) ( $seconds / WEEK_IN_SECONDS );
+ /* translators: %s: number of weeks. */
+ return sprintf( _n( '%s week', '%s weeks', $weeks, 'newspack-plugin' ), number_format_i18n( $weeks ) );
+ }
+
+ if ( $seconds >= DAY_IN_SECONDS && 0 === $seconds % DAY_IN_SECONDS ) {
+ $days = (int) ( $seconds / DAY_IN_SECONDS );
+ /* translators: %s: number of days. */
+ return sprintf( _n( '%s day', '%s days', $days, 'newspack-plugin' ), number_format_i18n( $days ) );
+ }
+
+ if ( $seconds >= HOUR_IN_SECONDS && 0 === $seconds % HOUR_IN_SECONDS ) {
+ $hours = (int) ( $seconds / HOUR_IN_SECONDS );
+ /* translators: %s: number of hours. */
+ return sprintf( _n( '%s hour', '%s hours', $hours, 'newspack-plugin' ), number_format_i18n( $hours ) );
+ }
+
+ $minutes = max( 1, (int) floor( $seconds / MINUTE_IN_SECONDS ) );
+ /* translators: %s: number of minutes. */
+ return sprintf( _n( '%s minute', '%s minutes', $minutes, 'newspack-plugin' ), number_format_i18n( $minutes ) );
+ }
+
/**
* Check if a group subscription invitation has expired.
* Expiration timestamps are stored as an array map keyed by invite key.
diff --git a/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php b/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php
index ac69c49d29..97133d38b0 100644
--- a/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php
+++ b/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php
@@ -111,8 +111,9 @@ public static function enqueue_assets() {
'change_payment_method_title' => __( 'Change payment method', 'newspack-plugin' ),
'switch_subscription_title' => __( 'Change Subscription', 'newspack-plugin' ),
'invite_link_copied' => __( 'Invite link copied.', 'newspack-plugin' ),
- 'invite_link_regenerated' => __( 'New invite link copied. Previous link is no longer valid.', 'newspack-plugin' ),
- 'invite_link_disabled' => __( 'Invite link disabled.', 'newspack-plugin' ),
+ 'invite_link_regenerated' => __( 'New invite link copied. The old one no longer works.', 'newspack-plugin' ),
+ 'invite_link_copy_failed' => __( 'Couldn\'t copy the invite link to your clipboard. Copy it manually:', 'newspack-plugin' ),
+ 'invite_link_disabled' => __( 'Invite link disabled. You can create a new link any time.', 'newspack-plugin' ),
],
'rest' => [
'base_url' => get_rest_url(),
diff --git a/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php b/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php
index e7725069a6..a5aa0bedd5 100644
--- a/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php
+++ b/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php
@@ -25,6 +25,7 @@
$invite_link = Group_Subscription_Invite::get_link_invite( $subscription, $current_user_id );
$invite_link_url = $invite_link ? Group_Subscription_Invite::get_link_invite_url( $subscription->get_id(), $current_user_id, $invite_link['key'] ) : '';
$is_at_limit = $member_limit > 0 && ( count( $members ) + count( $pending_invites ) ) >= $member_limit;
+$active_tab = ( isset( $_GET['activeTab'] ) && 'invites' === sanitize_key( wp_unslash( $_GET['activeTab'] ) ) ) ? 'invites' : 'members'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
?>
-
-
-
- %d)', 'newspack-plugin' ),
- count( $managers_and_members )
- )
- );
- ?>
-
- |
-
- %d)', 'newspack-plugin' ),
- count( $all_invites )
- )
- );
- ?>
-
-
+
diff --git a/src/my-account/v1/_group-subscriptions.scss b/src/my-account/v1/_group-subscriptions.scss
index 7440a32182..fb88ed4f1e 100644
--- a/src/my-account/v1/_group-subscriptions.scss
+++ b/src/my-account/v1/_group-subscriptions.scss
@@ -1,34 +1,35 @@
.newspack-ui .newspack-my-account__group_subscription {
- &__tabs {
- display: flex;
- gap: 16px;
- margin-bottom: 16px;
- }
- &__content {
- .newspack-my-account__group_subscription__members,
- .newspack-my-account__group_subscription__invites {
- display: none;
- }
- &[data-active-tab="members"] {
- .newspack-my-account__group_subscription__tabs--members {
- font-weight: 600;
- }
- .newspack-my-account__group_subscription__members {
- display: table;
- }
+ &__segmented-control {
+ align-items: flex-start;
+ margin-bottom: var(--newspack-ui-spacer-5);
+
+ .newspack-ui__button {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--newspack-ui-spacer-2);
}
- &[data-active-tab="invites"] {
- .newspack-my-account__group_subscription__tabs--invites {
- font-weight: 600;
- }
- .newspack-my-account__group_subscription__invites {
- display: table;
- }
+ }
+
+ // Enter animation for invite-link controls revealed after the link is created.
+ &__entering {
+ animation: newspack-group-subscription-enter 125ms ease both;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
}
}
+
&__members,
&__invites {
margin-top: var(--newspack-ui-spacer-5);
+
+ // Match populated row height for empty-state cells.
+ td[colspan]::after {
+ content: '';
+ display: inline-block;
+ vertical-align: middle;
+ min-height: var(--newspack-ui-spacer-7);
+ }
}
&__members--actions,
&__invites--actions {
@@ -40,9 +41,20 @@
content: '';
display: inline-block;
vertical-align: middle;
- min-height: 36px;
+ min-height: var(--newspack-ui-spacer-7);
}
}
}
}
}
+
+@keyframes newspack-group-subscription-enter {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/src/my-account/v1/group-subscriptions.js b/src/my-account/v1/group-subscriptions.js
index ce36d60e23..3030d3be3d 100644
--- a/src/my-account/v1/group-subscriptions.js
+++ b/src/my-account/v1/group-subscriptions.js
@@ -12,9 +12,6 @@ domReady( function () {
return;
}
- // Look for the activeTab parameter in the URL and set the active tab accordingly.
- const params = new URLSearchParams( window.location.search );
- const activeTab = params.get( 'activeTab' ) === 'invites' ? 'invites' : 'members';
const subId = parseInt( content.getAttribute( 'data-subscription-id' ), 10 );
const baseUrl = newspackMyAccountV1?.rest?.base_url;
const namespace = newspackMyAccountV1?.rest?.namespaces?.group;
@@ -23,22 +20,25 @@ domReady( function () {
typeof newspackUI?.notices?.createNotice === 'function'
? newspackUI.notices.createNotice
: ( msg, type ) => console.warn( '[group-subscriptions]', type, msg ); // eslint-disable-line no-console
- if ( content ) {
- content.setAttribute( 'data-active-tab', activeTab );
- }
- // Handle tab switching.
- const tabs = [ ...document.querySelectorAll( '.newspack-my-account__group_subscription__tabs a' ) ];
- tabs.forEach( tab => {
- tab.addEventListener( 'click', event => {
- event.preventDefault();
- if ( ! content ) {
- return;
- }
- const tabName = event.currentTarget.getAttribute( 'data-tab' );
- content.setAttribute( 'data-active-tab', tabName );
- } );
- } );
+ // Swap each tab badge between --outline (default) and --secondary (selected).
+ const segmentedControl = content.querySelector( '.newspack-my-account__group_subscription__segmented-control' );
+ if ( segmentedControl ) {
+ const syncBadgeVariants = () => {
+ segmentedControl.querySelectorAll( '.newspack-ui__segmented-control__tabs > .newspack-ui__button' ).forEach( button => {
+ const badge = button.querySelector( '.newspack-ui__badge' );
+ if ( ! badge ) {
+ return;
+ }
+ const isSelected = button.classList.contains( 'selected' );
+ badge.classList.toggle( 'newspack-ui__badge--secondary', isSelected );
+ badge.classList.toggle( 'newspack-ui__badge--outline', ! isSelected );
+ } );
+ };
+ syncBadgeVariants();
+ // `content-selected` fires after `.selected` is toggled, avoiding a click-handler ordering race.
+ segmentedControl.addEventListener( 'content-selected', syncBadgeVariants );
+ }
// Handle invite modal.
const inviteModal = document.getElementById( 'newspack-my-account__group_subscription--invite-member' );
@@ -84,7 +84,18 @@ domReady( function () {
const parent = button.closest( 'li' );
const el = parent || button;
if ( show ) {
+ const wasHidden = el.classList.contains( 'hidden' );
el.classList.remove( 'hidden' );
+ if ( wasHidden ) {
+ el.classList.add( 'newspack-my-account__group_subscription__entering' );
+ if ( parseFloat( getComputedStyle( el ).animationDuration ) > 0 ) {
+ el.addEventListener( 'animationend', () => el.classList.remove( 'newspack-my-account__group_subscription__entering' ), {
+ once: true,
+ } );
+ } else {
+ el.classList.remove( 'newspack-my-account__group_subscription__entering' );
+ }
+ }
} else {
el.classList.add( 'hidden' );
}
@@ -101,15 +112,43 @@ domReady( function () {
await navigator.clipboard.writeText( text );
return true;
} catch ( e ) {
- return false;
+ // Legacy fallback for insecure contexts / blocked clipboard permission.
+ try {
+ const textarea = document.createElement( 'textarea' );
+ textarea.value = text;
+ textarea.setAttribute( 'readonly', '' );
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ document.body.appendChild( textarea );
+ textarea.select();
+ const ok = document.execCommand( 'copy' );
+ document.body.removeChild( textarea );
+ return ok;
+ } catch ( e2 ) {
+ return false;
+ }
}
};
+
+ const copyFailedMessage = url => {
+ const text = newspackMyAccountV1?.labels?.invite_link_copy_failed || "Couldn't copy the invite link to your clipboard. Copy it manually:";
+ return `${ text } ${ url }`;
+ };
+ // Minimum loading duration so the spinner reads as "system thinking" even when the API is instant.
+ const MIN_LOADING_MS = 500;
+ const now = () => ( typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now() );
+ const waitForMinLoading = start => {
+ const elapsed = now() - start;
+ return elapsed < MIN_LOADING_MS ? new Promise( resolve => setTimeout( resolve, MIN_LOADING_MS - elapsed ) ) : Promise.resolve();
+ };
+
const generateLink = async e => {
const el = e.currentTarget;
el.classList.add( 'newspack-ui__button--loading' );
el.setAttribute( 'aria-busy', 'true' );
el.setAttribute( 'disabled', '' );
const errorText = e.currentTarget.getAttribute( 'data-error-text' );
+ const loadingStart = now();
try {
const response = await fetch( restUrl, {
method: 'POST',
@@ -121,20 +160,25 @@ domReady( function () {
body: JSON.stringify( { subscription_id: subId } ),
} );
const data = await response.json();
+ await waitForMinLoading( loadingStart );
if ( ! response.ok || ! data || ! data.url ) {
const message = ( data && data.message ) || errorText;
showSnackbar( message, 'error' );
return;
}
+ const isRegenerate = !! content.getAttribute( 'data-invite-link' );
+ content.setAttribute( 'data-invite-link', data.url );
+ afterInviteLink( true );
if ( await copyToClipboard( data.url ) ) {
- const message = !! content.getAttribute( 'data-invite-link' )
- ? newspackMyAccountV1?.labels?.invite_link_regenerated || 'New invite link copied. Previous link is no longer valid.'
+ const message = isRegenerate
+ ? newspackMyAccountV1?.labels?.invite_link_regenerated || 'New invite link copied. The old one no longer works.'
: newspackMyAccountV1?.labels?.invite_link_copied || 'Invite link copied.';
showSnackbar( message );
+ } else {
+ showSnackbar( copyFailedMessage( data.url ), 'error' );
}
- content.setAttribute( 'data-invite-link', data.url );
- afterInviteLink( true );
} catch ( error ) {
+ await waitForMinLoading( loadingStart );
showSnackbar( errorText, 'error' );
} finally {
el.classList.remove( 'newspack-ui__button--loading' );
@@ -149,6 +193,7 @@ domReady( function () {
el.setAttribute( 'aria-busy', 'true' );
el.setAttribute( 'disabled', '' );
const errorText = e.currentTarget.getAttribute( 'data-error-text' );
+ const loadingStart = now();
try {
const response = await fetch( restUrl, {
method: 'DELETE',
@@ -160,15 +205,17 @@ domReady( function () {
body: JSON.stringify( { subscription_id: subId } ),
} );
const data = await response.json();
+ await waitForMinLoading( loadingStart );
if ( ! response.ok ) {
const message = ( data && data.message ) || errorText;
showSnackbar( message, 'error' );
return;
}
- showSnackbar( newspackMyAccountV1?.labels?.invite_link_disabled || 'Invite link disabled.' );
+ showSnackbar( newspackMyAccountV1?.labels?.invite_link_disabled || 'Invite link disabled. You can create a new link any time.' );
content.removeAttribute( 'data-invite-link' );
afterInviteLink( false );
} catch ( error ) {
+ await waitForMinLoading( loadingStart );
showSnackbar( errorText, 'error' );
} finally {
el.classList.remove( 'newspack-ui__button--loading' );
@@ -184,6 +231,8 @@ domReady( function () {
if ( inviteLink ) {
if ( await copyToClipboard( inviteLink ) ) {
showSnackbar( newspackMyAccountV1?.labels?.invite_link_copied || 'Invite link copied.' );
+ } else {
+ showSnackbar( copyFailedMessage( inviteLink ), 'error' );
}
} else {
generateLink( e );
diff --git a/src/newspack-ui/js/segmented-control/index.js b/src/newspack-ui/js/segmented-control/index.js
index 01df55cf13..7b70798df9 100644
--- a/src/newspack-ui/js/segmented-control/index.js
+++ b/src/newspack-ui/js/segmented-control/index.js
@@ -68,6 +68,18 @@ domReady( function () {
element.dispatchEvent( new CustomEvent( 'content-selected', { detail: selectedContent } ) );
};
+ const isTablist = header && header.getAttribute( 'role' ) === 'tablist';
+ const updateAria = activeIndex => {
+ if ( ! isTablist ) {
+ return;
+ }
+ tab_headers.forEach( ( t, j ) => {
+ const isActive = j === activeIndex;
+ t.setAttribute( 'aria-selected', isActive ? 'true' : 'false' );
+ t.setAttribute( 'tabindex', isActive ? '0' : '-1' );
+ } );
+ };
+
tab_headers.forEach( ( tab, i ) => {
if ( tab_contents.length === 0 ) {
return;
@@ -84,13 +96,37 @@ domReady( function () {
if ( tab.classList.contains( 'selected' ) ) {
select_content( i );
+ updateAria( i );
}
tab.addEventListener( 'click', function () {
tab_headers.forEach( t => t.classList.remove( 'selected' ) );
this.classList.add( 'selected' );
select_content( i );
+ updateAria( i );
} );
+
+ if ( isTablist ) {
+ tab.addEventListener( 'keydown', function ( ev ) {
+ const last = tab_headers.length - 1;
+ let next = -1;
+ if ( ev.key === 'ArrowRight' ) {
+ next = i === last ? 0 : i + 1;
+ } else if ( ev.key === 'ArrowLeft' ) {
+ next = i === 0 ? last : i - 1;
+ } else if ( ev.key === 'Home' ) {
+ next = 0;
+ } else if ( ev.key === 'End' ) {
+ next = last;
+ }
+ if ( next < 0 ) {
+ return;
+ }
+ ev.preventDefault();
+ tab_headers[ next ].focus();
+ tab_headers[ next ].click();
+ } );
+ }
} );
}
diff --git a/tests/unit-tests/content-gate/group-subscriptions.php b/tests/unit-tests/content-gate/group-subscriptions.php
index 58bbd367fd..f3b031777e 100644
--- a/tests/unit-tests/content-gate/group-subscriptions.php
+++ b/tests/unit-tests/content-gate/group-subscriptions.php
@@ -2430,4 +2430,93 @@ public function test_accept_invite_returns_true_for_existing_member_even_with_bo
'Existing member must remain a member after re-clicking an invalid invite'
);
}
+
+ // -------------------------------------------------------------------------
+ // get_expiration_label()
+ // -------------------------------------------------------------------------
+
+ /**
+ * Promote to the largest unit that divides exactly; never round.
+ */
+ public function test_get_expiration_label_uses_largest_exact_unit() {
+ $cases = [
+ 'default (30 days)' => [ 30 * DAY_IN_SECONDS, '30 days' ],
+ '1 week' => [ WEEK_IN_SECONDS, '1 week' ],
+ '2 weeks (14 days)' => [ 14 * DAY_IN_SECONDS, '2 weeks' ],
+ '10 days' => [ 10 * DAY_IN_SECONDS, '10 days' ],
+ '1 day' => [ DAY_IN_SECONDS, '1 day' ],
+ '1 hour' => [ HOUR_IN_SECONDS, '1 hour' ],
+ '2 hours' => [ 2 * HOUR_IN_SECONDS, '2 hours' ],
+ '90 minutes' => [ 90 * MINUTE_IN_SECONDS, '90 minutes' ],
+ '15 minutes' => [ 15 * MINUTE_IN_SECONDS, '15 minutes' ],
+ '61 seconds' => [ 61, '1 minute' ],
+ '90 seconds' => [ 90, '1 minute' ],
+ ];
+
+ foreach ( $cases as $label => $case ) {
+ [ $seconds, $expected ] = $case;
+ $callback = function () use ( $seconds ) {
+ return $seconds;
+ };
+ add_filter( 'newspack_group_subscription_invite_expiration_time', $callback );
+
+ try {
+ $this->assertSame(
+ $expected,
+ Group_Subscription_Invite::get_expiration_label(),
+ "Expected '{$expected}' for case: {$label}"
+ );
+ } finally {
+ remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback );
+ }
+ }
+ }
+
+ /**
+ * Sub-minute values are floored at "1 minute".
+ */
+ public function test_get_expiration_label_floor_is_one_minute() {
+ $callback = function () {
+ return 0;
+ };
+ add_filter( 'newspack_group_subscription_invite_expiration_time', $callback );
+
+ try {
+ $this->assertSame( '1 minute', Group_Subscription_Invite::get_expiration_label() );
+ } finally {
+ remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback );
+ }
+ }
+
+ /**
+ * Negative expiration times are coerced to the "1 minute" floor.
+ */
+ public function test_get_expiration_label_floors_negative_values() {
+ $callback = function () {
+ return -100;
+ };
+ add_filter( 'newspack_group_subscription_invite_expiration_time', $callback );
+
+ try {
+ $this->assertSame( '1 minute', Group_Subscription_Invite::get_expiration_label() );
+ } finally {
+ remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback );
+ }
+ }
+
+ /**
+ * Large counts are formatted via number_format_i18n() (thousands separator under the active locale).
+ */
+ public function test_get_expiration_label_uses_number_format_i18n() {
+ $callback = function () {
+ return 1000 * DAY_IN_SECONDS;
+ };
+ add_filter( 'newspack_group_subscription_invite_expiration_time', $callback );
+
+ try {
+ $this->assertSame( '1,000 days', Group_Subscription_Invite::get_expiration_label() );
+ } finally {
+ remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback );
+ }
+ }
}