diff --git a/blotztask-mobile/src/feature/settings/components/delete-reason-picker.tsx b/blotztask-mobile/src/feature/settings/components/delete-reason-picker.tsx new file mode 100644 index 000000000..9400c44ee --- /dev/null +++ b/blotztask-mobile/src/feature/settings/components/delete-reason-picker.tsx @@ -0,0 +1,78 @@ +import { Pressable, Text, TextInput, View } from "react-native"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { DeleteAccountReason } from "@/shared/constants/posthog-events"; + +export type PickableReason = Exclude; + +const REASONS: Exclude[] = [ + "no_longer_needed", + "too_complicated", + "missing_feature", + "ai_not_good", + "privacy_concern", + "switched_app", +]; + +/** Random order so the first option isn't over-picked; "other" always stays last. */ +function shuffledReasons(): PickableReason[] { + const order = [...REASONS]; + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + return [...order, "other"]; +} + +export const DELETE_REASON_OTHER_MAX_LENGTH = 200; + +type Props = { + reason: PickableReason | null; + otherText: string; + onReasonChange: (reason: PickableReason | null) => void; + onOtherTextChange: (text: string) => void; +}; + +/** + * Optional "main reason for deleting" question in the delete-account modal. + * Answering is never required, and tapping the chosen reason again clears it. + */ +export function DeleteReasonPicker({ reason, otherText, onReasonChange, onOtherTextChange }: Props) { + const { t } = useTranslation("settings"); + // Shuffled once per mount, so the order doesn't jump while the user is choosing. + const [reasons] = useState(shuffledReasons); + + return ( + + {t("account.deleteReasonTitle")} + + {reasons.map((key) => { + const selected = reason === key; + return ( + onReasonChange(selected ? null : key)} + className={`mr-2 mb-2 px-3 py-1 rounded-full border ${ + selected ? "bg-secondary border-secondary" : "border-gray-300" + }`} + > + + {t(`account.deleteReasons.${key}`)} + + + ); + })} + + {reason === "other" ? ( + + ) : null} + + ); +} diff --git a/blotztask-mobile/src/feature/settings/screens/settings-account-screen.tsx b/blotztask-mobile/src/feature/settings/screens/settings-account-screen.tsx index bb4e551f4..20f69d30a 100644 --- a/blotztask-mobile/src/feature/settings/screens/settings-account-screen.tsx +++ b/blotztask-mobile/src/feature/settings/screens/settings-account-screen.tsx @@ -8,9 +8,14 @@ import { FormDivider } from "@/shared/components/form-divider"; import { useUserProfile } from "@/shared/hooks/useUserProfile"; import LoadingScreen from "@/shared/components/loading-screen"; import { useLogout } from "@/shared/hooks/useLogout"; +import { analytics } from "@/shared/services/analytics"; import { useTranslation } from "react-i18next"; import Modal from "react-native-modal"; import { useDeleteUserMutation } from "@/feature/settings/hooks/useDeleteUserMutation"; +import { + DeleteReasonPicker, + type PickableReason, +} from "@/feature/settings/components/delete-reason-picker"; export default function SettingsAccountScreen() { const logout = useLogout(); @@ -20,6 +25,8 @@ export default function SettingsAccountScreen() { const { userProfile, isUserProfileLoading } = useUserProfile(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const [deleteConfirmChecked, setDeleteConfirmChecked] = useState(false); + const [deleteReason, setDeleteReason] = useState(null); + const [deleteReasonOtherText, setDeleteReasonOtherText] = useState(""); const displayName = userProfile?.displayName && userProfile.displayName.length > 20 @@ -32,12 +39,25 @@ export default function SettingsAccountScreen() { : (userProfile?.email ?? ""); const handleConfirmDelete = async () => { + // Queued before the delete request so it leaves while the user is still identified: + // logout resets PostHog right after. Analytics must never block the deletion. + try { + analytics.trackAccountDeleted({ + reason: deleteReason ?? "skipped", + otherText: deleteReasonOtherText.trim(), + }); + analytics.flush(); + } catch { + // ignore — deleting the account matters more than the event + } await deleteUser(); await logout(); }; const openDeleteModal = () => { setDeleteConfirmChecked(false); + setDeleteReason(null); + setDeleteReasonOtherText(""); setIsDeleteModalVisible(true); }; @@ -123,6 +143,7 @@ export default function SettingsAccountScreen() { animationOut="fadeOut" useNativeDriver style={{ margin: 0 }} + avoidKeyboard > {t("account.deleteTitle")} @@ -139,6 +160,13 @@ export default function SettingsAccountScreen() { {t("account.deleteWarning")} + + setDeleteConfirmChecked((prev) => !prev)} className="flex-row items-center mt-4" diff --git a/blotztask-mobile/src/i18n/locales/en/settings.json b/blotztask-mobile/src/i18n/locales/en/settings.json index fd738aa5b..d81f88189 100644 --- a/blotztask-mobile/src/i18n/locales/en/settings.json +++ b/blotztask-mobile/src/i18n/locales/en/settings.json @@ -29,7 +29,18 @@ "deleteWarning": "This permanently deletes your account and all data (tasks, notes, preferences). This can’t be undone.", "deleteConfirmText": "I understand this cannot be undone.", "deleteLoading": "Deleting your data...", - "deleteConfirm": "Delete permanently" + "deleteConfirm": "Delete permanently", + "deleteReasonTitle": "What’s the main reason you’re deleting your account?", + "deleteReasons": { + "no_longer_needed": "I no longer need this kind of app", + "too_complicated": "It's too complicated", + "missing_feature": "It's missing a feature I need", + "ai_not_good": "The AI results weren't good", + "privacy_concern": "I’m concerned about privacy or data security", + "switched_app": "I switched to another app", + "other": "Other" + }, + "deleteReasonOtherPlaceholder": "Tell us more..." }, "MembershipPlan": { "completedLabel": "Completed this month", diff --git a/blotztask-mobile/src/i18n/locales/zh/settings.json b/blotztask-mobile/src/i18n/locales/zh/settings.json index 75617ad8f..13f88e317 100644 --- a/blotztask-mobile/src/i18n/locales/zh/settings.json +++ b/blotztask-mobile/src/i18n/locales/zh/settings.json @@ -29,7 +29,18 @@ "deleteWarning": "将永久删除你的账号及所有数据(任务、笔记、偏好设置)。此操作无法撤销。", "deleteConfirmText": "我已了解此操作无法撤销。", "deleteLoading": "正在删除你的数据...", - "deleteConfirm": "永久删除" + "deleteConfirm": "永久删除", + "deleteReasonTitle": "你注销账号的主要原因是?(单选)", + "deleteReasons": { + "no_longer_needed": "我不再需要这类 App", + "too_complicated": "用起来太复杂", + "missing_feature": "缺少我需要的功能", + "ai_not_good": "AI 的结果不够好", + "privacy_concern": "担心隐私或数据安全", + "switched_app": "我换用了别的 App", + "other": "其他" + }, + "deleteReasonOtherPlaceholder": "多说一点..." }, "MembershipPlan": { "completedLabel": "本月已完成", diff --git a/blotztask-mobile/src/shared/constants/posthog-events.ts b/blotztask-mobile/src/shared/constants/posthog-events.ts index d6237df73..cb0ecb80b 100644 --- a/blotztask-mobile/src/shared/constants/posthog-events.ts +++ b/blotztask-mobile/src/shared/constants/posthog-events.ts @@ -38,6 +38,7 @@ export const EVENTS = { ONBOARDING_VOICE_MIC_PRESSED: "onboarding_voice_mic_pressed", ONBOARDING_VOICE_TASK_GENERATED: "onboarding_voice_task_generated", ONBOARDING_VOICE_TASK_CREATED: "onboarding_voice_task_created", + ACCOUNT_DELETED: "account_deleted", } as const; export const SCREEN_NAMES = { @@ -152,3 +153,18 @@ export type ShareContentType = "review" | "badge"; export type OnboardingOutcome = "completed" | "skipped"; export type OnboardingSection = "ai-voice" | "note" | "breakdown" | "invite"; + +/** + * Why the user deleted their account, from the optional question in the delete modal. + * Fixed keys, not the shown text, so zh and en answers land in one bucket and the wording + * can change without splitting the chart. `skipped` = the user answered nothing. + */ +export type DeleteAccountReason = + | "no_longer_needed" + | "too_complicated" + | "missing_feature" + | "ai_not_good" + | "privacy_concern" + | "switched_app" + | "other" + | "skipped"; diff --git a/blotztask-mobile/src/shared/services/analytics.ts b/blotztask-mobile/src/shared/services/analytics.ts index 6f7004059..9ea5af30a 100644 --- a/blotztask-mobile/src/shared/services/analytics.ts +++ b/blotztask-mobile/src/shared/services/analytics.ts @@ -22,6 +22,7 @@ import { type ShareContentType, type OnboardingOutcome, type OnboardingSection, + type DeleteAccountReason, } from "@/shared/constants/posthog-events"; type ScreenName = (typeof SCREEN_NAMES)[keyof typeof SCREEN_NAMES]; @@ -191,6 +192,18 @@ export const analytics = { }); }, + /** + * Fires just before the delete request, so the event is queued while the user is still + * identified — logout resets PostHog right after. Pair with `flush()`. + * `otherText` is only sent for `other`, and is capped at 200 characters by the input. + */ + trackAccountDeleted(params: { reason: DeleteAccountReason; otherText?: string }) { + posthog.capture(EVENTS.ACCOUNT_DELETED, { + reason: params.reason, + ...(params.reason === "other" && params.otherText ? { other_text: params.otherText } : {}), + }); + }, + /** Fires when the user reaches the onboarding screen. */ trackOnboardingStarted() { posthog.capture(EVENTS.ONBOARDING_STARTED);