From f284c31a37a96e4381bbf143aacec9d9dcf69398 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 5 Jul 2026 00:55:13 -0300 Subject: [PATCH 1/3] feat(consent): in-app marketing email consent prompt + preferences toggle Add the shared consent contract: a nullable marketingEmailConsent profile field, the marketing-consent endpoint, a setMarketingConsent mutation type, an engagement-prompt-store 'consent' kind at top priority with a cooldown-bypassing arming path, and i18n in both locales. Add a one-time consent prompt on web and mobile that shows when marketingEmailConsent is null and onboarding is complete (single-slot arbitration, records markEngagementPrompted on show), and a "Product updates by email" preferences toggle with an optimistic cache update and rollback. Shared changes are additive; the API deploys first. Co-Authored-By: Claude Fable 5 --- .../marketing-consent-prompt.test.tsx | 224 ++++++++++++++++++ .../marketing-consent-section.test.tsx | 138 +++++++++++ apps/mobile/app/_layout.tsx | 11 + apps/mobile/app/preferences-sections.tsx | 5 + .../marketing-consent-prompt.tsx | 194 +++++++++++++++ .../marketing-consent-section.tsx | 59 +++++ apps/mobile/lib/offline-mutations.ts | 1 + apps/mobile/test-mocks/lucide-react-native.ts | 1 + .../marketing-consent-prompt.test.tsx | 181 ++++++++++++++ .../marketing-consent-section.test.tsx | 84 +++++++ apps/web/app/(app)/layout.tsx | 17 ++ .../_components/marketing-consent-section.tsx | 48 ++++ .../_components/preference-settings-list.tsx | 5 + apps/web/app/actions/profile.ts | 10 + .../marketing-consent-prompt.tsx | 159 +++++++++++++ .../__tests__/engagement-prompt-store.test.ts | 53 +++++ packages/shared/src/api/endpoints.ts | 1 + packages/shared/src/i18n/en.json | 14 ++ packages/shared/src/i18n/pt-BR.json | 14 ++ .../src/stores/engagement-prompt-store.ts | 13 +- packages/shared/src/stores/index.ts | 1 + packages/shared/src/types/profile.ts | 7 + packages/shared/src/types/sync.ts | 2 +- 23 files changed, 1238 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx create mode 100644 apps/mobile/__tests__/components/marketing-consent/marketing-consent-section.test.tsx create mode 100644 apps/mobile/components/marketing-consent/marketing-consent-prompt.tsx create mode 100644 apps/mobile/components/marketing-consent/marketing-consent-section.tsx create mode 100644 apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx create mode 100644 apps/web/__tests__/components/marketing-consent/marketing-consent-section.test.tsx create mode 100644 apps/web/app/(app)/preferences/_components/marketing-consent-section.tsx create mode 100644 apps/web/components/marketing-consent/marketing-consent-prompt.tsx diff --git a/apps/mobile/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx b/apps/mobile/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx new file mode 100644 index 000000000..723eff9aa --- /dev/null +++ b/apps/mobile/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx @@ -0,0 +1,224 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})) + +const performQueuedApiMutation = vi.fn().mockResolvedValue(undefined) +vi.mock('@/lib/queued-api-mutation', () => ({ + performQueuedApiMutation: (input: unknown) => performQueuedApiMutation(input), +})) + +const patchProfile = vi.fn() +let profileValue: { marketingEmailConsent: boolean | null } | undefined +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: profileValue, patchProfile }), +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (options: { + mutationFn: (vars: boolean) => Promise + onMutate?: (vars: boolean) => unknown + onError?: (err: unknown, vars: boolean, ctx: unknown) => void + }) => ({ + isPending: false, + mutate: (vars: boolean) => { + const ctx = options.onMutate?.(vars) + Promise.resolve(options.mutationFn(vars)).catch((err) => + options.onError?.(err, vars, ctx), + ) + }, + }), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }), +})) + +vi.mock('@/lib/theme', () => ({ + createTokensV2: () => + new Proxy({}, { get: () => '#ffffff' }) as Record, + tintFromPrimary: () => 'rgba(0,0,0,0.15)', +})) + +vi.mock('@/components/bottom-sheet-modal', () => ({ + BottomSheetModal: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + onClose?: () => void + }) => (open ? React.createElement('BottomSheetOpen', {}, children) : null), +})) + +vi.mock('@/components/ui/pill-button', () => ({ + PillButton: ({ + children, + onPress, + }: { + children: React.ReactNode + onPress?: () => void + }) => React.createElement('PillButtonStub', { onPress }, children), +})) + +import { MarketingConsentPrompt } from '@/components/marketing-consent/marketing-consent-prompt' +import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' +import { MARKETING_CONSENT_MILESTONE_KEY } from '@orbit/shared/stores' + +const TestRenderer = require('react-test-renderer') + +type RenderedNode = { + type: unknown + props: { onPress?: () => void; accessibilityLabel?: string } & Record< + string, + unknown + > +} +type RenderedTree = { + root: { findAll: (predicate: (node: RenderedNode) => boolean) => RenderedNode[] } + unmount: () => void +} + +let currentTree: RenderedTree | null = null + +function findByType(tree: RenderedTree, typeName: string) { + return tree.root.findAll((node) => node.type === typeName) +} + +function resetStores() { + useReferralPromptStore.setState({ + promptedMilestoneKeys: [], + lastPromptedAtIso: null, + homeEntryDismissed: false, + armedPrompt: null, + }) + useUIStore.setState({ activeCelebration: null, queuedCelebrations: [] }) +} + +describe('MarketingConsentPrompt (mobile)', () => { + beforeEach(() => { + vi.useFakeTimers() + resetStores() + patchProfile.mockClear() + performQueuedApiMutation.mockClear() + profileValue = { marketingEmailConsent: null } + }) + + afterEach(() => { + if (currentTree) { + TestRenderer.act(() => currentTree!.unmount()) + currentTree = null + } + vi.clearAllTimers() + vi.useRealTimers() + }) + + async function render() { + await TestRenderer.act(async () => { + currentTree = TestRenderer.create() + await Promise.resolve() + }) + return currentTree! + } + + async function renderArmed() { + const tree = await render() + await TestRenderer.act(async () => { + useReferralPromptStore + .getState() + .armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY) + await Promise.resolve() + }) + return tree + } + + async function settle() { + await TestRenderer.act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + } + + it('renders nothing when no consent prompt is armed', async () => { + const tree = await render() + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + }) + + it('does not render when a different kind is armed', async () => { + const tree = await render() + await TestRenderer.act(async () => { + useReferralPromptStore.getState().armReferralPrompt('level-3') + await Promise.resolve() + }) + await settle() + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + }) + + it('shows after the settle delay and records markEngagementPrompted', async () => { + const tree = await renderArmed() + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + + await settle() + + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(1) + expect(useReferralPromptStore.getState().promptedMilestoneKeys).toContain( + MARKETING_CONSENT_MILESTONE_KEY, + ) + }) + + it('stays hidden while a celebration is in flight', async () => { + useUIStore.getState().enqueueCelebration('streak', { streak: 7 }) + const tree = await renderArmed() + + await TestRenderer.act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + }) + + it('opts in through the offline queue and patches the profile on accept', async () => { + const tree = await renderArmed() + await settle() + + const [accept] = findByType(tree, 'PillButtonStub') + await TestRenderer.act(async () => { + accept!.props.onPress?.() + await Promise.resolve() + }) + + expect(performQueuedApiMutation).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'setMarketingConsent', + scope: 'profile', + payload: { enabled: true }, + dedupeKey: 'profile-marketing-consent', + }), + ) + expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: true }) + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + }) + + it('opts out through the offline queue on decline', async () => { + const tree = await renderArmed() + await settle() + + const [decline] = tree.root.findAll( + (node) => node.props.accessibilityLabel === 'marketingConsent.prompt.decline', + ) + await TestRenderer.act(async () => { + decline!.props.onPress?.() + await Promise.resolve() + }) + + expect(performQueuedApiMutation).toHaveBeenCalledWith( + expect.objectContaining({ payload: { enabled: false } }), + ) + expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: false }) + }) +}) diff --git a/apps/mobile/__tests__/components/marketing-consent/marketing-consent-section.test.tsx b/apps/mobile/__tests__/components/marketing-consent/marketing-consent-section.test.tsx new file mode 100644 index 000000000..52833248d --- /dev/null +++ b/apps/mobile/__tests__/components/marketing-consent/marketing-consent-section.test.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})) + +const performQueuedApiMutation = vi.fn().mockResolvedValue(undefined) +vi.mock('@/lib/queued-api-mutation', () => ({ + performQueuedApiMutation: (input: unknown) => performQueuedApiMutation(input), +})) + +const patchProfile = vi.fn() +let profileValue: { marketingEmailConsent: boolean | null } | undefined +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: profileValue, patchProfile }), +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (options: { + mutationFn: (vars: boolean) => Promise + onMutate?: (vars: boolean) => unknown + onError?: (err: unknown, vars: boolean, ctx: unknown) => void + }) => ({ + isPending: false, + mutate: (vars: boolean) => { + const ctx = options.onMutate?.(vars) + Promise.resolve(options.mutationFn(vars)).catch((err) => + options.onError?.(err, vars, ctx), + ) + }, + }), +})) + +vi.mock('@/components/ui/section-label', () => ({ + SectionLabel: ({ children }: { children: React.ReactNode }) => + React.createElement('SectionLabelStub', {}, children), +})) + +vi.mock('@/components/ui/settings-row', () => ({ + SettingsRow: ({ children }: { children: React.ReactNode }) => + React.createElement('SettingsRowStub', {}, children), + Switch: ({ on, onToggle }: { on: boolean; onToggle: () => void }) => + React.createElement('SwitchStub', { on, onToggle }), +})) + +import { MarketingConsentSection } from '@/components/marketing-consent/marketing-consent-section' + +const TestRenderer = require('react-test-renderer') + +type RenderedNode = { + type: unknown + props: { on?: boolean; onToggle?: () => void } & Record +} +type RenderedTree = { + root: { findAll: (predicate: (node: RenderedNode) => boolean) => RenderedNode[] } + unmount: () => void +} + +let currentTree: RenderedTree | null = null + +function getSwitch(tree: RenderedTree) { + return tree.root.findAll((node) => node.type === 'SwitchStub')[0] +} + +async function render() { + await TestRenderer.act(async () => { + currentTree = TestRenderer.create() + await Promise.resolve() + }) + return currentTree! +} + +describe('MarketingConsentSection (mobile)', () => { + beforeEach(() => { + patchProfile.mockClear() + performQueuedApiMutation.mockClear() + performQueuedApiMutation.mockResolvedValue(undefined) + profileValue = { marketingEmailConsent: null } + }) + + afterEach(() => { + if (currentTree) { + TestRenderer.act(() => currentTree!.unmount()) + currentTree = null + } + }) + + it('reflects consent off when the profile has not opted in', async () => { + profileValue = { marketingEmailConsent: null } + const tree = await render() + expect(getSwitch(tree).props.on).toBe(false) + }) + + it('reflects consent on when the profile opted in', async () => { + profileValue = { marketingEmailConsent: true } + const tree = await render() + expect(getSwitch(tree).props.on).toBe(true) + }) + + it('opts in through the offline queue and patches optimistically', async () => { + profileValue = { marketingEmailConsent: false } + const tree = await render() + + await TestRenderer.act(async () => { + getSwitch(tree).props.onToggle?.() + await Promise.resolve() + }) + + expect(performQueuedApiMutation).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'setMarketingConsent', + scope: 'profile', + payload: { enabled: true }, + dedupeKey: 'profile-marketing-consent', + }), + ) + expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: true }) + }) + + it('rolls the optimistic patch back when the mutation fails', async () => { + profileValue = { marketingEmailConsent: true } + performQueuedApiMutation.mockRejectedValueOnce(new Error('network')) + const tree = await render() + + await TestRenderer.act(async () => { + getSwitch(tree).props.onToggle?.() + await Promise.resolve() + await Promise.resolve() + }) + + expect(patchProfile).toHaveBeenNthCalledWith(1, { marketingEmailConsent: false }) + expect(patchProfile).toHaveBeenLastCalledWith({ marketingEmailConsent: true }) + }) +}) diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index e33691abb..1e7163a2b 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -41,6 +41,7 @@ import { getMilestoneShareStreakKey, getMilestoneShareAchievementKey, getReviewMomentLevelKey, + MARKETING_CONSENT_MILESTONE_KEY, } from '@orbit/shared/stores' import { formatAPIDate, isShareableAchievement } from '@orbit/shared/utils' import { @@ -57,6 +58,7 @@ import { GoalCompletedCelebration } from '@/components/gamification/goal-complet import { LevelUpOverlay } from '@/components/gamification/level-up-overlay' import { ReferralPrompt } from '@/components/referral/referral-prompt' import { MilestoneSharePrompt } from '@/components/milestone-share/milestone-share-prompt' +import { MarketingConsentPrompt } from '@/components/marketing-consent/marketing-consent-prompt' import { ReviewMomentSheet } from '@/components/review-moment/review-moment-sheet' import { StreakCelebration } from '@/components/gamification/streak-celebration' import { @@ -326,7 +328,15 @@ function GlobalOverlays({ (s) => s.armMilestoneSharePrompt, ) const armReviewPrompt = useReferralPromptStore((s) => s.armReviewPrompt) + const armConsentPrompt = useReferralPromptStore((s) => s.armConsentPrompt) const hasCompletedOnboarding = profile?.hasCompletedOnboarding ?? false + const marketingEmailConsent = profile?.marketingEmailConsent + + useEffect(() => { + if (hasCompletedOnboarding && marketingEmailConsent === null) { + armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY) + } + }, [hasCompletedOnboarding, marketingEmailConsent, armConsentPrompt]) useEffect(() => { if (gamification.leveledUp && gamification.newLevel) { @@ -391,6 +401,7 @@ function GlobalOverlays({ onClear={gamification.clearLevelUp} /> ) : null} + diff --git a/apps/mobile/app/preferences-sections.tsx b/apps/mobile/app/preferences-sections.tsx index bb76a53b0..87a225744 100644 --- a/apps/mobile/app/preferences-sections.tsx +++ b/apps/mobile/app/preferences-sections.tsx @@ -15,6 +15,7 @@ import { SectionLabel } from '@/components/ui/section-label' import { SettingsRow, Switch } from '@/components/ui/settings-row' import { RadioRow } from '@/components/ui/select-check' import { ProBadge } from '@/components/ui/pro-badge' +import { MarketingConsentSection } from '@/components/marketing-consent/marketing-consent-section' import { styles, type Tokens } from './preferences-styles' export type PreferencePicker = 'language' | 'theme' | 'scheme' | 'weekStart' @@ -251,6 +252,10 @@ export function PreferenceSettingsList({ /> ) : null} + + + + ) } diff --git a/apps/mobile/components/marketing-consent/marketing-consent-prompt.tsx b/apps/mobile/components/marketing-consent/marketing-consent-prompt.tsx new file mode 100644 index 000000000..22bc9bdff --- /dev/null +++ b/apps/mobile/components/marketing-consent/marketing-consent-prompt.tsx @@ -0,0 +1,194 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Pressable, ScrollView, StyleSheet, Text } from 'react-native' +import Animated, { FadeInDown, ReduceMotion } from 'react-native-reanimated' +import { useTranslation } from 'react-i18next' +import { useMutation } from '@tanstack/react-query' +import { Mail } from 'lucide-react-native' +import { API } from '@orbit/shared/api' +import { MARKETING_CONSENT_MILESTONE_KEY } from '@orbit/shared/stores' +import { BottomSheetModal } from '@/components/bottom-sheet-modal' +import { PillButton } from '@/components/ui/pill-button' +import { createTokensV2, tintFromPrimary } from '@/lib/theme' +import { useAppTheme } from '@/lib/use-app-theme' +import { useProfile } from '@/hooks/use-profile' +import { performQueuedApiMutation } from '@/lib/queued-api-mutation' +import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' + +const SETTLE_DELAY_MS = 500 + +function enterAnimation(delayMs: number) { + return FadeInDown.duration(220).delay(delayMs).reduceMotion(ReduceMotion.System) +} + +/** + * One-time LGPD-lawful marketing-email consent nudge. Shows once onboarding is complete, the + * armed slot holds a consent prompt, and no celebration is in flight — deliberately skipping the + * shared 14-day cooldown while still recording the prompt so other prompts treat it as recent. + * Dismissing leaves consent undecided (null) so it can re-ask; an explicit Yes/No persists the + * decision and the strict `=== null` arming gate prevents it from ever re-showing. + */ +export function MarketingConsentPrompt() { + const { t } = useTranslation() + const { currentScheme, currentTheme } = useAppTheme() + const tokens = useMemo( + () => createTokensV2(currentScheme, currentTheme), + [currentScheme, currentTheme], + ) + const styles = useMemo(() => createStyles(tokens), [tokens]) + const { profile, patchProfile } = useProfile() + const armedPrompt = useReferralPromptStore((s) => s.armedPrompt) + const markEngagementPrompted = useReferralPromptStore( + (s) => s.markEngagementPrompted, + ) + const celebrationInFlight = useUIStore( + (s) => s.activeCelebration !== null || s.queuedCelebrations.length > 0, + ) + + const isArmed = armedPrompt?.kind === 'consent' + const [visible, setVisible] = useState(false) + const settleTimerRef = useRef>(undefined) + + const mutation = useMutation({ + mutationFn: (enabled: boolean) => + performQueuedApiMutation({ + type: 'setMarketingConsent', + scope: 'profile', + endpoint: API.profile.marketingConsent, + method: 'PUT', + payload: { enabled }, + dedupeKey: 'profile-marketing-consent', + }), + onMutate: (enabled) => { + const previous = profile?.marketingEmailConsent ?? null + patchProfile({ marketingEmailConsent: enabled }) + return { previous } + }, + onError: ( + _err: unknown, + _enabled: boolean, + context: { previous?: boolean | null } | undefined, + ) => { + patchProfile({ marketingEmailConsent: context?.previous ?? null }) + }, + }) + + useEffect(() => { + if (visible || !isArmed || celebrationInFlight) return + + settleTimerRef.current = setTimeout(() => { + markEngagementPrompted( + MARKETING_CONSENT_MILESTONE_KEY, + new Date().toISOString(), + ) + setVisible(true) + }, SETTLE_DELAY_MS) + + return () => { + if (settleTimerRef.current) clearTimeout(settleTimerRef.current) + } + }, [isArmed, celebrationInFlight, visible, markEngagementPrompted]) + + function answer(enabled: boolean) { + setVisible(false) + mutation.mutate(enabled) + } + + return ( + setVisible(false)} + title={t('marketingConsent.prompt.title')} + snapPoints={['55%']} + contentManagesScroll + > + + + {t('marketingConsent.prompt.eyebrow')} + + + + + + {t('marketingConsent.prompt.body')} + + + answer(true)}> + {t('marketingConsent.prompt.accept')} + + answer(false)} + accessibilityRole="button" + accessibilityLabel={t('marketingConsent.prompt.decline')} + style={({ pressed }) => [ + styles.laterButton, + pressed ? styles.laterButtonPressed : null, + ]} + > + + {t('marketingConsent.prompt.decline')} + + + + + + ) +} + +function createStyles(tokens: ReturnType) { + return StyleSheet.create({ + scroll: { + flex: 1, + }, + content: { + alignItems: 'center', + paddingHorizontal: 24, + paddingTop: 8, + paddingBottom: 24, + gap: 16, + }, + eyebrow: { + fontFamily: 'Rubik_500Medium', + fontSize: 12, + letterSpacing: 0.96, + textTransform: 'uppercase', + color: tokens.fg3, + }, + heroDisc: { + width: 64, + height: 64, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: tintFromPrimary(tokens, 0.15), + }, + body: { + fontFamily: 'Rubik_400Regular', + fontSize: 15, + lineHeight: 22, + textAlign: 'center', + color: tokens.fg2, + }, + actions: { + alignSelf: 'stretch', + gap: 8, + paddingTop: 4, + }, + laterButton: { + alignItems: 'center', + paddingVertical: 12, + }, + laterButtonPressed: { + opacity: 0.6, + }, + laterText: { + fontFamily: 'Rubik_500Medium', + fontSize: 14, + color: tokens.fg3, + }, + }) +} diff --git a/apps/mobile/components/marketing-consent/marketing-consent-section.tsx b/apps/mobile/components/marketing-consent/marketing-consent-section.tsx new file mode 100644 index 000000000..8900049c6 --- /dev/null +++ b/apps/mobile/components/marketing-consent/marketing-consent-section.tsx @@ -0,0 +1,59 @@ +import { useMutation } from '@tanstack/react-query' +import { Mail } from 'lucide-react-native' +import { useTranslation } from 'react-i18next' +import { API } from '@orbit/shared/api' +import { SectionLabel } from '@/components/ui/section-label' +import { SettingsRow, Switch } from '@/components/ui/settings-row' +import { useProfile } from '@/hooks/use-profile' +import { performQueuedApiMutation } from '@/lib/queued-api-mutation' + +/** Self-contained "Product updates by email" preference row: reflects and optimistically toggles marketing-email consent through the offline queue, rolling back on error. Never Pro-gated. */ +export function MarketingConsentSection() { + const { t } = useTranslation() + const { profile, patchProfile } = useProfile() + const enabled = profile?.marketingEmailConsent === true + + const mutation = useMutation({ + mutationFn: (next: boolean) => + performQueuedApiMutation({ + type: 'setMarketingConsent', + scope: 'profile', + endpoint: API.profile.marketingConsent, + method: 'PUT', + payload: { enabled: next }, + dedupeKey: 'profile-marketing-consent', + }), + onMutate: (next) => { + const previous = profile?.marketingEmailConsent ?? null + patchProfile({ marketingEmailConsent: next }) + return { previous } + }, + onError: ( + _err: unknown, + _next: boolean, + context: { previous?: boolean | null } | undefined, + ) => { + patchProfile({ marketingEmailConsent: context?.previous ?? null }) + }, + }) + + return ( + <> + {t('profile.sections.communication')} + + mutation.mutate(!enabled)} + disabled={mutation.isPending} + accessibilityLabel={t('profile.marketingEmails.title')} + /> + + + ) +} diff --git a/apps/mobile/lib/offline-mutations.ts b/apps/mobile/lib/offline-mutations.ts index 71bf88d10..17b92de75 100644 --- a/apps/mobile/lib/offline-mutations.ts +++ b/apps/mobile/lib/offline-mutations.ts @@ -368,6 +368,7 @@ function inferScope(type: MutationType): MutationScope { case 'setAiMemory': case 'setAiSummary': case 'setProactiveAstra': + case 'setMarketingConsent': case 'completeOnboarding': case 'resetProfile': return 'profile' diff --git a/apps/mobile/test-mocks/lucide-react-native.ts b/apps/mobile/test-mocks/lucide-react-native.ts index 1368782f6..d01a2a0ce 100644 --- a/apps/mobile/test-mocks/lucide-react-native.ts +++ b/apps/mobile/test-mocks/lucide-react-native.ts @@ -44,6 +44,7 @@ export const Home = createIcon('Home') export const Infinity = createIcon('Infinity') export const ListChecks = createIcon('ListChecks') export const Lock = createIcon('Lock') +export const Mail = createIcon('Mail') export const MessageCircle = createIcon('MessageCircle') export const MessageSquare = createIcon('MessageSquare') export const MinusCircle = createIcon('MinusCircle') diff --git a/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx b/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx new file mode 100644 index 000000000..d639ed183 --- /dev/null +++ b/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx @@ -0,0 +1,181 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock('motion/react', () => ({ + motion: new Proxy( + {}, + { + get: + (_target, tag: string) => + ({ children, ...rest }: { children?: React.ReactNode }) => + React.createElement(tag, rest, children), + }, + ), + useReducedMotion: () => true, +})) + +vi.mock('@/components/ui/app-overlay', () => ({ + AppOverlay: ({ + open, + children, + title, + }: { + open: boolean + children?: React.ReactNode + title?: string + onOpenChange?: (open: boolean) => void + }) => + open ? ( +
+ {title} + {children} +
+ ) : null, +})) + +const patchProfile = vi.fn() +let profileValue: { marketingEmailConsent: boolean | null } | undefined = { + marketingEmailConsent: null, +} +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: profileValue, patchProfile }), +})) + +const updateMarketingConsent = vi.fn().mockResolvedValue(undefined) +vi.mock('@/app/actions/profile', () => ({ + updateMarketingConsent: (data: { enabled: boolean }) => + updateMarketingConsent(data), +})) + +import { MarketingConsentPrompt } from '@/components/marketing-consent/marketing-consent-prompt' +import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' +import { MARKETING_CONSENT_MILESTONE_KEY } from '@orbit/shared/stores' + +function renderPrompt() { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + return render( + + + , + ) +} + +function resetStores() { + useReferralPromptStore.setState({ + promptedMilestoneKeys: [], + lastPromptedAtIso: null, + homeEntryDismissed: false, + armedPrompt: null, + }) + useUIStore.setState({ activeCelebration: null, queuedCelebrations: [] }) +} + +async function armConsent() { + await act(async () => { + useReferralPromptStore + .getState() + .armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY) + await Promise.resolve() + }) +} + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) +} + +describe('MarketingConsentPrompt', () => { + beforeEach(() => { + vi.useFakeTimers() + resetStores() + patchProfile.mockClear() + updateMarketingConsent.mockClear() + profileValue = { marketingEmailConsent: null } + }) + + afterEach(() => { + cleanup() + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('renders nothing when no consent prompt is armed', () => { + renderPrompt() + expect(screen.queryByTestId('consent-prompt')).toBeNull() + }) + + it('does not render when a different kind is armed', async () => { + renderPrompt() + await act(async () => { + useReferralPromptStore.getState().armReferralPrompt('level-3') + await Promise.resolve() + }) + await settle() + expect(screen.queryByTestId('consent-prompt')).toBeNull() + }) + + it('shows after the settle delay and records markEngagementPrompted', async () => { + renderPrompt() + await armConsent() + expect(screen.queryByTestId('consent-prompt')).toBeNull() + + await settle() + + expect(screen.getByTestId('consent-prompt')).toBeInTheDocument() + expect(useReferralPromptStore.getState().promptedMilestoneKeys).toContain( + MARKETING_CONSENT_MILESTONE_KEY, + ) + }) + + it('stays hidden while a celebration is in flight', async () => { + useUIStore.getState().enqueueCelebration('streak', { streak: 7 }) + renderPrompt() + await armConsent() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + + expect(screen.queryByTestId('consent-prompt')).toBeNull() + }) + + it('opts in and optimistically patches the profile on accept', async () => { + renderPrompt() + await armConsent() + await settle() + + await act(async () => { + fireEvent.click(screen.getByText('marketingConsent.prompt.accept')) + await Promise.resolve() + }) + + expect(updateMarketingConsent).toHaveBeenCalledWith({ enabled: true }) + expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: true }) + expect(screen.queryByTestId('consent-prompt')).toBeNull() + }) + + it('opts out and optimistically patches the profile on decline', async () => { + renderPrompt() + await armConsent() + await settle() + + await act(async () => { + fireEvent.click(screen.getByText('marketingConsent.prompt.decline')) + await Promise.resolve() + }) + + expect(updateMarketingConsent).toHaveBeenCalledWith({ enabled: false }) + expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: false }) + expect(screen.queryByTestId('consent-prompt')).toBeNull() + }) +}) diff --git a/apps/web/__tests__/components/marketing-consent/marketing-consent-section.test.tsx b/apps/web/__tests__/components/marketing-consent/marketing-consent-section.test.tsx new file mode 100644 index 000000000..a53b6977b --- /dev/null +++ b/apps/web/__tests__/components/marketing-consent/marketing-consent-section.test.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +const patchProfile = vi.fn() +let profileValue: { marketingEmailConsent: boolean | null } | undefined +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: profileValue, patchProfile }), +})) + +const updateMarketingConsent = vi.fn().mockResolvedValue(undefined) +vi.mock('@/app/actions/profile', () => ({ + updateMarketingConsent: (data: { enabled: boolean }) => + updateMarketingConsent(data), +})) + +import { MarketingConsentSection } from '@/app/(app)/preferences/_components/marketing-consent-section' + +function renderSection() { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + return render( + + + , + ) +} + +describe('MarketingConsentSection', () => { + beforeEach(() => { + patchProfile.mockClear() + updateMarketingConsent.mockClear() + updateMarketingConsent.mockResolvedValue(undefined) + profileValue = { marketingEmailConsent: null } + }) + + afterEach(() => cleanup()) + + it('reflects consent off when the profile has not opted in', () => { + profileValue = { marketingEmailConsent: null } + renderSection() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) + + it('reflects consent on when the profile opted in', () => { + profileValue = { marketingEmailConsent: true } + renderSection() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true') + }) + + it('opts in and optimistically patches the profile on toggle', async () => { + profileValue = { marketingEmailConsent: false } + renderSection() + + await act(async () => { + fireEvent.click(screen.getByRole('switch')) + await Promise.resolve() + }) + + expect(updateMarketingConsent).toHaveBeenCalledWith({ enabled: true }) + expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: true }) + }) + + it('rolls the optimistic patch back when the mutation fails', async () => { + profileValue = { marketingEmailConsent: true } + updateMarketingConsent.mockRejectedValueOnce(new Error('network')) + renderSection() + + await act(async () => { + fireEvent.click(screen.getByRole('switch')) + await Promise.resolve() + await Promise.resolve() + }) + + expect(patchProfile).toHaveBeenNthCalledWith(1, { marketingEmailConsent: false }) + expect(patchProfile).toHaveBeenLastCalledWith({ marketingEmailConsent: true }) + }) +}) diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 6b010b463..4afcdeb2e 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -27,6 +27,7 @@ import { LevelUpOverlay } from '@/components/gamification/level-up-overlay' import { StreakFreezeCelebration } from '@/components/gamification/streak-freeze-celebration' import { ReferralPrompt } from '@/components/referral/referral-prompt' import { MilestoneSharePrompt } from '@/components/milestone-share/milestone-share-prompt' +import { MarketingConsentPrompt } from '@/components/marketing-consent/marketing-consent-prompt' import { useProfile } from '@/hooks/use-profile' import { useTimezoneAutoSync } from '@/hooks/use-timezone-auto-sync' import { useAuthStore } from '@/stores/auth-store' @@ -38,6 +39,7 @@ import { getReferralLevelMilestone, getMilestoneShareAchievementKey, getMilestoneShareStreakKey, + MARKETING_CONSENT_MILESTONE_KEY, } from '@orbit/shared/stores' import { dismissCalendarImport } from '@/app/actions/calendar' import { TourProvider } from '@/components/tour/tour-provider' @@ -248,6 +250,20 @@ function GlobalOverlays({ const armMilestoneSharePrompt = useReferralPromptStore( (s) => s.armMilestoneSharePrompt, ) + const armConsentPrompt = useReferralPromptStore((s) => s.armConsentPrompt) + + useEffect(() => { + if ( + profile?.hasCompletedOnboarding && + profile.marketingEmailConsent === null + ) { + armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY) + } + }, [ + profile?.hasCompletedOnboarding, + profile?.marketingEmailConsent, + armConsentPrompt, + ]) useEffect(() => { if (gamification.leveledUp && gamification.newLevel) { @@ -291,6 +307,7 @@ function GlobalOverlays({ onClear={gamification.clearLevelUp} /> )} + {profile?.hasCompletedOnboarding && } {profile?.hasCompletedOnboarding && } {profile?.hasCompletedOnboarding && } diff --git a/apps/web/app/(app)/preferences/_components/marketing-consent-section.tsx b/apps/web/app/(app)/preferences/_components/marketing-consent-section.tsx new file mode 100644 index 000000000..7f01bf6ac --- /dev/null +++ b/apps/web/app/(app)/preferences/_components/marketing-consent-section.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { Mail } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { SectionLabel } from '@/components/ui/section-label' +import { SettingsRow, Switch } from '@/components/ui/settings-row' +import { useProfile } from '@/hooks/use-profile' +import { updateMarketingConsent } from '@/app/actions/profile' + +/** Self-contained "Product updates by email" preference row: reflects and optimistically toggles marketing-email consent, rolling back on error. Never Pro-gated. */ +export function MarketingConsentSection() { + const t = useTranslations() + const { profile, patchProfile } = useProfile() + const enabled = profile?.marketingEmailConsent === true + + const mutation = useMutation({ + mutationFn: (next: boolean) => updateMarketingConsent({ enabled: next }), + onMutate: (next) => { + const previous = profile?.marketingEmailConsent ?? null + patchProfile({ marketingEmailConsent: next }) + return { previous } + }, + onError: (_error, _next, context) => { + patchProfile({ marketingEmailConsent: context?.previous ?? null }) + }, + }) + + return ( + <> + {t('profile.sections.communication')} + + mutation.mutate(!enabled)} + ariaLabel={t('profile.marketingEmails.title')} + disabled={mutation.isPending} + /> + + + ) +} diff --git a/apps/web/app/(app)/preferences/_components/preference-settings-list.tsx b/apps/web/app/(app)/preferences/_components/preference-settings-list.tsx index 50283baf8..a482b4e02 100644 --- a/apps/web/app/(app)/preferences/_components/preference-settings-list.tsx +++ b/apps/web/app/(app)/preferences/_components/preference-settings-list.tsx @@ -10,6 +10,7 @@ import { PushNotificationSection, type PushSectionState, } from './push-notification-section' +import { MarketingConsentSection } from './marketing-consent-section' function SchemeDot({ color }: Readonly<{ color: string }>) { return ( @@ -103,6 +104,10 @@ export function PreferenceSettingsList({
+ +
+ +
diff --git a/apps/web/app/actions/profile.ts b/apps/web/app/actions/profile.ts index f0094167d..7b40f32b7 100644 --- a/apps/web/app/actions/profile.ts +++ b/apps/web/app/actions/profile.ts @@ -7,6 +7,7 @@ import type { SetAiMemoryRequest, SetAiSummaryRequest, SetProactiveAstraRequest, + SetMarketingEmailConsentRequest, SetWeekStartDayRequest, SetThemePreferenceRequest, SetColorSchemeRequest, @@ -59,6 +60,15 @@ export async function updateProactiveAstra(data: SetProactiveAstraRequest): Prom }) } +export async function updateMarketingConsent( + data: SetMarketingEmailConsentRequest, +): Promise { + await serverAuthFetch(API.profile.marketingConsent, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + export async function updateWeekStartDay(data: SetWeekStartDayRequest): Promise { await serverAuthFetch(API.profile.weekStartDay, { method: 'PUT', diff --git a/apps/web/components/marketing-consent/marketing-consent-prompt.tsx b/apps/web/components/marketing-consent/marketing-consent-prompt.tsx new file mode 100644 index 000000000..0b4bf6c74 --- /dev/null +++ b/apps/web/components/marketing-consent/marketing-consent-prompt.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' +import { useMutation } from '@tanstack/react-query' +import { Mail } from 'lucide-react' +import { motion, useReducedMotion } from 'motion/react' +import { motionDurations, motionEasings } from '@orbit/shared/theme' +import { MARKETING_CONSENT_MILESTONE_KEY } from '@orbit/shared/stores' +import { AppOverlay } from '@/components/ui/app-overlay' +import { PillButton } from '@/components/ui/pill-button' +import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' +import { useProfile } from '@/hooks/use-profile' +import { updateMarketingConsent } from '@/app/actions/profile' + +const SETTLE_DELAY_MS = 500 + +function enterTransition(delayMs: number) { + return { + duration: motionDurations.base / 1000, + ease: motionEasings.enter, + delay: delayMs / 1000, + } +} + +/** + * One-time LGPD-lawful marketing-email consent nudge. Shows once onboarding is complete, the + * armed slot holds a consent prompt, and no celebration is in flight — deliberately skipping the + * shared 14-day cooldown while still recording the prompt so other prompts treat it as recent. + * The backdrop dismiss leaves consent undecided (null), so it can re-ask; an explicit Yes/No + * persists the decision and the strict `=== null` arming gate prevents it from ever re-showing. + */ +export function MarketingConsentPrompt() { + const t = useTranslations() + const prefersReducedMotion = useReducedMotion() + const { profile, patchProfile } = useProfile() + const armedPrompt = useReferralPromptStore((s) => s.armedPrompt) + const markEngagementPrompted = useReferralPromptStore( + (s) => s.markEngagementPrompted, + ) + const celebrationInFlight = useUIStore( + (s) => s.activeCelebration !== null || s.queuedCelebrations.length > 0, + ) + + const isArmed = armedPrompt?.kind === 'consent' + const [visible, setVisible] = useState(false) + const settleTimerRef = useRef>(undefined) + + const mutation = useMutation({ + mutationFn: (enabled: boolean) => updateMarketingConsent({ enabled }), + onMutate: (enabled) => { + const previous = profile?.marketingEmailConsent ?? null + patchProfile({ marketingEmailConsent: enabled }) + return { previous } + }, + onError: (_error, _enabled, context) => { + patchProfile({ marketingEmailConsent: context?.previous ?? null }) + }, + }) + + useEffect(() => { + if (visible || !isArmed || celebrationInFlight) return + + settleTimerRef.current = setTimeout(() => { + markEngagementPrompted( + MARKETING_CONSENT_MILESTONE_KEY, + new Date().toISOString(), + ) + setVisible(true) + }, SETTLE_DELAY_MS) + + return () => { + if (settleTimerRef.current) clearTimeout(settleTimerRef.current) + } + }, [isArmed, celebrationInFlight, visible, markEngagementPrompted]) + + function answer(enabled: boolean) { + setVisible(false) + mutation.mutate(enabled) + } + + return ( + { + if (!open) setVisible(false) + }} + title={t('marketingConsent.prompt.title')} + > +
+ + {t('marketingConsent.prompt.eyebrow')} + + + + {t('marketingConsent.prompt.body')} + + + answer(true)}> + {t('marketingConsent.prompt.accept')} + + + +
+
+ ) +} diff --git a/packages/shared/src/__tests__/engagement-prompt-store.test.ts b/packages/shared/src/__tests__/engagement-prompt-store.test.ts index 9372045e8..6722a35ab 100644 --- a/packages/shared/src/__tests__/engagement-prompt-store.test.ts +++ b/packages/shared/src/__tests__/engagement-prompt-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + MARKETING_CONSENT_MILESTONE_KEY, REFERRAL_PROMPT_COOLDOWN_DAYS, canPromptEngagement, createEngagementPromptStoreState, @@ -352,6 +353,58 @@ describe('engagement prompt arbiter', () => { milestoneKey: 'review-level-5', }) }) + + it('lets the consent prompt outrank every other kind for the armed slot', () => { + const store = createStoreHarness() + + store.getState().armReviewPrompt('review-streak-7') + store.getState().armMilestoneSharePrompt('share-streak-7') + store.getState().armReferralPrompt('level-5') + store.getState().armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY) + + expect(store.getState().armedPrompt).toEqual({ + kind: 'consent', + milestoneKey: MARKETING_CONSENT_MILESTONE_KEY, + }) + }) + + it('keeps the armed consent prompt when any lower-priority prompt arms after', () => { + const store = createStoreHarness() + + store.getState().armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY) + store.getState().armReviewPrompt('review-streak-7') + + expect(store.getState().armedPrompt).toEqual({ + kind: 'consent', + milestoneKey: MARKETING_CONSENT_MILESTONE_KEY, + }) + }) +}) + +describe('consent prompt cooldown bypass', () => { + it('arms even when another prompt fired inside the shared cooldown window', () => { + const store = createStoreHarness() + + store.getState().markEngagementPrompted('share-streak-7', '2026-06-27T12:00:00.000Z') + store.getState().armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY) + + expect(store.getState().armedPrompt).toEqual({ + kind: 'consent', + milestoneKey: MARKETING_CONSENT_MILESTONE_KEY, + }) + }) + + it('records markEngagementPrompted on show so other prompts treat consent as recent', () => { + const store = createStoreHarness() + const now = '2026-06-27T12:00:00.000Z' + + store.getState().armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY) + store.getState().markEngagementPrompted(MARKETING_CONSENT_MILESTONE_KEY, now) + + expect(store.getState().lastPromptedAtIso).toBe(now) + expect(store.getState().armedPrompt).toBeNull() + expect(canPromptEngagement(store.getState(), 'level-3', now)).toBe(false) + }) }) describe('migratePersistedEngagementPromptState', () => { diff --git a/packages/shared/src/api/endpoints.ts b/packages/shared/src/api/endpoints.ts index 94c879400..e018f6baa 100644 --- a/packages/shared/src/api/endpoints.ts +++ b/packages/shared/src/api/endpoints.ts @@ -26,6 +26,7 @@ export const API = { export: '/api/profile/export', handle: '/api/profile/handle', socialOptIn: '/api/profile/social-opt-in', + marketingConsent: '/api/profile/marketing-consent', public: '/api/profile/public', }, diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 6ebd2f21e..740134eb2 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -961,6 +961,7 @@ "sections": { "account": "Account", "preferences": "Preferences", + "communication": "Communication", "aiFeatures": "AI Features", "aboutHelp": "About & Help", "advanced": "Advanced", @@ -1065,6 +1066,10 @@ "title": "Proactive check-ins", "description": "Let Astra reach out with a personalized nudge when you fall behind, even if you forget to open the app." }, + "marketingEmails": { + "title": "Product updates by email", + "description": "Get occasional emails about new Orbit features and improvements. No spam, unsubscribe anytime." + }, "facts": { "title": "What Astra knows", "empty": "Astra hasn't learned anything yet. Have a conversation in Chat to teach it.", @@ -1222,6 +1227,15 @@ }, "loginBanner": "You're invited! 10% off Pro when you sign up" }, + "marketingConsent": { + "prompt": { + "eyebrow": "Stay in the loop", + "title": "Product updates by email?", + "body": "We'll occasionally email you about new Orbit features and improvements. No spam, and you can unsubscribe anytime.", + "accept": "Yes, keep me posted", + "decline": "No thanks" + } + }, "shareCard": { "title": "Share your progress", "entry": "Share progress", diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index fe1b79716..1cef71f30 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -961,6 +961,7 @@ "sections": { "account": "Conta", "preferences": "Preferências", + "communication": "Comunicação", "aiFeatures": "Recursos de IA", "aboutHelp": "Sobre e Ajuda", "advanced": "Avançado", @@ -1065,6 +1066,10 @@ "title": "Check-ins proativos", "description": "Deixe a Astra te procurar com um empurrãozinho personalizado quando você ficar para trás, mesmo que você esqueça de abrir o app." }, + "marketingEmails": { + "title": "Novidades do produto por e-mail", + "description": "Receba e-mails ocasionais sobre novos recursos e melhorias do Orbit. Sem spam, cancele quando quiser." + }, "facts": { "title": "O que a Astra sabe", "empty": "A Astra ainda não aprendeu nada. Converse no Chat para ensinar a ela.", @@ -1222,6 +1227,15 @@ }, "loginBanner": "Você foi convidado! 10% de desconto no Pro ao se cadastrar" }, + "marketingConsent": { + "prompt": { + "eyebrow": "Fique por dentro", + "title": "Novidades do produto por e-mail?", + "body": "Vamos enviar e-mails ocasionais sobre novos recursos e melhorias do Orbit. Sem spam, e você pode cancelar quando quiser.", + "accept": "Sim, quero receber", + "decline": "Agora não" + } + }, "shareCard": { "title": "Compartilhe seu progresso", "entry": "Compartilhar progresso", diff --git a/packages/shared/src/stores/engagement-prompt-store.ts b/packages/shared/src/stores/engagement-prompt-store.ts index 74ec9b140..b0c311c94 100644 --- a/packages/shared/src/stores/engagement-prompt-store.ts +++ b/packages/shared/src/stores/engagement-prompt-store.ts @@ -13,16 +13,20 @@ type EngagementPromptStoreSet = { ): void } -/** The engagement-prompt families that share one cooldown budget and one armed slot. */ -export type EngagementPromptKind = 'referral' | 'milestone-share' | 'review' +/** The engagement-prompt families that share one armed slot. */ +export type EngagementPromptKind = 'consent' | 'referral' | 'milestone-share' | 'review' -/** Arbiter weights: a review moment outranks a milestone-share prompt, which outranks a referral prompt, for the single armed slot. */ +/** Arbiter weights for the single armed slot: the one-time marketing-consent prompt outranks a review moment, which outranks a milestone-share prompt, which outranks a referral prompt. */ export const ENGAGEMENT_PROMPT_PRIORITY: Record = { + consent: 4, review: 3, 'milestone-share': 2, referral: 1, } +/** Fixed key for the one-time marketing-email consent prompt: it is asked at most once per account, so it needs no per-milestone namespacing. */ +export const MARKETING_CONSENT_MILESTONE_KEY = 'marketing-consent' + export const STREAK_CROSSING_MILESTONES = [7, 30, 100] as const export const REVIEW_MOMENT_STREAK_MILESTONES = [7, 14, 30, 100, 365] as const @@ -171,6 +175,7 @@ export interface EngagementPromptStoreState extends PersistedEngagementPromptSta armedPrompt: ArmedEngagementPrompt | null armEngagementPrompt: (kind: EngagementPromptKind, milestoneKey: string) => void + armConsentPrompt: (milestoneKey: string) => void armReferralPrompt: (milestoneKey: string) => void armMilestoneSharePrompt: (milestoneKey: string) => void armReviewPrompt: (milestoneKey: string) => void @@ -244,6 +249,8 @@ export function createEngagementPromptStoreState( armedPrompt: null, armEngagementPrompt, + armConsentPrompt: (milestoneKey) => + armEngagementPrompt('consent', milestoneKey), armReferralPrompt: (milestoneKey) => armEngagementPrompt('referral', milestoneKey), armMilestoneSharePrompt: (milestoneKey) => diff --git a/packages/shared/src/stores/index.ts b/packages/shared/src/stores/index.ts index b66c8eee2..e2303705c 100644 --- a/packages/shared/src/stores/index.ts +++ b/packages/shared/src/stores/index.ts @@ -10,6 +10,7 @@ export { } from './tour-store' export { ENGAGEMENT_PROMPT_PRIORITY, + MARKETING_CONSENT_MILESTONE_KEY, STREAK_CROSSING_MILESTONES, REVIEW_MOMENT_STREAK_MILESTONES, REFERRAL_STREAK_MILESTONES, diff --git a/packages/shared/src/types/profile.ts b/packages/shared/src/types/profile.ts index d88e732e3..b0c9d1420 100644 --- a/packages/shared/src/types/profile.ts +++ b/packages/shared/src/types/profile.ts @@ -62,6 +62,7 @@ export const profileSchema = z.object({ socialOptIn: z.boolean().optional(), publicProfile: publicProfileSettingsSchema.optional(), proactiveAstraEnabled: z.boolean().optional(), + marketingEmailConsent: z.boolean().nullable().optional(), }) export type Profile = z.infer @@ -90,6 +91,12 @@ export const setProactiveAstraRequestSchema = z.object({ export type SetProactiveAstraRequest = z.infer +export const setMarketingEmailConsentRequestSchema = z.object({ + enabled: z.boolean(), +}) + +export type SetMarketingEmailConsentRequest = z.infer + export const setNameRequestSchema = z.object({ name: z.string().trim().min(1).max(50), }) diff --git a/packages/shared/src/types/sync.ts b/packages/shared/src/types/sync.ts index 431c9761c..a4c44ecd8 100644 --- a/packages/shared/src/types/sync.ts +++ b/packages/shared/src/types/sync.ts @@ -7,7 +7,7 @@ export const mutationTypeSchema = z.enum([ 'createGoal', 'updateGoal', 'deleteGoal', 'restoreGoal', 'updateGoalProgress', 'updateGoalStatus', 'reorderGoals', 'linkGoalHabits', 'createTag', 'updateTag', 'deleteTag', 'restoreTag', 'assignTags', 'markNotificationRead', 'markAllNotificationsRead', 'deleteNotification', 'deleteAllNotifications', - 'setName', 'setLanguage', 'setWeekStartDay', 'setColorScheme', 'setThemePreference', 'setTimeZone', 'setAiMemory', 'setAiSummary', 'setProactiveAstra', + 'setName', 'setLanguage', 'setWeekStartDay', 'setColorScheme', 'setThemePreference', 'setTimeZone', 'setAiMemory', 'setAiSummary', 'setProactiveAstra', 'setMarketingConsent', 'completeOnboarding', 'dismissCalendarPrompt', 'resetProfile', 'deleteUserFact', 'bulkDeleteUserFacts', 'createApiKey', 'deleteApiKey', From 7a806a5dedef528748ef166cdeefde761a96af50 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 5 Jul 2026 02:34:21 -0300 Subject: [PATCH 2/3] docs: add marketing email consent row to FEATURES.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #401 review — track the new consent prompt + preferences toggle in the Settings & Account section. Co-Authored-By: Claude Fable 5 --- FEATURES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/FEATURES.md b/FEATURES.md index 3736e8ba9..e466ece02 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -237,6 +237,7 @@ XP/gamification is **Free**, enabled by a feature flag (migration `EnableGamific | Language | English / Brazilian Portuguese; app, AI, and emails follow | Free | Both | Full en + pt-BR parity | | AI memory | AI learns preferences/routines from conversations | Pro | Both | — | | AI daily summary toggle | Enable the Today summary card | Pro | Both | — | +| Marketing email consent | One-time opt-in prompt + "Product updates by email" toggle; syncs to a Resend segment and honors unsubscribes both directions | Free | Both | en + pt-BR | | What Orbit Knows | Manually add facts the AI uses to personalize (max 50) | Pro | Both | — | | Timezone | Auto-detected; drives due dates and "today" | Free | Both | — | | Week start day | Monday or Sunday across all calendars | Free | Both | — | From 7f9740226ecf38016cb5768b7814f0321aa4defe Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 6 Jul 2026 15:16:25 -0300 Subject: [PATCH 3/3] docs: correct marketing-consent FEATURES row to DB-only sender The row claimed "syncs to a Resend segment"; the shipped design stores consent in-app (DB is the source of truth for the code-based sender) with a self-hosted unsubscribe. Matches the paired orbit-api#287 rework. Co-Authored-By: Claude Fable 5 --- FEATURES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FEATURES.md b/FEATURES.md index e9b19eae6..db9aa1acb 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -237,7 +237,7 @@ XP/gamification is **Free**, enabled by a feature flag (migration `EnableGamific | Language | English / Brazilian Portuguese; app, AI, and emails follow | Free | Both | Full en + pt-BR parity | | AI memory | AI learns preferences/routines from conversations | Pro | Both | — | | AI daily summary toggle | Enable the Today summary card | Pro | Both | — | -| Marketing email consent | One-time opt-in prompt + "Product updates by email" toggle; syncs to a Resend segment and honors unsubscribes both directions | Free | Both | en + pt-BR | +| Marketing email consent | One-time opt-in prompt + "Product updates by email" toggle; consent stored in-app (DB is the source of truth for the code-based sender), self-hosted unsubscribe flips it back | Free | Both | en + pt-BR | | What Orbit Knows | Manually add facts the AI uses to personalize (max 50) | Pro | Both | — | | Timezone | Auto-detected; drives due dates and "today" | Free | Both | — | | Week start day | Monday or Sunday across all calendars | Free | Both | — |