diff --git a/.gitignore b/.gitignore index fc097f2f..32b7ce2c 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,41 @@ contracts/migrations/history/* !contracts/migrations/history/.gitkeep contracts/migrations/snapshots/* !contracts/migrations/snapshots/.gitkeep + +# Test snapshots (Rust / Jest) +contracts/test_snapshots/ +contracts/**/test_snapshots/ +src/**/__snapshots__/ +**/__snapshots__/ +*.snap + +# Unnecessary generated / temp root files +test_output.txt +tsc_output.txt +tsc_output_2.txt +lint_output.txt +lint_final_error.txt +final_lint_check.txt +contracts/clippy_output.txt +issue*.json +PR_*.md +*_FIX*.md +*_FIX_GUIDE*.md +COMPLETION_SUMMARY.md +RACE_CONDITION_FIX.md +JS_BUNDLE_FIX.md +BUNDLE_AUDIT.md +BUILD_FIX_GUIDE.md +QUICK_START.md +PR_CI_Optimizations.md +FORMATTING.md +DESIGN_SYSTEM_INTEGRATION.md +DESIGN_SYSTEM_IMPLEMENTATION.md +DESIGN_SYSTEM_SETUP.md +WCAG_COMPLIANCE.md + +# Backup / duplicate files +*.backup +*\ copy.* +package.json.backup +SubTrackr diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0f32b2ee..2c14b1f2 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -78,6 +78,16 @@ const PaymentMethodsScreen = lazyScreen(() => ); const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsDashboard')); +// Issue #547: GDPR +const PrivacyCenterScreen = lazyScreen(() => import('../screens/PrivacyCenterScreen')); +const DataExportScreen = lazyScreen(() => import('../screens/DataExportScreen')); +// Issue #548: Push notifications +const NotificationPreferencesScreen = lazyScreen(() => import('../screens/NotificationPreferencesScreen')); +// Issue #549: Email templates +const EmailTemplateEditorScreen = lazyScreen(() => import('../screens/EmailTemplateEditorScreen')); +// Issue #550: Advanced dunning +const DunningDashboardScreen = lazyScreen(() => import('../screens/DunningDashboardScreen')); + const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); @@ -355,6 +365,40 @@ const SettingsStack = () => ( component={AnalyticsDashboard} options={{ title: 'Analytics Dashboard', headerShown: true }} /> + {/* Issue #547: GDPR */} + + + + {/* Issue #548: Push notifications */} + + {/* Issue #549: Email templates */} + + {/* Issue #550: Advanced dunning */} + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 10bf40b0..c270b303 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -49,6 +49,16 @@ export type RootStackParamList = { PaymentMethods: undefined; AnalyticsDashboard: undefined; NotFound: { reason?: string }; + // Issue #547: GDPR + PrivacyCenter: undefined; + DataExport: undefined; + DPALog: undefined; + // Issue #548: Push notifications + NotificationPreferences: undefined; + // Issue #549: Email templates + EmailTemplateEditor: undefined; + // Issue #550: Advanced dunning + DunningDashboard: undefined; }; export type TabParamList = { diff --git a/src/screens/DataExportScreen.tsx b/src/screens/DataExportScreen.tsx new file mode 100644 index 00000000..f3a525f6 --- /dev/null +++ b/src/screens/DataExportScreen.tsx @@ -0,0 +1,163 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; +import { useThemeColors } from '../hooks/useThemeColors'; +import { gdprService, PII_FIELDS } from '../services/gdpr'; + +const DataExportScreen = () => { + const colors = useThemeColors(); + const styles = React.useMemo(() => createStyles(colors), [colors]); + const [loading, setLoading] = useState(false); + const [lastExport, setLastExport] = useState<{ url: string; timestamp: string } | null>(null); + + const handleExport = async () => { + setLoading(true); + try { + const result = await gdprService.exportData(); + setLastExport({ url: result.url, timestamp: result.timestamp }); + await gdprService.downloadData(result); + } catch { + Alert.alert('Error', 'Could not prepare your data export. Please try again later.'); + } finally { + setLoading(false); + } + }; + + const categoryGroups = Array.from( + PII_FIELDS.reduce((acc, field) => { + const list = acc.get(field.category) ?? []; + list.push(field); + acc.set(field.category, list); + return acc; + }, new Map()) + ); + + return ( + + Export Your Data + + Under GDPR Article 20, you have the right to receive a machine-readable copy of all personal + data we hold about you. + + + {/* Data categories included */} + + What's included + {categoryGroups.map(([category, fields]) => ( + + {category.charAt(0).toUpperCase() + category.slice(1)} + {fields.map((f) => f.field).join(', ')} + + ))} + + + {/* Export info */} + + Export details + + Format + JSON (machine-readable) + + + Encryption + AES-256, fields annotated + + + Delivery + Sent to registered email + + + Processing time + Within 72 hours + + + + {lastExport && ( + + Last export + + Generated: {new Date(lastExport.timestamp).toLocaleString()} + + + URL: {lastExport.url} + + + )} + + + {loading ? ( + + ) : ( + 📦 Request Data Export + )} + + + + Your export request is logged in the Data Processing Activity register as required by GDPR + Article 30. + + + ); +}; + +function createStyles(colors: ReturnType) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + content: { padding: 16, paddingBottom: 40 }, + title: { fontSize: 24, fontWeight: '700', color: colors.text.primary, marginBottom: 8 }, + subtitle: { fontSize: 14, color: colors.textSecondary, marginBottom: 20, lineHeight: 20 }, + card: { + backgroundColor: colors.background.card, + borderRadius: 12, + padding: 16, + marginBottom: 16, + borderWidth: 1, + borderColor: colors.border.default, + }, + cardTitle: { fontSize: 16, fontWeight: '700', color: colors.text.primary, marginBottom: 12 }, + categoryRow: { + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + categoryLabel: { fontSize: 13, fontWeight: '600', color: colors.text.primary, textTransform: 'capitalize' }, + categoryFields: { fontSize: 12, color: colors.textSecondary, marginTop: 2 }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + infoLabel: { fontSize: 13, color: colors.textSecondary }, + infoValue: { fontSize: 13, color: colors.text.primary, maxWidth: '60%', textAlign: 'right' }, + exportBtn: { + backgroundColor: colors.primary, + padding: 16, + borderRadius: 10, + alignItems: 'center', + marginBottom: 16, + }, + exportBtnDisabled: { opacity: 0.6 }, + exportBtnText: { color: colors.onPrimary, fontSize: 16, fontWeight: '700' }, + footer: { fontSize: 12, color: colors.textSecondary, textAlign: 'center', lineHeight: 18 }, + }); +} + +export default DataExportScreen; diff --git a/src/screens/DunningDashboardScreen.tsx b/src/screens/DunningDashboardScreen.tsx new file mode 100644 index 00000000..148b11c8 --- /dev/null +++ b/src/screens/DunningDashboardScreen.tsx @@ -0,0 +1,338 @@ +/** + * DunningDashboardScreen — ML-enhanced dunning dashboard. + * Extends the base DunningDashboard with: + * - Recovery funnel visualization + * - Smart retry decision display + * - Decline code breakdown + * - Multi-channel outreach stats + * - Card updater status + */ +import React, { useState, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { useDunningStore } from '../store/dunningStore'; +import { dunningEngine, smartRetryService, type DeclineCode } from '../services/smartRetryService'; +import type { DunningStage } from '../types/dunning'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; + +const DECLINE_CODE_LABELS: Record = { + insufficient_funds: 'Insufficient funds', + card_expired: 'Card expired', + do_not_honor: 'Do not honor', + card_lost_stolen: 'Lost / stolen', + authentication_required: '3DS required', + generic_decline: 'Generic decline', +}; + +const STAGE_COLORS: Record = { + retry: colors.warning, + warn: '#f97316', + suspend: colors.error, + cancel: '#6b7280', +}; + +// ─── Recovery Funnel ────────────────────────────────────────────────────────── + +const RecoveryFunnel: React.FC<{ entries: ReturnType['entries'] }> = ({ + entries, +}) => { + const stats = useMemo(() => dunningEngine.buildFunnelStats(entries), [entries]); + + const funnelSteps = [ + { label: 'Total at risk', count: stats.total, color: colors.primary }, + { label: 'Retrying', count: stats.retrying, color: colors.warning }, + { label: 'Recovered', count: stats.recovered, color: colors.success }, + { label: 'Failed / lost', count: stats.failed, color: colors.error }, + ]; + + return ( + + 📊 Recovery Funnel + + {funnelSteps.map((step, i) => ( + + {step.count} + {step.label} + {i < funnelSteps.length - 1 && } + + ))} + + + + + + Recovery rate: {stats.recoveryRate}% + + + {/* Channel breakdown */} + Outreach channels + + {(['email', 'push', 'sms'] as const).map((ch) => { + const d = stats.byChannel[ch]; + const rate = d.sent > 0 ? Math.round((d.conversions / d.sent) * 100) : 0; + return ( + + {ch.toUpperCase()} + {d.sent} sent + {rate}% conv. + + ); + })} + + + ); +}; + +// ─── Stage Breakdown ────────────────────────────────────────────────────────── + +const StageBreakdown: React.FC<{ entries: ReturnType['entries'] }> = ({ + entries, +}) => { + const breakdown: Record = { retry: 0, warn: 0, suspend: 0, cancel: 0 }; + for (const e of entries) { + breakdown[e.currentStage] = (breakdown[e.currentStage] ?? 0) + 1; + } + + return ( + + 🎯 Stage Breakdown + + {(Object.keys(breakdown) as DunningStage[]).map((stage) => ( + + + {breakdown[stage]} + + {stage} + + ))} + + + ); +}; + +// ─── Smart Retry Demo ───────────────────────────────────────────────────────── + +const SmartRetryPanel: React.FC = () => { + const [result, setResult] = useState(null); + const declineCodes: DeclineCode[] = [ + 'insufficient_funds', 'card_expired', 'authentication_required', 'generic_decline', + ]; + + const simulateRetry = (code: DeclineCode) => { + const invoiceId = `demo_inv_${code}`; + if (!smartRetryService.getRecord(invoiceId)) { + smartRetryService.registerInvoice(invoiceId, 'demo_sub', 149.99, 'USD', 6); + } + const decision = smartRetryService.decideRetry(invoiceId, code); + const msg = [ + `Decline: ${DECLINE_CODE_LABELS[code]}`, + `Retry: ${decision.shouldRetry ? 'Yes' : 'No'}`, + `Delay: ${decision.delayHours}h`, + decision.splitAmount ? `Split: $${decision.splitAmount}` : '', + `Channel: ${decision.outreachChannel.toUpperCase()}`, + `Priority escalated: ${decision.escalatePriority ? 'Yes' : 'No'}`, + `Reason: ${decision.reason}`, + ] + .filter(Boolean) + .join('\n'); + setResult(msg); + }; + + return ( + + 🤖 ML Smart Retry Simulator + + Tap a decline code to see the ML-optimized retry decision, amount splitting, and channel + escalation. + + + {declineCodes.map((code) => ( + simulateRetry(code)} + accessibilityRole="button" + accessibilityLabel={`Simulate ${DECLINE_CODE_LABELS[code]} decline`}> + {DECLINE_CODE_LABELS[code]} + + ))} + + {result && ( + + {result} + + )} + + ); +}; + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +const DunningDashboardScreen: React.FC = () => { + const navigation = useNavigation>(); + const entries = useDunningStore((s) => s.entries); + const startDunning = useDunningStore((s) => s.startDunning); + + const handleSeedDemo = () => { + if (entries.length > 0) { + Alert.alert('Demo data already seeded.'); + return; + } + startDunning('sub_001', 'user_1', 'merchant_1', 'pro_plan'); + startDunning('sub_002', 'user_2', 'merchant_1', 'basic_plan'); + Alert.alert('Demo seeded', '2 dunning cases created.'); + }; + + return ( + + {/* Header */} + + navigation.goBack()} + accessibilityRole="button" + accessibilityLabel="Go back"> + ‹ Back + + Dunning + + Demo + + + + + + + + navigation.navigate('DunningDashboard')} + accessibilityRole="button" + accessibilityLabel="View full dunning dashboard"> + View Full Dunning List → + + + ); +}; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + content: { paddingBottom: 40 }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + backText: { ...typography.body, color: colors.primary, fontWeight: '500' }, + title: { ...typography.h2, color: colors.text }, + demoBtn: { ...typography.body, color: colors.accent }, + card: { + backgroundColor: colors.surface, + margin: spacing.md, + marginBottom: 0, + borderRadius: borderRadius.md, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.border, + }, + cardTitle: { ...typography.body, color: colors.text, fontWeight: '700', marginBottom: spacing.sm }, + subTitle: { ...typography.caption, color: colors.textSecondary, fontWeight: '600', marginTop: spacing.md, marginBottom: spacing.xs }, + desc: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.sm, lineHeight: 18 }, + + // Funnel + funnelRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-around', marginBottom: spacing.sm }, + funnelStep: { alignItems: 'center', flex: 1 }, + funnelCount: { ...typography.h2, fontWeight: '700' }, + funnelLabel: { ...typography.small, color: colors.textSecondary, textAlign: 'center', marginTop: 2 }, + funnelArrow: { fontSize: 20, color: colors.border, position: 'absolute', right: -8 }, + recoveryRateBar: { + height: 8, + backgroundColor: colors.border, + borderRadius: 4, + overflow: 'hidden', + marginBottom: spacing.xs, + }, + recoveryRateFill: { height: '100%', backgroundColor: colors.success, borderRadius: 4 }, + recoveryRateText: { ...typography.small, color: colors.textSecondary, textAlign: 'center' }, + channelRow: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.xs }, + channelBox: { + flex: 1, + backgroundColor: colors.background, + borderRadius: borderRadius.sm, + padding: spacing.sm, + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + }, + channelName: { ...typography.caption, color: colors.text, fontWeight: '700' }, + channelStat: { ...typography.small, color: colors.textSecondary, marginTop: 2 }, + + // Stage breakdown + stageRow: { flexDirection: 'row', gap: spacing.sm }, + stageBox: { + flex: 1, + borderRadius: borderRadius.sm, + padding: spacing.sm, + alignItems: 'center', + borderWidth: 1, + backgroundColor: colors.background, + }, + stageCount: { ...typography.h3, fontWeight: '700' }, + stageLabel: { ...typography.small, color: colors.textSecondary, marginTop: 2, textTransform: 'capitalize' }, + + // Smart retry + codeChips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginBottom: spacing.sm }, + codeChip: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + codeChipText: { ...typography.small, color: colors.text }, + resultBox: { + backgroundColor: colors.background, + borderRadius: borderRadius.sm, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.border, + marginTop: spacing.xs, + }, + resultText: { ...typography.caption, color: colors.text, lineHeight: 20 }, + + // Footer + viewAllBtn: { + margin: spacing.md, + padding: spacing.md, + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + alignItems: 'center', + }, + viewAllBtnText: { ...typography.body, color: colors.text, fontWeight: '700' }, +}); + +export default DunningDashboardScreen; diff --git a/src/screens/EmailTemplateEditorScreen.tsx b/src/screens/EmailTemplateEditorScreen.tsx new file mode 100644 index 00000000..95185033 --- /dev/null +++ b/src/screens/EmailTemplateEditorScreen.tsx @@ -0,0 +1,540 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + TextInput, + Alert, + Switch, +} from 'react-native'; +import { useThemeColors } from '../hooks/useThemeColors'; +import { useEmailTemplateStore } from '../store/emailTemplateStore'; +import { emailTemplateService } from '../services/emailTemplateService'; +import type { BlockType, TemplateBlock } from '../types/emailTemplate'; +import { injectVariables, TEMPLATE_VARIABLES } from '../types/emailTemplate'; + +const BLOCK_ICONS: Record = { + header: '🏷️', + body: '📝', + cta_button: '🔘', + footer: '📄', + divider: '➖', + image: '🖼️', +}; + +const MERCHANT_ID = 'merchant-demo'; + +const EmailTemplateEditorScreen = () => { + const colors = useThemeColors(); + const styles = React.useMemo(() => createStyles(colors), [colors]); + const { + templates, + activeTemplate, + previewHtml, + loadTemplates, + selectTemplate, + createTemplate, + updateBlocks, + updateCustomCss, + publishTemplate, + rollbackTemplate, + refreshPreview, + } = useEmailTemplateStore(); + + const [activeTab, setActiveTab] = useState<'blocks' | 'preview' | 'abtest' | 'versions'>('blocks'); + const [editingBlockId, setEditingBlockId] = useState(null); + const [editContent, setEditContent] = useState(''); + const [customCss, setCustomCss] = useState(''); + const [newTemplateName, setNewTemplateName] = useState(''); + const [showCreate, setShowCreate] = useState(false); + + useEffect(() => { + loadTemplates(MERCHANT_ID); + // Create demo template if none exist + if (emailTemplateService.list(MERCHANT_ID).length === 0) { + createTemplate(MERCHANT_ID, 'Payment Failed', 'payment_failed'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const blocks = activeTemplate?.locales[0]?.blocks ?? []; + + const handleEditBlock = (block: TemplateBlock) => { + setEditingBlockId(block.id); + setEditContent(block.content); + }; + + const handleSaveBlock = () => { + if (!activeTemplate || editingBlockId === null) return; + const updated = blocks.map((b) => + b.id === editingBlockId ? { ...b, content: editContent } : b + ); + updateBlocks(activeTemplate.id, updated); + setEditingBlockId(null); + }; + + const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => { + if (!activeTemplate) return; + const sorted = [...blocks].sort((a, b) => a.order - b.order); + const idx = sorted.findIndex((b) => b.id === blockId); + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= sorted.length) return; + const reordered = sorted.map((b, i) => { + if (i === idx) return { ...b, order: sorted[swapIdx].order }; + if (i === swapIdx) return { ...b, order: sorted[idx].order }; + return b; + }); + updateBlocks(activeTemplate.id, reordered); + }; + + const handlePublish = () => { + if (!activeTemplate) return; + Alert.alert('Publish template?', 'This will make the template live for all subscribers.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Publish', + onPress: () => { + publishTemplate(activeTemplate.id); + Alert.alert('Published', `v${(activeTemplate.version + 1)} is now live.`); + }, + }, + ]); + }; + + const versions = activeTemplate ? emailTemplateService.getVersionHistory(activeTemplate.id) : []; + + return ( + + {/* Template selector */} + + {templates.map((t) => ( + selectTemplate(t.id)} + accessibilityRole="button" + accessibilityLabel={`Select template ${t.name}`} + accessibilityState={{ selected: activeTemplate?.id === t.id }}> + + {t.name} + + {t.status} + + ))} + setShowCreate(true)} + accessibilityRole="button" + accessibilityLabel="Create new template"> + + New + + + + {showCreate && ( + + + { + if (newTemplateName.trim()) { + createTemplate(MERCHANT_ID, newTemplateName.trim(), 'custom'); + setNewTemplateName(''); + setShowCreate(false); + } + }}> + Create + + + )} + + {/* Tab bar */} + + {(['blocks', 'preview', 'abtest', 'versions'] as const).map((tab) => ( + { + setActiveTab(tab); + if (tab === 'preview' && activeTemplate) refreshPreview(activeTemplate.id); + }} + accessibilityRole="tab" + accessibilityState={{ selected: activeTab === tab }}> + + {tab === 'abtest' ? 'A/B Test' : tab.charAt(0).toUpperCase() + tab.slice(1)} + + + ))} + + + + {!activeTemplate ? ( + Select or create a template to get started. + ) : ( + <> + {/* Blocks editor */} + {activeTab === 'blocks' && ( + <> + + {activeTemplate.name} — v{activeTemplate.version} ({activeTemplate.status}) + + {[...blocks] + .sort((a, b) => a.order - b.order) + .map((block) => ( + + + + {BLOCK_ICONS[block.type]} {block.type.replace('_', ' ')} + + + handleMoveBlock(block.id, 'up')} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel="Move block up"> + + + handleMoveBlock(block.id, 'down')} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel="Move block down"> + + + handleEditBlock(block)} + style={styles.editBtn} + accessibilityRole="button" + accessibilityLabel="Edit block content"> + Edit + + + + {editingBlockId === block.id ? ( + + + + Available variables: {Object.keys(TEMPLATE_VARIABLES).map((v) => `{{${v}}}`).join(', ')} + + + Preview: {injectVariables(editContent)} + + + + Save + + setEditingBlockId(null)} + accessibilityRole="button"> + Cancel + + + + ) : ( + + {injectVariables(block.content)} + + )} + + ))} + + {/* Custom CSS */} + Custom CSS + + updateCustomCss(activeTemplate.id, customCss)} + accessibilityRole="button" + accessibilityLabel="Save custom CSS"> + Apply CSS + + + + 🚀 Publish Template + + + )} + + {/* HTML Preview */} + {activeTab === 'preview' && ( + <> + Rendered Preview + + + {previewHtml} + + + + )} + + {/* A/B Test config */} + {activeTab === 'abtest' && ( + <> + A/B Test Configuration + + + Enable A/B test + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useEmailTemplateStore.getState().updateABTest(activeTemplate.id, { + enabled: val, + variantA: activeTemplate.abTest?.variantA ?? { subject: activeTemplate.locales[0]?.subject ?? '', sendTimeHour: 9 }, + variantB: activeTemplate.abTest?.variantB ?? { subject: activeTemplate.locales[0]?.subject ?? ' ✨', sendTimeHour: 14 }, + splitPercent: activeTemplate.abTest?.splitPercent ?? 50, + }); + }} + accessibilityRole="switch" + accessibilityLabel="Enable A/B test" + accessibilityState={{ checked: activeTemplate.abTest?.enabled ?? false }} + /> + + {activeTemplate.abTest?.enabled && ( + <> + + Variant A subject: {activeTemplate.abTest.variantA.subject} + + + Variant B subject: {activeTemplate.abTest.variantB.subject} + + + Split: {activeTemplate.abTest.splitPercent}% → A, {100 - activeTemplate.abTest.splitPercent}% → B + + + Send time A: {activeTemplate.abTest.variantA.sendTimeHour}:00 UTC + + + Send time B: {activeTemplate.abTest.variantB.sendTimeHour}:00 UTC + + + )} + + + )} + + {/* Version history */} + {activeTab === 'versions' && ( + <> + Version History + {versions.length === 0 ? ( + No versions saved yet. + ) : ( + [...versions].reverse().map((v) => ( + + + v{v.version} + + Saved {new Date(v.savedAt).toLocaleString()} by {v.savedBy} + + + {v.version !== activeTemplate.version && ( + { + Alert.alert( + 'Rollback', + `Restore to v${v.version}? Current changes will be saved as a new version.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Rollback', + onPress: () => rollbackTemplate(activeTemplate.id, v.version), + }, + ] + ); + }} + accessibilityRole="button" + accessibilityLabel={`Rollback to version ${v.version}`}> + Restore + + )} + + )) + )} + + )} + + )} + + + ); +}; + +function createStyles(colors: ReturnType) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + templateBar: { maxHeight: 64, paddingHorizontal: 8, paddingVertical: 8 }, + templateChip: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 20, + borderWidth: 1, + borderColor: colors.border.default, + marginRight: 8, + alignItems: 'center', + }, + templateChipActive: { backgroundColor: colors.primary, borderColor: colors.primary }, + templateChipText: { fontSize: 13, color: colors.text.primary, fontWeight: '600' }, + templateChipTextActive: { color: colors.onPrimary }, + templateStatus: { fontSize: 10, color: colors.textSecondary, marginTop: 2 }, + addTemplateBtn: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 20, + borderWidth: 1, + borderColor: colors.primary, + justifyContent: 'center', + }, + addTemplateBtnText: { color: colors.primary, fontSize: 13, fontWeight: '600' }, + createRow: { flexDirection: 'row', padding: 8, gap: 8 }, + createInput: { + flex: 1, + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: 8, + padding: 8, + color: colors.text.primary, + backgroundColor: colors.background.card, + }, + createBtn: { + backgroundColor: colors.primary, + paddingHorizontal: 16, + borderRadius: 8, + justifyContent: 'center', + }, + createBtnText: { color: colors.onPrimary, fontWeight: '600' }, + tabBar: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: colors.border.default }, + tab: { flex: 1, paddingVertical: 10, alignItems: 'center' }, + tabActive: { borderBottomWidth: 2, borderBottomColor: colors.primary }, + tabText: { fontSize: 13, color: colors.textSecondary }, + tabTextActive: { color: colors.primary, fontWeight: '600' }, + content: { flex: 1 }, + contentPad: { padding: 16, paddingBottom: 40 }, + emptyText: { color: colors.textSecondary, textAlign: 'center', marginTop: 40 }, + sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.text.primary, marginBottom: 12, marginTop: 8 }, + blockCard: { + backgroundColor: colors.background.card, + borderRadius: 10, + padding: 12, + marginBottom: 10, + borderWidth: 1, + borderColor: colors.border.default, + }, + blockHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, + blockType: { fontSize: 13, fontWeight: '600', color: colors.text.primary }, + blockActions: { flexDirection: 'row', gap: 8 }, + iconBtn: { padding: 4 }, + editBtn: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 6, + backgroundColor: colors.primary + '22', + }, + editBtnText: { color: colors.primary, fontSize: 12, fontWeight: '600' }, + blockContent: { fontSize: 13, color: colors.textSecondary }, + blockInput: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: 8, + padding: 10, + color: colors.text.primary, + backgroundColor: colors.background.card, + minHeight: 60, + }, + cssInput: { minHeight: 80, fontFamily: 'monospace', fontSize: 12 }, + variableHint: { fontSize: 11, color: colors.textSecondary, marginTop: 6, lineHeight: 16 }, + previewText: { fontSize: 12, color: colors.primary, marginTop: 4, fontStyle: 'italic' }, + saveRow: { flexDirection: 'row', gap: 8, marginTop: 8 }, + saveBtn: { + backgroundColor: colors.primary, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + alignItems: 'center', + }, + saveBtnText: { color: colors.onPrimary, fontWeight: '600' }, + cancelBtn: { + borderWidth: 1, + borderColor: colors.border.default, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + }, + cancelBtnText: { color: colors.textSecondary }, + publishBtn: { + backgroundColor: '#22c55e', + padding: 14, + borderRadius: 10, + alignItems: 'center', + marginTop: 20, + }, + publishBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 }, + previewBox: { + backgroundColor: colors.background.card, + borderRadius: 10, + padding: 12, + borderWidth: 1, + borderColor: colors.border.default, + }, + previewHtml: { fontSize: 11, color: colors.text.primary, fontFamily: 'monospace' }, + card: { + backgroundColor: colors.background.card, + borderRadius: 10, + padding: 14, + borderWidth: 1, + borderColor: colors.border.default, + }, + rowSpaceBetween: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }, + label: { fontSize: 15, fontWeight: '600', color: colors.text.primary }, + desc: { fontSize: 13, color: colors.textSecondary, marginTop: 6 }, + rowText: { flex: 1 }, + versionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + rollbackBtn: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + borderWidth: 1, + borderColor: colors.primary, + }, + rollbackBtnText: { color: colors.primary, fontSize: 12, fontWeight: '600' }, + }); +} + +export default EmailTemplateEditorScreen; diff --git a/src/screens/NotificationPreferencesScreen.tsx b/src/screens/NotificationPreferencesScreen.tsx new file mode 100644 index 00000000..caa54157 --- /dev/null +++ b/src/screens/NotificationPreferencesScreen.tsx @@ -0,0 +1,267 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + Switch, + TouchableOpacity, +} from 'react-native'; +import { useThemeColors } from '../hooks/useThemeColors'; +import { useNotificationPreferencesStore } from '../store/notificationPreferencesStore'; +import type { OptInCategory, NotificationPriority } from '../services/pushScheduleEngine'; + +const OPT_IN_LABELS: Record = { + billing: { label: 'Billing & Payments', desc: 'Payment due, charge results, invoice ready' }, + product: { label: 'Product Updates', desc: 'New features, improvements, announcements' }, + marketing: { label: 'Promotions', desc: 'Offers, discounts, and promotional campaigns' }, + security: { label: 'Security', desc: 'Login alerts, suspicious activity, 2FA prompts' }, +}; + +const PRIORITY_LABELS: Record = { + critical: { label: 'Critical only', desc: 'Payment failures & security alerts only' }, + informative: { label: 'Important & critical', desc: 'Exclude marketing promotions' }, + marketing: { label: 'All notifications', desc: 'Including promotional messages' }, +}; + +const DIGEST_OPTIONS: Array<{ + value: 'immediate' | 'daily' | 'weekly'; + label: string; + desc: string; +}> = [ + { value: 'immediate', label: 'Immediate', desc: 'Send each notification as it happens' }, + { value: 'daily', label: 'Daily digest', desc: 'Bundle non-critical alerts into one daily summary' }, + { value: 'weekly', label: 'Weekly digest', desc: 'Bundle non-critical alerts into one weekly summary' }, +]; + +const NotificationPreferencesScreen = () => { + const colors = useThemeColors(); + const styles = React.useMemo(() => createStyles(colors), [colors]); + const { preferences, toggleCategory, setQuietHours, updatePreferences } = + useNotificationPreferencesStore(); + + const priorityOrder: NotificationPriority[] = ['critical', 'informative', 'marketing']; + + return ( + + Notification Preferences + + Control which notifications you receive, when they're delivered, and how they're batched. + + + {/* Opt-in categories */} + + Notification categories + {(Object.keys(OPT_IN_LABELS) as OptInCategory[]).map((category) => { + const { label, desc } = OPT_IN_LABELS[category]; + const isOn = preferences.optInCategories[category]; + const isCritical = category === 'billing' || category === 'security'; + return ( + + + {label} + {desc} + {isCritical && ( + Recommended for account safety + )} + + toggleCategory(category)} + accessibilityLabel={`Toggle ${label} notifications`} + accessibilityRole="switch" + accessibilityState={{ checked: isOn }} + /> + + ); + })} + + + {/* Minimum priority filter */} + + Minimum priority + {priorityOrder.map((priority) => { + const { label, desc } = PRIORITY_LABELS[priority]; + const selected = preferences.minimumPriority === priority; + return ( + updatePreferences({ minimumPriority: priority })} + accessibilityRole="radio" + accessibilityState={{ checked: selected }} + accessibilityLabel={label}> + + {selected && } + + + {label} + {desc} + + + ); + })} + + + {/* Digest batching */} + + Delivery batching + {DIGEST_OPTIONS.map(({ value, label, desc }) => { + const selected = preferences.digestFrequency === value; + return ( + updatePreferences({ digestFrequency: value })} + accessibilityRole="radio" + accessibilityState={{ checked: selected }} + accessibilityLabel={label}> + + {selected && } + + + {label} + {desc} + + + ); + })} + + + {/* Quiet hours */} + + + + Quiet hours + + Pause non-critical notifications during the specified time window. + + + setQuietHours({ enabled: val })} + accessibilityLabel="Enable quiet hours" + accessibilityRole="switch" + accessibilityState={{ checked: preferences.quietHours.enabled }} + /> + + + {preferences.quietHours.enabled && ( + + + Start hour (UTC): {preferences.quietHours.startHour}:00 + + {[20, 21, 22, 23].map((h) => ( + setQuietHours({ startHour: h })} + accessibilityRole="button" + accessibilityLabel={`Set quiet hours start to ${h}:00`}> + {h}:00 + + ))} + + + + End hour (UTC): {preferences.quietHours.endHour}:00 + + {[6, 7, 8, 9].map((h) => ( + setQuietHours({ endHour: h })} + accessibilityRole="button" + accessibilityLabel={`Set quiet hours end to ${h}:00`}> + {h}:00 + + ))} + + + + Timezone changes mid-subscription are handled automatically via device locale. + + + )} + + + + A/B test variant: {preferences.abVariant} — notification copy and timing may vary as we + optimize delivery for you. + + + ); +}; + +function createStyles(colors: ReturnType) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + content: { padding: 16, paddingBottom: 40 }, + title: { fontSize: 24, fontWeight: '700', color: colors.text.primary, marginBottom: 8 }, + subtitle: { fontSize: 14, color: colors.textSecondary, marginBottom: 20, lineHeight: 20 }, + card: { + backgroundColor: colors.background.card, + borderRadius: 12, + padding: 16, + marginBottom: 16, + borderWidth: 1, + borderColor: colors.border.default, + }, + cardTitle: { fontSize: 16, fontWeight: '700', color: colors.text.primary, marginBottom: 12 }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + rowSpaceBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + rowText: { flex: 1, paddingRight: 8 }, + rowLabel: { fontSize: 15, fontWeight: '600', color: colors.text.primary }, + rowDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 2, lineHeight: 16 }, + requiredTag: { fontSize: 11, color: colors.primary, marginTop: 4, fontWeight: '500' }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + optionRowSelected: { backgroundColor: colors.primary + '11', borderRadius: 8 }, + radioCircle: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + radioFill: { width: 10, height: 10, borderRadius: 5, backgroundColor: colors.primary }, + quietHoursDetail: { marginTop: 12 }, + timeRow: { marginBottom: 12 }, + timeButtons: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 }, + timeBtn: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 6, + borderWidth: 1, + borderColor: colors.border.default, + }, + timeBtnActive: { backgroundColor: colors.primary, borderColor: colors.primary }, + timeBtnText: { fontSize: 13, color: colors.text.primary }, + footer: { fontSize: 12, color: colors.textSecondary, textAlign: 'center', lineHeight: 18 }, + }); +} + +export default NotificationPreferencesScreen; diff --git a/src/screens/PrivacyCenterScreen.tsx b/src/screens/PrivacyCenterScreen.tsx new file mode 100644 index 00000000..d8d830e8 --- /dev/null +++ b/src/screens/PrivacyCenterScreen.tsx @@ -0,0 +1,210 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { useThemeColors } from '../hooks/useThemeColors'; +import { gdprService } from '../services/gdpr'; +import { useUserStore } from '../store/userStore'; + +type Nav = NativeStackNavigationProp; + +const PrivacyCenterScreen = () => { + const colors = useThemeColors(); + const styles = React.useMemo(() => createStyles(colors), [colors]); + const navigation = useNavigation