diff --git a/assets/images/august2026-promo-admins.png b/assets/images/august2026-promo-admins.png new file mode 100644 index 000000000000..eaf6fe7f5437 Binary files /dev/null and b/assets/images/august2026-promo-admins.png differ diff --git a/assets/images/august2026-promo-employees.png b/assets/images/august2026-promo-employees.png new file mode 100644 index 000000000000..bf3c7518db38 Binary files /dev/null and b/assets/images/august2026-promo-employees.png differ diff --git a/assets/images/july26-promo.png b/assets/images/july26-promo.png deleted file mode 100644 index 6a873fd5c244..000000000000 Binary files a/assets/images/july26-promo.png and /dev/null differ diff --git a/src/components/ProductMarketingWindow/ProductMarketingWindow.tsx b/src/components/ProductMarketingWindow/ProductMarketingWindow.tsx index bc6048a5d166..c19e67bc5625 100644 --- a/src/components/ProductMarketingWindow/ProductMarketingWindow.tsx +++ b/src/components/ProductMarketingWindow/ProductMarketingWindow.tsx @@ -26,6 +26,9 @@ type ProductMarketingWindowProps = { /** Resolved illustration asset for illustration-backed variants. Typed optional to match ImageSVG's src. */ illustration: IconAsset | undefined; + /** Whether the CTA must wait for its destination data to settle. */ + isCtaDisabled?: boolean; + /** Called when the primary CTA is pressed. */ onCtaPress: () => void; @@ -33,7 +36,7 @@ type ProductMarketingWindowProps = { onDismiss: () => void; }; -function ProductMarketingWindow({variant, illustration, onCtaPress, onDismiss}: ProductMarketingWindowProps) { +function ProductMarketingWindow({variant, illustration, isCtaDisabled = false, onCtaPress, onDismiss}: ProductMarketingWindowProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout, isExtraSmallScreenHeight, isInLandscapeMode} = useResponsiveLayout(); @@ -116,6 +119,7 @@ function ProductMarketingWindow({variant, illustration, onCtaPress, onDismiss}: variant={CONST.BUTTON_VARIANT.SUCCESS} size={buttonSize} style={styles.flex1} + isDisabled={isCtaDisabled} onPress={onCtaPress} sentryLabel={CONST.SENTRY_LABEL.PRODUCT_MARKETING_WINDOW.CTA} testID="ProductMarketingWindowCTA" diff --git a/src/components/ProductMarketingWindow/ProductMarketingWindowManager.tsx b/src/components/ProductMarketingWindow/ProductMarketingWindowManager.tsx index b4393abd3d98..820fcb677a10 100644 --- a/src/components/ProductMarketingWindow/ProductMarketingWindowManager.tsx +++ b/src/components/ProductMarketingWindow/ProductMarketingWindowManager.tsx @@ -1,6 +1,8 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePolicyConnectionsPrefetch from '@hooks/usePolicyConnectionsPrefetch'; import {setNameValuePair} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -46,6 +48,7 @@ type ProductMarketingWindowManagerProps = { */ function ProductMarketingWindowManager({topmostRouteName}: ProductMarketingWindowManagerProps) { const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); const [activeAdminPolicies, activeAdminPoliciesMetadata] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: (policies: OnyxCollection) => activeAdminPoliciesSelector(policies, currentUserLogin), }); @@ -64,12 +67,27 @@ function ProductMarketingWindowManager({topmostRouteName}: ProductMarketingWindo // assets when the requested names change after mount (e.g. when the audience flips after policies arrive). const illustrationNames = announcement ? [announcement.admin.visual, announcement.member?.visual].flatMap((visual) => (visual?.type === 'illustration' ? [visual.name] : [])) : []; const illustrations = useMemoizedLazyIllustrations(illustrationNames); - const targetAdminPolicyID = activeAdminPolicies?.find((policy) => policy.id === activePolicyID)?.id ?? activeAdminPolicies?.at(0)?.id; - const variant = getProductMarketingAnnouncementVariant(announcement, !!targetAdminPolicyID, lastDismissedMarketingWindow); + const targetAdminPolicy = activeAdminPolicies?.find((policy) => policy.id === activePolicyID) ?? activeAdminPolicies?.at(0); + const variant = getProductMarketingAnnouncementVariant(announcement, !!targetAdminPolicy, lastDismissedMarketingWindow); + const isMemberVariantUnavailable = variant === announcement?.member && !isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); + const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); + const shouldPrefetchTargetPolicyConnections = isVendorMatchingBetaEnabled && !!targetAdminPolicy && targetAdminPolicy.id !== activePolicyID; + const {isFetchNeeded, isLoadingFetchedFlag, hasBeenFetched} = usePolicyConnectionsPrefetch(targetAdminPolicy, shouldPrefetchTargetPolicyConnections); + const isAdminCtaPending = shouldPrefetchTargetPolicyConnections && (isLoadingFetchedFlag || (isFetchNeeded && hasBeenFetched === undefined)); + const isAdminPolicyConnectionDataAvailable = !shouldPrefetchTargetPolicyConnections || hasBeenFetched === true; const isCoveredByCenteredModalScreen = !!topmostRouteName && CENTERED_MODAL_SCREEN_NAVIGATORS.has(topmostRouteName); const isLoading = isLoadingOnyxValue(lastDismissedMarketingWindowMetadata, activeAdminPoliciesMetadata, activePolicyIDMetadata, isLoadingAppMetadata, accountMetadata) || isLoadingApp; - if (!announcement || !variant || isLoading || isProductMarketingWindowCovered || isAnonymousSession || isActingAsDelegate || isCoveredByCenteredModalScreen) { + if ( + !announcement || + !variant || + isMemberVariantUnavailable || + isLoading || + isProductMarketingWindowCovered || + isAnonymousSession || + isActingAsDelegate || + isCoveredByCenteredModalScreen + ) { return null; } @@ -82,15 +100,25 @@ function ProductMarketingWindowManager({topmostRouteName}: ProductMarketingWindo }; const completeCta = () => { + if (isAdminCtaPending) { + return; + } // Record the dismissal before navigating so the window doesn't flash again during navigation. persistDismissal(); - Navigation.navigate(variant.getCtaRoute(targetAdminPolicyID)); + Navigation.navigate( + variant.getCtaRoute({ + adminPolicy: targetAdminPolicy, + isVendorMatchingBetaEnabled, + isAdminPolicyConnectionDataAvailable, + }), + ); }; return ( diff --git a/src/languages/de.ts b/src/languages/de.ts index 635638daa3ab..f63de4c998e8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10364,8 +10364,13 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc productMarketingWindow: { roleTypes: { admin: { - heading: 'Neue Rollentypen für Admins', - body: 'Geben Sie Ihrem Team detailliertere Berechtigungen mit neuen Administratorrollen für Karten, Personen und Zahlungen.', + heading: 'Verbesserte Lieferantenzuordnung', + body: 'Erstelle Lieferanten und benutzerdefinierte Regeln, um sie einfach den wichtigsten Buchhaltungssystemen zuzuordnen.', + cta: 'Ausprobieren', + }, + member: { + heading: 'Vorgefertigte Agenten für dich', + body: 'Verwende vorgefertigte oder benutzerdefinierte Agenten, um Ausgaben in deinem Namen automatisch zu kontieren, aufzuteilen und einzureichen.', cta: 'Ausprobieren', }, }, diff --git a/src/languages/el.ts b/src/languages/el.ts index d90403cca7b6..86a4cf468907 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10920,8 +10920,13 @@ ${reportName}`, productMarketingWindow: { roleTypes: { admin: { - heading: 'Νέοι τύποι ρόλων για διαχειριστές', - body: 'Δώστε στην ομάδα σας πιο λεπτομερή δικαιώματα με νέους ρόλους διαχειριστή καρτών, ατόμων και πληρωμών.', + heading: 'Βελτιωμένη αντιστοίχιση προμηθευτών', + body: 'Δημιουργήστε προμηθευτές και προσαρμοσμένους κανόνες για εύκολη αντιστοίχιση με τα κυριότερα λογιστικά συστήματα.', + cta: 'Δοκιμάστε το', + }, + member: { + heading: 'Προκατασκευασμένοι πράκτορες για εσάς', + body: 'Χρησιμοποιήστε προκατασκευασμένους ή προσαρμοσμένους πράκτορες για να κωδικοποιούν, να διαχωρίζουν και να υποβάλλουν αυτόματα έξοδα εκ μέρους σας.', cta: 'Δοκιμάστε το', }, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 4634fd9386ed..501b0d321efc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10521,8 +10521,13 @@ const translations = { productMarketingWindow: { roleTypes: { admin: { - heading: 'New role types for admins', - body: 'Give your team more granular permissions with new card, people, and payments admin roles.', + heading: 'Enhanced vendor mapping', + body: 'Create vendors and custom rules for easy mapping to major accounting packages.', + cta: 'Try it out', + }, + member: { + heading: 'Pre-built agents for you', + body: 'Use pre-built or custom agents to code, split, and submit expenses automatically on your behalf.', cta: 'Try it out', }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 154b29106a74..2191632395e1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10561,8 +10561,13 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, productMarketingWindow: { roleTypes: { admin: { - heading: 'Nuevos tipos de roles para admins', - body: 'Ofrece a tu equipo permisos más granulares con los nuevos roles de administrador de tarjetas, personas y pagos.', + heading: 'Mapeo de proveedores mejorado', + body: 'Crea proveedores y reglas personalizadas para asignarlos fácilmente a los principales sistemas de contabilidad.', + cta: 'Pruébalo', + }, + member: { + heading: 'Agentes prediseñados para ti', + body: 'Usa agentes prediseñados o personalizados para codificar, dividir y enviar gastos automáticamente en tu nombre.', cta: 'Pruébalo', }, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d5fb36e85b25..d12dee9aae61 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10397,8 +10397,13 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e productMarketingWindow: { roleTypes: { admin: { - heading: 'Nouveaux types de rôles pour les administrateurs', - body: 'Offrez à votre équipe des autorisations plus granulaires avec de nouveaux rôles d’administrateur pour les cartes, les personnes et les paiements.', + heading: 'Mise en correspondance améliorée des fournisseurs', + body: 'Créez des fournisseurs et des règles personnalisées pour les associer facilement aux principaux logiciels de comptabilité.', + cta: 'Essayer', + }, + member: { + heading: 'Agents préconfigurés pour vous', + body: 'Utilisez des agents préconfigurés ou personnalisés pour coder, fractionner et soumettre automatiquement des dépenses en votre nom.', cta: 'Essayer', }, }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 8e1b563d788f..aa52de7d4063 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10335,8 +10335,13 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, productMarketingWindow: { roleTypes: { admin: { - heading: 'Nuovi tipi di ruolo per gli amministratori', - body: 'Offri al tuo team autorizzazioni più granulari con i nuovi ruoli di amministratore per carte, persone e pagamenti.', + heading: 'Mappatura avanzata dei fornitori', + body: 'Crea fornitori e regole personalizzate per associarli facilmente ai principali software di contabilità.', + cta: 'Provalo', + }, + member: { + heading: 'Agenti predefiniti per te', + body: 'Usa agenti predefiniti o personalizzati per codificare, suddividere e inviare automaticamente le spese per tuo conto.', cta: 'Provalo', }, }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 283c302e0312..4b76cdbb065b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10198,8 +10198,13 @@ ${reportName}`, productMarketingWindow: { roleTypes: { admin: { - heading: '管理者向けの新しいロールタイプ', - body: '新しいカード、メンバー、支払いの各管理者ロールで、チームにより細かな権限を付与できます。', + heading: 'ベンダーマッピングの強化', + body: '主要な会計システムに簡単にマッピングできるよう、ベンダーとカスタムルールを作成します。', + cta: '試してみる', + }, + member: { + heading: 'あなたのための構築済みエージェント', + body: '構築済みまたはカスタムのエージェントを使用して、経費のコード付け、分割、提出をあなたに代わって自動化します。', cta: '試してみる', }, }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c313ee7d3e65..d3028385385d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10305,8 +10305,13 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, productMarketingWindow: { roleTypes: { admin: { - heading: 'Nieuwe roltypen voor beheerders', - body: 'Geef je team meer gedetailleerde toegangsrechten met nieuwe kaart-, personen- en betalingsbeheerrollen.', + heading: 'Verbeterde leverancierskoppeling', + body: 'Maak leveranciers en aangepaste regels aan om ze eenvoudig aan belangrijke boekhoudpakketten te koppelen.', + cta: 'Probeer het uit', + }, + member: { + heading: 'Vooraf gebouwde agents voor jou', + body: 'Gebruik vooraf gebouwde of aangepaste agents om uitgaven automatisch namens jou te coderen, splitsen en indienen.', cta: 'Probeer het uit', }, }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ad7bfa61e49f..e12cd8ec3e9e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10274,9 +10274,14 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, productMarketingWindow: { roleTypes: { admin: { - heading: 'Nowe typy ról dla administratorów', - body: 'Daj swojemu zespołowi bardziej szczegółowe uprawnienia dzięki nowym rolom administratora kart, osób i płatności.', - cta: 'Wypróbuj to', + heading: 'Ulepszone mapowanie dostawców', + body: 'Twórz dostawców i niestandardowe reguły, aby łatwo mapować ich do najpopularniejszych systemów księgowych.', + cta: 'Wypróbuj', + }, + member: { + heading: 'Gotowi agenci dla Ciebie', + body: 'Używaj gotowych lub niestandardowych agentów, aby automatycznie kodować, dzielić i przesyłać wydatki w Twoim imieniu.', + cta: 'Wypróbuj', }, }, }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ebb2357903b4..475058eca1a0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10298,8 +10298,13 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, productMarketingWindow: { roleTypes: { admin: { - heading: 'Novos tipos de função para administradores', - body: 'Dê à sua equipe permissões mais granulares com novos papéis de administrador para cartões, pessoas e pagamentos.', + heading: 'Mapeamento aprimorado de fornecedores', + body: 'Crie fornecedores e regras personalizadas para mapeá-los facilmente nos principais sistemas de contabilidade.', + cta: 'Experimente', + }, + member: { + heading: 'Agentes prontos para você', + body: 'Use agentes prontos ou personalizados para codificar, dividir e enviar despesas automaticamente em seu nome.', cta: 'Experimente', }, }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index cbe4a0c5063b..46f8e62a906c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9949,9 +9949,14 @@ ${reportName}`, productMarketingWindow: { roleTypes: { admin: { - heading: '新的管理员角色类型', - body: '通过新增卡片、成员和付款管理员角色,为你的团队提供更精细的权限控制。', - cta: '试用一下', + heading: '增强的供应商映射', + body: '创建供应商和自定义规则,以便轻松映射到主流会计软件。', + cta: '试用', + }, + member: { + heading: '为你预构建的智能代理', + body: '使用预构建或自定义智能代理,代表你自动对费用进行编码、拆分和提交。', + cta: '试用', }, }, }, diff --git a/src/libs/ProductMarketingWindowUtils.ts b/src/libs/ProductMarketingWindowUtils.ts index 08aafcc48640..91e45482f6c0 100644 --- a/src/libs/ProductMarketingWindowUtils.ts +++ b/src/libs/ProductMarketingWindowUtils.ts @@ -1,14 +1,18 @@ -import July26PromoImage from '@assets/images/july26-promo.png'; +import August2026PromoAdminsImage from '@assets/images/august2026-promo-admins.png'; +import August2026PromoEmployeesImage from '@assets/images/august2026-promo-employees.png'; import type {IllustrationName} from '@components/Icon/IllustrationLoader'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; import type {ImageSourcePropType} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {hasVendorFeature} from './PolicyUtils'; + type ProductMarketingAnnouncementVisual = | { type: 'image'; @@ -19,6 +23,17 @@ type ProductMarketingAnnouncementVisual = name: IllustrationName; }; +type ProductMarketingCtaContext = { + /** Admin workspace selected for the announcement. Undefined for member variants. */ + adminPolicy?: Policy; + + /** Whether the vendor-matching beta is enabled for the current account. */ + isVendorMatchingBetaEnabled: boolean; + + /** Whether connection hydration completed for the selected admin workspace. */ + isAdminPolicyConnectionDataAvailable: boolean; +}; + /** One audience-specific content variant of a product marketing announcement. All content is authored by marketing per release. */ type ProductMarketingAnnouncementVariant = { /** Marketing-supplied product screenshot or fallback illustration shown at the top of the window. */ @@ -33,8 +48,8 @@ type ProductMarketingAnnouncementVariant = { /** Label of the primary CTA button. */ ctaLabel: TranslationPaths; - /** Builds the route the primary CTA navigates to. Admin announcements receive the target workspace ID. */ - getCtaRoute: (adminPolicyID?: string) => Route; + /** Builds the route the primary CTA navigates to using the selected audience and workspace context. */ + getCtaRoute: (context: ProductMarketingCtaContext) => Route; }; /** A single product marketing announcement with audience-targeted content variants. */ @@ -55,13 +70,25 @@ type ProductMarketingAnnouncement = { * announcement is dismissed, nothing is shown until a later release replaces it with a new update key. */ const ACTIVE_PRODUCT_MARKETING_ANNOUNCEMENT: ProductMarketingAnnouncement | null = { - updateKey: 'productUpdateJuly2026', + updateKey: 'productUpdateAugust2026', admin: { - visual: {type: 'image', source: July26PromoImage}, + visual: {type: 'image', source: August2026PromoAdminsImage}, heading: 'productMarketingWindow.roleTypes.admin.heading', body: 'productMarketingWindow.roleTypes.admin.body', ctaLabel: 'productMarketingWindow.roleTypes.admin.cta', - getCtaRoute: (adminPolicyID) => ROUTES.WORKSPACE_MEMBERS.getRoute(adminPolicyID), + getCtaRoute: ({adminPolicy, isVendorMatchingBetaEnabled, isAdminPolicyConnectionDataAvailable}) => { + if (isAdminPolicyConnectionDataAvailable && hasVendorFeature(adminPolicy, isVendorMatchingBetaEnabled)) { + return ROUTES.WORKSPACE_VENDORS.getRoute(adminPolicy?.id); + } + return ROUTES.WORKSPACE_MORE_FEATURES.getRoute(adminPolicy?.id); + }, + }, + member: { + visual: {type: 'image', source: August2026PromoEmployeesImage}, + heading: 'productMarketingWindow.roleTypes.member.heading', + body: 'productMarketingWindow.roleTypes.member.body', + ctaLabel: 'productMarketingWindow.roleTypes.member.cta', + getCtaRoute: () => ROUTES.SETTINGS_AGENTS_NEW.getRoute(), }, }; diff --git a/tests/unit/ProductMarketingWindowTest.tsx b/tests/unit/ProductMarketingWindowTest.tsx index 5bdd5a587a84..efe4471c9a16 100644 --- a/tests/unit/ProductMarketingWindowTest.tsx +++ b/tests/unit/ProductMarketingWindowTest.tsx @@ -1,6 +1,7 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import July26PromoImage from '@assets/images/july26-promo.png'; +import August2026PromoAdminsImage from '@assets/images/august2026-promo-admins.png'; +import August2026PromoEmployeesImage from '@assets/images/august2026-promo-employees.png'; import ComposeProviders from '@components/ComposeProviders'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; @@ -11,9 +12,11 @@ import ProductMarketingWindowManager from '@components/ProductMarketingWindow/Pr import ThemeProvider from '@components/ThemeProvider'; import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; +import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import {openPolicyAccountingPage} from '@libs/actions/PolicyConnections'; import {setNameValuePair} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import {ACTIVE_PRODUCT_MARKETING_ANNOUNCEMENT} from '@libs/ProductMarketingWindowUtils'; @@ -26,12 +29,15 @@ import CONST from '@src/CONST'; import en from '@src/languages/en'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Policy} from '@src/types/onyx'; +import type {Connections} from '@src/types/onyx/Policy'; import React from 'react'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; import {buildPersonalDetails} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -39,10 +45,15 @@ const POLICY_ID = 'product-marketing-policy'; const SECOND_POLICY_ID = 'second-product-marketing-policy'; const USER_EMAIL = 'user@example.com'; const USER_ACCOUNT_ID = 7; -const OLDER_UPDATE_KEY = 'productUpdateJune2026'; +const OLDER_UPDATE_KEY = 'productUpdateJuly2026'; jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/useSafeAreaPaddings', () => jest.fn()); +jest.mock('@hooks/useNetwork', () => jest.fn()); + +jest.mock('@libs/actions/PolicyConnections', () => ({ + openPolicyAccountingPage: jest.fn(), +})); jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -67,12 +78,17 @@ if (!announcement) { } const mockSetNameValuePair = jest.mocked(setNameValuePair); const mockNavigate = jest.mocked(Navigation.navigate); +const mockOpenPolicyAccountingPage = jest.mocked(openPolicyAccountingPage); +const mockUseNetwork = jest.mocked(useNetwork); const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); const mockUseSafeAreaPaddings = jest.mocked(useSafeAreaPaddings); const adminHeading = en.productMarketingWindow.roleTypes.admin.heading; const adminBody = en.productMarketingWindow.roleTypes.admin.body; const adminCtaLabel = en.productMarketingWindow.roleTypes.admin.cta; +const memberHeading = en.productMarketingWindow.roleTypes.member.heading; +const memberBody = en.productMarketingWindow.roleTypes.member.body; +const memberCtaLabel = en.productMarketingWindow.roleTypes.member.cta; function buildAdminPolicy(policyID = POLICY_ID): Policy { return { @@ -92,6 +108,20 @@ function buildAdminPolicy(policyID = POLICY_ID): Policy { } as Policy; } +function buildVendorEnabledAdminPolicy(policyID = POLICY_ID): Policy { + return { + ...buildAdminPolicy(policyID), + areConnectionsEnabled: true, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + }, + }, + }), + }; +} + const renderManager = (topmostRouteName?: string, theme: ThemePreferenceWithoutSystem = CONST.THEME.LIGHT) => render( @@ -103,13 +133,16 @@ const renderManager = (topmostRouteName?: string, theme: ThemePreferenceWithoutS , ); -async function setupOnyxBaseline({isAdmin, activePolicyID = POLICY_ID}: {isAdmin: boolean; activePolicyID?: string}) { +async function setupOnyxBaseline({isAdmin, activePolicyID = POLICY_ID, initializeBetas = true}: {isAdmin: boolean; activePolicyID?: string; initializeBetas?: boolean}) { await Onyx.clear(); await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { [USER_ACCOUNT_ID]: buildPersonalDetails(USER_EMAIL, USER_ACCOUNT_ID, 'User'), }); await Onyx.merge(ONYXKEYS.SESSION, {email: USER_EMAIL, accountID: USER_ACCOUNT_ID}); + if (initializeBetas) { + await Onyx.set(ONYXKEYS.BETAS, []); + } if (isAdmin) { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildAdminPolicy()); await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, activePolicyID); @@ -122,6 +155,7 @@ describe('ProductMarketingWindowManager', () => { }); beforeEach(() => { + mockUseNetwork.mockReturnValue({isOffline: false}); mockUseResponsiveLayout.mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE}); mockUseSafeAreaPaddings.mockReturnValue({ paddingTop: 0, @@ -140,16 +174,38 @@ describe('ProductMarketingWindowManager', () => { }); }); - it('renders nothing for a user without an admin role on any workspace', async () => { + it('shows the member variant for a user without an admin role on any workspace', async () => { await act(async () => { await setupOnyxBaseline({isAdmin: false}); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CUSTOM_AGENT]); await waitForBatchedUpdatesWithAct(); }); renderManager(); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByTestId('ProductMarketingWindow')).toBeNull(); + expect(screen.getByText(memberHeading)).toBeTruthy(); + expect(screen.getByText(memberBody)).toBeTruthy(); + expect(screen.UNSAFE_getByType(Image).props.source).toBe(August2026PromoEmployeesImage); + }); + + it('does not show the member variant until the Custom Agent beta is available', async () => { + await act(async () => { + await setupOnyxBaseline({isAdmin: false, initializeBetas: false}); + await waitForBatchedUpdatesWithAct(); + }); + + renderManager(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(memberHeading)).toBeNull(); + + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CUSTOM_AGENT]); + await waitForBatchedUpdatesWithAct(); + }); + + expect(screen.getByText(memberHeading)).toBeTruthy(); }); it('shows the admin variant when the user administers at least one active workspace', async () => { @@ -163,7 +219,7 @@ describe('ProductMarketingWindowManager', () => { expect(screen.getByText(adminHeading)).toBeTruthy(); expect(screen.getByText(adminBody)).toBeTruthy(); - expect(screen.UNSAFE_getByType(Image).props.source).toBe(July26PromoImage); + expect(screen.UNSAFE_getByType(Image).props.source).toBe(August2026PromoAdminsImage); }); it('renders nothing on startup when the active update key was already dismissed', async () => { @@ -497,7 +553,7 @@ describe('ProductMarketingWindowManager', () => { expect(mockSetNameValuePair).toHaveBeenCalledTimes(1); expect(mockSetNameValuePair).toHaveBeenCalledWith(ONYXKEYS.NVP_LAST_DISMISSED_MARKETING_WINDOW, announcement.updateKey, OLDER_UPDATE_KEY); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(announcement.admin.getCtaRoute(POLICY_ID)); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(POLICY_ID)); const dismissCallOrder = mockSetNameValuePair.mock.invocationCallOrder.at(0) ?? Number.NaN; const navigateCallOrder = mockNavigate.mock.invocationCallOrder.at(0) ?? Number.NaN; @@ -506,6 +562,23 @@ describe('ProductMarketingWindowManager', () => { expect(screen.queryByText(adminHeading)).toBeNull(); }); + it('routes an enabled Vendors CTA to the active admin workspace', async () => { + await act(async () => { + await setupOnyxBaseline({isAdmin: true}); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.VENDOR_MATCHING]); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildVendorEnabledAdminPolicy()); + await waitForBatchedUpdatesWithAct(); + }); + + renderManager(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(adminCtaLabel)); + await waitForBatchedUpdatesWithAct(); + + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_VENDORS.getRoute(POLICY_ID)); + }); + it('routes the CTA to the active admin workspace when the user administers multiple workspaces', async () => { await act(async () => { await setupOnyxBaseline({isAdmin: true, activePolicyID: SECOND_POLICY_ID}); @@ -519,22 +592,96 @@ describe('ProductMarketingWindowManager', () => { fireEvent.press(screen.getByText(adminCtaLabel)); await waitForBatchedUpdatesWithAct(); - expect(mockNavigate).toHaveBeenCalledWith(announcement.admin.getCtaRoute(SECOND_POLICY_ID)); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(SECOND_POLICY_ID)); }); - it('falls back to the first eligible admin workspace when the active workspace is not administered by the user', async () => { + it('waits for fallback workspace connections, then uses the hydrated Vendors route', async () => { await act(async () => { await setupOnyxBaseline({isAdmin: true, activePolicyID: 'non-admin-policy'}); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.VENDOR_MATCHING]); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {...buildAdminPolicy(), areConnectionsEnabled: true}); await waitForBatchedUpdatesWithAct(); }); renderManager(); await waitForBatchedUpdatesWithAct(); + expect(mockOpenPolicyAccountingPage).toHaveBeenCalledWith(POLICY_ID); + expect(screen.getByText(adminCtaLabel)).toBeDisabled(); fireEvent.press(screen.getByText(adminCtaLabel)); await waitForBatchedUpdatesWithAct(); + expect(mockNavigate).not.toHaveBeenCalled(); + + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildVendorEnabledAdminPolicy()); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${POLICY_ID}`, true); + await waitForBatchedUpdatesWithAct(); + }); + + expect(screen.getByText(adminCtaLabel)).not.toBeDisabled(); + fireEvent.press(screen.getByText(adminCtaLabel)); + await waitForBatchedUpdatesWithAct(); + + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_VENDORS.getRoute(POLICY_ID)); + }); + + it('uses More Features without fetching fallback workspace connections when Vendor Matching beta is disabled', async () => { + await act(async () => { + await setupOnyxBaseline({isAdmin: true, activePolicyID: 'non-admin-policy'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {...buildAdminPolicy(), areConnectionsEnabled: true}); + await waitForBatchedUpdatesWithAct(); + }); + + renderManager(); + await waitForBatchedUpdatesWithAct(); + + expect(mockOpenPolicyAccountingPage).not.toHaveBeenCalled(); + expect(screen.getByText(adminCtaLabel)).not.toBeDisabled(); + fireEvent.press(screen.getByText(adminCtaLabel)); + await waitForBatchedUpdatesWithAct(); + + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(POLICY_ID)); + }); + + it.each([ + ['offline', true, undefined], + ['failed', false, false], + ] as const)('uses More Features for a fallback workspace when connection hydration is %s', async (_state, isOffline, hasBeenFetched) => { + mockUseNetwork.mockReturnValue({isOffline}); + await act(async () => { + await setupOnyxBaseline({isAdmin: true, activePolicyID: 'non-admin-policy'}); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.VENDOR_MATCHING]); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildVendorEnabledAdminPolicy()); + if (hasBeenFetched !== undefined) { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${POLICY_ID}`, hasBeenFetched); + } + await waitForBatchedUpdatesWithAct(); + }); + + renderManager(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(adminCtaLabel)).not.toBeDisabled(); + fireEvent.press(screen.getByText(adminCtaLabel)); + await waitForBatchedUpdatesWithAct(); + + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(POLICY_ID)); + }); + + it('routes the member CTA to the new Agents page', async () => { + await act(async () => { + await setupOnyxBaseline({isAdmin: false}); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CUSTOM_AGENT]); + await waitForBatchedUpdatesWithAct(); + }); + + renderManager(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(memberCtaLabel)); + await waitForBatchedUpdatesWithAct(); - expect(mockNavigate).toHaveBeenCalledWith(announcement.admin.getCtaRoute(POLICY_ID)); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_NEW.getRoute()); }); it('shows the window again when a failed persistence request rolls the NVP back to its previous update key', async () => { diff --git a/tests/unit/ProductMarketingWindowUtilsTest.ts b/tests/unit/ProductMarketingWindowUtilsTest.ts index 13523ce5f5bc..b2accadb974c 100644 --- a/tests/unit/ProductMarketingWindowUtilsTest.ts +++ b/tests/unit/ProductMarketingWindowUtilsTest.ts @@ -4,7 +4,7 @@ import type {ProductMarketingAnnouncement} from '@libs/ProductMarketingWindowUti import ROUTES from '@src/ROUTES'; const activeAnnouncement: ProductMarketingAnnouncement = { - updateKey: 'productUpdateJuly2026', + updateKey: 'productUpdateAugust2026', admin: { visual: {type: 'illustration', name: 'Rules'}, heading: 'productMarketingWindow.roleTypes.admin.heading', @@ -25,7 +25,7 @@ const adminOnlyAnnouncement: ProductMarketingAnnouncement = { admin: activeAnnouncement.admin, }; -const OLDER_UPDATE_KEY = 'productUpdateJune2026'; +const OLDER_UPDATE_KEY = 'productUpdateJuly2026'; describe('ProductMarketingWindowUtils', () => { describe('isProductMarketingAnnouncementDismissed', () => {