Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<DeleteAccountReason, "skipped">;

const REASONS: Exclude<PickableReason, "other">[] = [
"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 (
<View className="mt-4">
<Text className="text-sm font-baloo text-secondary">{t("account.deleteReasonTitle")}</Text>
<View className="flex-row flex-wrap mt-2">
{reasons.map((key) => {
const selected = reason === key;
return (
<Pressable
key={key}
onPress={() => onReasonChange(selected ? null : key)}
className={`mr-2 mb-2 px-3 py-1 rounded-full border ${
selected ? "bg-secondary border-secondary" : "border-gray-300"
}`}
>
<Text className={`text-xs font-baloo ${selected ? "text-white" : "text-secondary"}`}>
{t(`account.deleteReasons.${key}`)}
</Text>
</Pressable>
);
})}
</View>
{reason === "other" ? (
<TextInput
value={otherText}
onChangeText={onOtherTextChange}
maxLength={DELETE_REASON_OTHER_MAX_LENGTH}
placeholder={t("account.deleteReasonOtherPlaceholder")}
multiline
className="mt-1 border border-gray-300 rounded-xl px-3 py-2 text-sm font-baloo text-secondary max-h-24"
/>
) : null}
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<PickableReason | null>(null);
const [deleteReasonOtherText, setDeleteReasonOtherText] = useState("");

const displayName =
userProfile?.displayName && userProfile.displayName.length > 20
Expand All @@ -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);
};

Expand Down Expand Up @@ -123,6 +143,7 @@ export default function SettingsAccountScreen() {
animationOut="fadeOut"
useNativeDriver
style={{ margin: 0 }}
avoidKeyboard
>
<View className="bg-white rounded-2xl p-6 mx-6">
<Text className="text-xl font-baloo text-secondary">{t("account.deleteTitle")}</Text>
Expand All @@ -139,6 +160,13 @@ export default function SettingsAccountScreen() {
{t("account.deleteWarning")}
</Text>

<DeleteReasonPicker
reason={deleteReason}
otherText={deleteReasonOtherText}
onReasonChange={setDeleteReason}
onOtherTextChange={setDeleteReasonOtherText}
/>

<Pressable
onPress={() => setDeleteConfirmChecked((prev) => !prev)}
className="flex-row items-center mt-4"
Expand Down
13 changes: 12 additions & 1 deletion blotztask-mobile/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion blotztask-mobile/src/i18n/locales/zh/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "本月已完成",
Expand Down
16 changes: 16 additions & 0 deletions blotztask-mobile/src/shared/constants/posthog-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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";
13 changes: 13 additions & 0 deletions blotztask-mobile/src/shared/services/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand Down
Loading