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 ?>
-
-

- - | - -

+
+
@@ -289,7 +303,15 @@

- +

@@ -322,10 +344,10 @@

- +

- +
@@ -346,10 +368,10 @@

- +

- +
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 ); + } + } }