diff --git a/android/app/build.gradle b/android/app/build.gradle index 6325892e..23a28b10 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -101,8 +101,8 @@ android { applicationId 'ca.psiphon.conduit' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 80 - versionName "2.1.2" + versionCode 81 + versionName "2.1.3" } signingConfigs { if (hasCustomKeystoreProperties) { diff --git a/app.json b/app.json index ad57b443..eae3bd7b 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "conduit", "slug": "conduit", - "version": "2.1.2", + "version": "2.1.3", "orientation": "portrait", "scheme": "ca.psiphon.conduit", "userInterfaceStyle": "automatic", @@ -10,7 +10,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "ca.psiphon.conduit", - "buildNumber": "31", + "buildNumber": "32", "deploymentTarget": "15.1", "icon": "./assets/images/conduit-launcher.png", "infoPlist": { @@ -19,7 +19,7 @@ }, "android": { "package": "ca.psiphon.conduit", - "versionCode": 80, + "versionCode": 81, "permissions": [ "android.permission.POST_NOTIFICATIONS", "com.android.vending.BILLING" diff --git a/assets/images/icons/delete-account.svg b/assets/images/icons/delete-account.svg new file mode 100644 index 00000000..3f1f6fa6 --- /dev/null +++ b/assets/images/icons/delete-account.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/icons/sign-out.svg b/assets/images/icons/sign-out.svg new file mode 100644 index 00000000..908784db --- /dev/null +++ b/assets/images/icons/sign-out.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/icons/subscription-active.svg b/assets/images/icons/subscription-active.svg new file mode 100644 index 00000000..50d8a93c --- /dev/null +++ b/assets/images/icons/subscription-active.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/images/icons/subscription-expired.svg b/assets/images/icons/subscription-expired.svg new file mode 100644 index 00000000..562f1a39 --- /dev/null +++ b/assets/images/icons/subscription-expired.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/images/icons/subscription-renews.svg b/assets/images/icons/subscription-renews.svg new file mode 100644 index 00000000..342421eb --- /dev/null +++ b/assets/images/icons/subscription-renews.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/images/icons/subscription.png b/assets/images/icons/subscription.png new file mode 100644 index 00000000..6a65b440 Binary files /dev/null and b/assets/images/icons/subscription.png differ diff --git a/ios/conduit/Info.plist b/ios/conduit/Info.plist index d8f4f3ad..d81aa5b4 100644 --- a/ios/conduit/Info.plist +++ b/ios/conduit/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.1.2 + 2.1.3 CFBundleSignature ???? CFBundleURLTypes @@ -33,7 +33,7 @@ CFBundleVersion - 31 + 32 ExpoLocalization_supportsRTL LSApplicationQueriesSchemes diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 64e6dbca..2e3d251d 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -64,6 +64,7 @@ export default function AppLayout() { + diff --git a/src/app/(app)/account.tsx b/src/app/(app)/account.tsx new file mode 100644 index 00000000..37dfa6de --- /dev/null +++ b/src/app/(app)/account.tsx @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Psiphon Inc. + * All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +import { Canvas, LinearGradient, Rect, vec } from "@shopify/react-native-skia"; +import React from "react"; +import { ScrollView, View, useWindowDimensions } from "react-native"; + +import { HostedConduitAccountPage } from "@/src/components/HostedConduitAccountPage"; +import { SafeAreaView } from "@/src/components/SafeAreaView"; +import { APP_MAX_CONTENT_WIDTH } from "@/src/constants"; + +export default function AccountScreen() { + const win = useWindowDimensions(); + + return ( + + + + + + + + + + + + + ); +} diff --git a/src/app/(app)/hosted-setup.tsx b/src/app/(app)/hosted-setup.tsx index d21b9c01..4bb4c953 100644 --- a/src/app/(app)/hosted-setup.tsx +++ b/src/app/(app)/hosted-setup.tsx @@ -16,13 +16,7 @@ * along with this program. If not, see . * */ -import { - Canvas, - Group, - LinearGradient, - Rect, - vec, -} from "@shopify/react-native-skia"; +import { Canvas, LinearGradient, Rect, vec } from "@shopify/react-native-skia"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Image as ExpoImage } from "expo-image"; import * as Linking from "expo-linking"; @@ -39,14 +33,18 @@ import { View, useWindowDimensions, } from "react-native"; -import { useSharedValue, withTiming } from "react-native-reanimated"; import { toErrorString } from "@/src/common/errors"; import { timedLog } from "@/src/common/utils"; +import { + AnimatedProvisioningStatusText, + HostedProvisioningHero, + useHostedProvisioningStages, + useSharedHostedProvisioningStatusStartedAtMs, +} from "@/src/components/HostedProvisioningScene"; import { HostedSetupSignInHero } from "@/src/components/HostedSetupSignInHero"; import { ProxyID } from "@/src/components/ProxyID"; import { SafeAreaView } from "@/src/components/SafeAreaView"; -import { OnboardingScene } from "@/src/components/canvas/OnboardingScene"; import { APPLE_STANDARD_EULA_URL, APP_MAX_CONTENT_WIDTH, @@ -73,6 +71,7 @@ import { useHostedExperienceActions, useHostedExperienceInitialSessionResolved, useHostedExperienceLastAuthProvider, + useHostedExperienceRevenueCatNativeActionPending, useHostedExperienceState, } from "@/src/hosted/experience/hooks"; import { shouldRouteToHostedActiveExperience } from "@/src/hosted/experience/navigation"; @@ -102,6 +101,7 @@ const APPLE_SIGN_IN_ICON = require("../../../assets/images/apple.png"); const NO_NETWORK_ICON = require("@/assets/images/icons/no-network.svg"); const HOSTED_PRIMARY_GRADIENT_START = "#7E5CB8"; const HOSTED_PRIMARY_GRADIENT_END = "rgba(156, 129, 201, 0.69)"; +const PROVISIONING_STATUS_BACKGROUND = "#0B58A4"; export default function HostedSetupScreen() { const { t } = useTranslation(); @@ -115,6 +115,8 @@ export default function HostedSetupScreen() { ); const state = useHostedExperienceState(); const initialSessionResolved = useHostedExperienceInitialSessionResolved(); + const revenueCatNativeActionPending = + useHostedExperienceRevenueCatNativeActionPending(); const lastAuthProvider = useHostedExperienceLastAuthProvider(); const actions = useHostedExperienceActions(); const revenueCat = useRevenueCatContext(); @@ -147,11 +149,6 @@ export default function HostedSetupScreen() { [isOffline, lastAuthProvider, state, t], ); const conduits = state.conduitsSnapshot?.conduits ?? []; - const sceneViewIndex = React.useMemo( - () => toSceneViewIndex(onboarding.primaryAction), - [onboarding.primaryAction], - ); - const sceneView = useSharedValue(sceneViewIndex); const attentionLogSignatureRef = React.useRef(null); React.useEffect(() => { @@ -217,10 +214,6 @@ export default function HostedSetupScreen() { state.stationPhase, ]); - React.useEffect(() => { - sceneView.value = withTiming(sceneViewIndex, { duration: 600 }); - }, [sceneView, sceneViewIndex]); - const bootstrapConduitsQuery = useQuery({ queryKey: ["hosted", "bootstrap-conduits", state.session?.accountId], enabled: @@ -430,11 +423,6 @@ export default function HostedSetupScreen() { purchaseMutation.isPending || restorePurchasesMutation.isPending; const recoverActionPending = recoverAccessMutation.isPending; - const activationInFlight = - purchaseMutation.isPending || - restorePurchasesMutation.isPending || - state.revenuecatPhase === "purchase_pending" || - state.revenuecatPhase === "restore_pending"; const setupReady = hasBaseUrl && hasClerkKey && hasRevenueCatKeyForPlatform; const storyParagraph = `${onboarding.detail} ${onboarding.helper}`; // Keep the loading spinner visible until both the persisted session AND the @@ -451,27 +439,26 @@ export default function HostedSetupScreen() { !initialSessionResolved || awaitingBootstrapAfterSessionLoad; const showTransitionLoading = state.authPhase === "authenticating" || signInMutation.isPending; - // Keep the provisioning screen visible when a purchase or restore mutation - // just succeeded but the conduits query observer hasn't propagated the - // final poll data yet. Without this guard there is a 1-frame render gap - // where the state falls back to "plan selection" before the redirect fires. - const purchaseSucceededAwaitingSync = - purchaseMutation.isSuccess && !canContinue; + // Do not mount the animated provisioning scene under RevenueCat's native + // purchase/restore UI. Only show it for an explicit hosted provisioning + // status or a resolved wait state after activation, not generic bootstrap + // or plan-loading waits. + const resolvedInfrastructureWait = + onboarding.primaryAction === "wait" && state.conduitsSnapshot !== null; const showProvisioningScreen = - onboarding.primaryAction === "wait" || - (activationInFlight && - (onboarding.primaryAction !== "share_or_manage" || - isRenewIntent)) || - purchaseSucceededAwaitingSync; + !revenueCatNativeActionPending && + (state.stationPhase === "provisioning" || resolvedInfrastructureWait); const loadingMessage = t("CONNECTING_TO_YOUR_HOSTED_CONDUIT_I18N.string"); const showPlanSelectionScreen = - onboarding.primaryAction === "activate_or_restore" || isRenewIntent; + onboarding.primaryAction === "activate_or_restore" || + isRenewIntent || + revenueCatNativeActionPending; + const primaryActionForControls = + isRenewIntent || revenueCatNativeActionPending + ? "activate_or_restore" + : onboarding.primaryAction; const showHostedSignInHero = !showPlanSelectionScreen && onboarding.primaryAction === "sign_in"; - const showSkiaScene = - !showPlanSelectionScreen && - onboarding.primaryAction !== "share_or_manage" && - onboarding.primaryAction !== "sign_in"; const bootstrapRefreshError = bootstrapConduitsQuery.error ? `Failed to refresh hosted setup status: ${toErrorString(bootstrapConduitsQuery.error)}` : null; @@ -488,14 +475,14 @@ export default function HostedSetupScreen() { ? null : actionError; - const contentWidth = Math.min(window.width, APP_MAX_CONTENT_WIDTH); const centeredContentStyle = { width: "100%" as const, maxWidth: APP_MAX_CONTENT_WIDTH, alignSelf: "center" as const, }; - const sceneWidth = Math.max(240, Math.min(contentWidth - 32, 520)); - const sceneHeight = Math.max(220, Math.min(window.height * 0.32, 320)); + const provisioningStages = useHostedProvisioningStages(); + const provisioningStatusStartedAtMs = + useSharedHostedProvisioningStatusStartedAtMs(showProvisioningScreen); if (showInitialLoading || showTransitionLoading) { return ( @@ -598,6 +585,75 @@ export default function HostedSetupScreen() { } if (showProvisioningScreen) { + return ( + + + + + + {t("PREPARING_HOSTED_CONDUIT_I18N.string")} + + + + + + + + ); + } + + if (onboarding.primaryAction === "wait") { return ( - - {t("SETTING_UP_INFRASTRUCTURE_I18N.string")} - - - {t("PROVISIONING_LEAVE_HINT_I18N.string")} + + {loadingMessage} - router.replace("/(app)")} - style={{ - borderWidth: 1, - borderColor: palette.purple, - borderRadius: 12, - paddingHorizontal: 32, - paddingVertical: 10, - marginTop: 4, - }} - > - - {t("CLOSE_I18N.string")} - - ); @@ -703,29 +732,6 @@ export default function HostedSetupScreen() { /> ) : null} - {showSkiaScene ? ( - - - - - - - - ) : null} {onboarding.primaryAction !== "sign_in" ? ( {showProvisioning ? ( - + ) : showRenew ? null : ( { @@ -202,28 +207,28 @@ export function ActionsArea({ ); } -function ProvisioningIndicator() { - const { t } = useTranslation(); +function ProvisioningStatusLine() { + const provisioningStages = useHostedProvisioningStages(); + const statusStartedAtMs = useSharedHostedProvisioningStatusStartedAtMs(); + return ( - - - {t("SETTING_UP_INFRASTRUCTURE_I18N.string")} - + ); } diff --git a/src/components/AppBottomNav.tsx b/src/components/AppBottomNav.tsx index aed36097..83890e41 100644 --- a/src/components/AppBottomNav.tsx +++ b/src/components/AppBottomNav.tsx @@ -46,7 +46,10 @@ export function AppBottomNav() { pathname.startsWith("/hosted-dashboard") || pathname.startsWith("/(app)/hosted-dashboard"); const isSettingsActive = - pathname === "/settings" || pathname === "/(app)/settings"; + pathname === "/settings" || + pathname === "/(app)/settings" || + pathname === "/account" || + pathname === "/(app)/account"; const items: BottomNavItem[] = [ { diff --git a/src/components/ConduitSettings.tsx b/src/components/ConduitSettings.tsx index b7fc0919..767197e5 100644 --- a/src/components/ConduitSettings.tsx +++ b/src/components/ConduitSettings.tsx @@ -45,7 +45,6 @@ import { DropdownSection } from "@/src/components/DropdownSection"; import { EditableConduitAlias } from "@/src/components/EditableConduitAlias"; import { EditableNumberSlider } from "@/src/components/EditableNumberSlider"; import { GitHash } from "@/src/components/GitHash"; -import { HostedConduitSettingsCard } from "@/src/components/HostedConduitSettingsCard"; import { Icon } from "@/src/components/Icon"; import { ReducedUsageWindow } from "@/src/components/ReducedUsageWindow"; import { RyveCallToAction } from "@/src/components/RyveCallToAction"; @@ -1048,10 +1047,19 @@ export function ConduitSettings({ inline = false }: { inline?: boolean }) { /> ) : null} - {/* Account */} - - - + {renderSettingsAction({ + icon: ( + + ), + label: t("ACCOUNT_I18N.string"), + subtitle: t("ACCOUNT_SETTINGS_DESCRIPTION_I18N.string"), + onPress: () => router.push("/(app)/account"), + })} {renderSettingsAction({ icon: ( diff --git a/src/components/HostedConduitAccountPage.tsx b/src/components/HostedConduitAccountPage.tsx new file mode 100644 index 00000000..2394f99a --- /dev/null +++ b/src/components/HostedConduitAccountPage.tsx @@ -0,0 +1,807 @@ +/* + * Copyright (c) 2026, Psiphon Inc. + * All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +import { useMutation } from "@tanstack/react-query"; +import { Image as ExpoImage } from "expo-image"; +import * as Linking from "expo-linking"; +import { useRouter } from "expo-router"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; + +import { toErrorString } from "@/src/common/errors"; +import { readOptionalStringField } from "@/src/common/recordUtils"; +import { Icon } from "@/src/components/Icon"; +import { resolveManageBillingUrl } from "@/src/hosted/billingUtils"; +import { readHostedRuntimeConfig } from "@/src/hosted/config"; +import { + useHostedExperienceActions, + useHostedExperienceState, +} from "@/src/hosted/experience/hooks"; +import { palette, sharedStyles as ss } from "@/src/styles"; + +const ACCOUNT_HORIZONTAL_PADDING = 32; +const ACCOUNT_ACTIVE_GREEN = "#16954E"; +const ACCOUNT_RENEWS_ORANGE = "#FD6E02"; +const ACCOUNT_DANGER_RED = "#EE262B"; +const EXPIRING_SOON_WINDOW_MS = 1000 * 60 * 60 * 24 * 14; + +type AccountActionVariant = "primary" | "secondary" | "danger"; +type SubscriptionChipKind = "active" | "renews" | "expires-soon" | "expired"; +type ExpoImageSource = React.ComponentProps["source"]; + +const SUBSCRIPTION_ICON = require("@/assets/images/icons/subscription.png"); +const SUBSCRIPTION_ACTIVE_ICON = require("@/assets/images/icons/subscription-active.svg"); +const SUBSCRIPTION_RENEWS_ICON = require("@/assets/images/icons/subscription-renews.svg"); +const SUBSCRIPTION_EXPIRED_ICON = require("@/assets/images/icons/subscription-expired.svg"); +const SIGN_OUT_ICON = require("@/assets/images/icons/sign-out.svg"); +const DELETE_ACCOUNT_ICON = require("@/assets/images/icons/delete-account.svg"); + +export function HostedConduitAccountPage() { + const { t } = useTranslation(); + const router = useRouter(); + const state = useHostedExperienceState(); + const actions = useHostedExperienceActions(); + const hostedConfig = React.useMemo(readHostedRuntimeConfig, []); + + const [subscriptionExpanded, setSubscriptionExpanded] = + React.useState(false); + const [actionError, setActionError] = React.useState(null); + const [actionNotice, setActionNotice] = React.useState(null); + const [confirmDelete, setConfirmDelete] = React.useState(false); + + const entitlementSnapshot = + (state.conduitsSnapshot?.entitlement as Record) ?? + null; + const manageBillingUrl = React.useMemo( + () => + resolveManageBillingUrl(hostedConfig.baseUrl, entitlementSnapshot), + [entitlementSnapshot, hostedConfig.baseUrl], + ); + const subscriptionStatus = + readOptionalStringField(entitlementSnapshot, "status") ?? + state.entitlementSnapshot; + const expiresAt = readOptionalStringField( + entitlementSnapshot, + "expires_at", + ); + const productId = readOptionalStringField( + entitlementSnapshot, + "product_id", + ); + + const signOutMutation = useMutation({ + mutationFn: async () => { + await actions.signOut(); + }, + onMutate: () => { + setActionError(null); + setActionNotice(null); + setConfirmDelete(false); + }, + onError: (error) => { + setActionError(toErrorString(error)); + }, + }); + + const renewMutation = useMutation({ + mutationFn: async () => { + router.push({ + pathname: "/(app)/hosted-setup", + params: { intent: "renew" }, + }); + }, + onMutate: () => { + setActionError(null); + setActionNotice(null); + }, + onError: (error) => { + setActionError(toErrorString(error)); + }, + }); + + const restorePurchasesMutation = useMutation({ + mutationFn: async () => { + await actions.restorePurchases(); + }, + onMutate: () => { + setActionError(null); + setActionNotice(null); + }, + onSuccess: () => { + setActionNotice( + t("PURCHASES_RESTORED_I18N.string", { + defaultValue: "Purchases restored.", + }), + ); + }, + onError: (error) => { + setActionError(toErrorString(error)); + }, + }); + + const deleteAccountMutation = useMutation({ + mutationFn: async () => { + await actions.deleteAccount(); + }, + onMutate: () => { + setActionError(null); + setActionNotice(null); + }, + onSuccess: () => { + setConfirmDelete(false); + setSubscriptionExpanded(false); + setActionNotice( + t("ACCOUNT_DELETED_I18N.string", { + defaultValue: "Account deleted.", + }), + ); + }, + onError: (error) => { + setActionError(toErrorString(error)); + }, + }); + + const effectiveStatus = subscriptionStatus; + const isSignedOut = state.authPhase === "signed_out"; + const showRenew = effectiveStatus === "canceled_not_expired"; + const isEntitlementLinked = + effectiveStatus === "active" || + effectiveStatus === "grace" || + effectiveStatus === "canceled_not_expired"; + const showRestorePurchases = !isEntitlementLinked; + const actionPending = + signOutMutation.isPending || + renewMutation.isPending || + restorePurchasesMutation.isPending || + deleteAccountMutation.isPending; + const subscriptionPresentation = resolveSubscriptionPresentation({ + status: effectiveStatus, + expiresAt, + productId, + t, + }); + + function goBack() { + if (router.canGoBack()) { + router.back(); + return; + } + router.replace("/(app)/settings"); + } + + return ( + + + + + + + + + {t("ACCOUNT_I18N.string")} + + + + {isSignedOut ? ( + router.push("/(app)/hosted-setup")} + /> + ) : ( + <> + + setSubscriptionExpanded((value) => !value) + } + /> + + {subscriptionExpanded ? ( + + {expiresAt ? ( + + ) : null} + {state.session?.accountId ? ( + + ) : null} + {showRenew ? ( + renewMutation.mutate()} + disabled={actionPending} + pending={renewMutation.isPending} + variant="primary" + /> + ) : null} + {manageBillingUrl ? ( + { + void Linking.openURL( + manageBillingUrl, + ).catch((error) => { + setActionError( + toErrorString(error), + ); + }); + }} + disabled={actionPending} + variant="secondary" + /> + ) : null} + {showRestorePurchases ? ( + + restorePurchasesMutation.mutate() + } + disabled={actionPending} + pending={ + restorePurchasesMutation.isPending + } + variant="secondary" + /> + ) : null} + + ) : null} + + + + signOutMutation.mutate()} + disabled={actionPending} + pending={signOutMutation.isPending} + /> + + + + setConfirmDelete(true)} + disabled={actionPending} + danger={true} + showChevron={false} + /> + + {confirmDelete ? ( + + + {t( + "DELETE_ACCOUNT_CONFIRMATION_I18N.string", + )} + + + deleteAccountMutation.mutate() + } + disabled={actionPending} + pending={deleteAccountMutation.isPending} + variant="danger" + /> + setConfirmDelete(false)} + disabled={actionPending} + variant="secondary" + /> + + ) : null} + + )} + + {actionNotice ? ( + + {actionNotice} + + ) : null} + + {actionError ? ( + + {actionError} + + ) : null} + + + ); +} + +function SubscriptionRow({ + presentation, + expanded, + onPress, +}: { + presentation: SubscriptionPresentation; + expanded: boolean; + onPress: () => void; +}) { + const { t } = useTranslation(); + + return ( + + + + + {t("ACCOUNT_SUBSCRIPTION_I18N.string")} + + + + {presentation.intervalLabel ? ( + + {presentation.intervalLabel} + + ) : null} + + {presentation.detail ? ( + + {presentation.detail} + + ) : null} + + + + + + ); +} + +function AccountActionRow({ + iconSource, + label, + onPress, + disabled = false, + pending = false, + danger = false, + showChevron = true, +}: { + iconSource: ExpoImageSource; + label: string; + onPress: () => void; + disabled?: boolean; + pending?: boolean; + danger?: boolean; + showChevron?: boolean; +}) { + const color = danger ? ACCOUNT_DANGER_RED : palette.black; + + return ( + + + + {label} + + {pending ? ( + + ) : showChevron ? ( + + ) : null} + + ); +} + +function SubscriptionChip({ + presentation, +}: { + presentation: SubscriptionPresentation; +}) { + const colors = resolveChipColors(presentation.kind); + + return ( + + + + {presentation.chipLabel} + + + ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function AccountPanelButton({ + label, + onPress, + disabled = false, + pending = false, + variant = "secondary", +}: { + label: string; + onPress: () => void; + disabled?: boolean; + pending?: boolean; + variant?: AccountActionVariant; +}) { + const isDanger = variant === "danger"; + const isPrimary = variant === "primary"; + const borderColor = isDanger ? ACCOUNT_DANGER_RED : palette.purple; + const backgroundColor = disabled + ? isDanger + ? palette.redTint5 + : palette.fadedMauve + : isPrimary + ? palette.purple + : palette.white; + const textColor = isDanger + ? ACCOUNT_DANGER_RED + : isPrimary + ? palette.white + : palette.black; + + return ( + + {pending ? ( + + ) : ( + + {label} + + )} + + ); +} + +function Divider() { + return ( + + ); +} + +type SubscriptionPresentation = { + kind: SubscriptionChipKind; + iconSource: ExpoImageSource; + chipLabel: string; + intervalLabel: string | null; + detail: string | null; +}; + +function resolveSubscriptionPresentation({ + status, + expiresAt, + productId, + t, +}: { + status: string | null | undefined; + expiresAt: string | null; + productId: string | null; + t: ReturnType["t"]; +}): SubscriptionPresentation { + const expiresAtMs = expiresAt ? Date.parse(expiresAt) : NaN; + const hasValidExpiry = Number.isFinite(expiresAtMs); + const expiresSoon = + hasValidExpiry && + expiresAtMs > Date.now() && + expiresAtMs - Date.now() <= EXPIRING_SOON_WINDOW_MS; + const formattedDate = expiresAt ? formatSubscriptionDate(expiresAt) : ""; + const intervalLabel = resolveSubscriptionInterval(productId, t); + + if (status === "active" || status === "grace") { + if (expiresSoon && formattedDate) { + return { + kind: "renews", + iconSource: SUBSCRIPTION_RENEWS_ICON, + chipLabel: t("ACCOUNT_RENEWS_DATE_I18N.string", { + date: formattedDate, + }), + intervalLabel, + detail: null, + }; + } + return { + kind: "active", + iconSource: SUBSCRIPTION_ACTIVE_ICON, + chipLabel: t("ACCOUNT_STATUS_ACTIVE_I18N.string"), + intervalLabel, + detail: formattedDate + ? t("ACCOUNT_RENEWS_ON_I18N.string", { + date: formattedDate, + }) + : null, + }; + } + + if (status === "canceled_not_expired") { + return { + kind: "expires-soon", + iconSource: SUBSCRIPTION_EXPIRED_ICON, + chipLabel: t("ACCOUNT_STATUS_EXPIRES_SOON_I18N.string"), + intervalLabel, + detail: formattedDate + ? t("ACCOUNT_EXPIRES_ON_I18N.string", { + date: formattedDate, + }) + : null, + }; + } + + return { + kind: "expired", + iconSource: SUBSCRIPTION_EXPIRED_ICON, + chipLabel: t("ACCOUNT_STATUS_EXPIRED_I18N.string"), + intervalLabel, + detail: null, + }; +} + +function resolveChipColors(kind: SubscriptionChipKind) { + if (kind === "active") { + return { + borderColor: "rgba(22, 149, 78, 0.54)", + backgroundColor: "rgba(22, 149, 78, 0.12)", + textColor: ACCOUNT_ACTIVE_GREEN, + }; + } + if (kind === "renews") { + return { + borderColor: "rgba(253, 110, 2, 0.54)", + backgroundColor: "rgba(253, 110, 2, 0.12)", + textColor: ACCOUNT_RENEWS_ORANGE, + }; + } + return { + borderColor: "rgba(0, 0, 0, 0.46)", + backgroundColor: "rgba(0, 0, 0, 0.12)", + textColor: palette.black, + }; +} + +function resolveSubscriptionInterval( + productId: string | null, + t: ReturnType["t"], +) { + const normalized = productId?.toLowerCase() ?? ""; + if (!normalized) { + return null; + } + if (/year|annual|12month/.test(normalized)) { + return t("ACCOUNT_PLAN_YEARLY_I18N.string"); + } + if (/month|monthly|1month/.test(normalized)) { + return t("ACCOUNT_PLAN_MONTHLY_I18N.string"); + } + return null; +} + +function formatSubscriptionDate(iso: string) { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return iso; + } + return date.toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }); +} diff --git a/src/components/HostedProvisioningScene.tsx b/src/components/HostedProvisioningScene.tsx new file mode 100644 index 00000000..14d71696 --- /dev/null +++ b/src/components/HostedProvisioningScene.tsx @@ -0,0 +1,809 @@ +/* + * Copyright (c) 2026, Psiphon Inc. + * All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +import { + Canvas, + Circle, + Group, + LinearGradient, + Path, + RadialGradient, + Rect, + vec, +} from "@shopify/react-native-skia"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { TextStyle, View, ViewStyle } from "react-native"; +import Animated, { + Easing, + Extrapolation, + SharedValue, + cancelAnimation, + interpolate, + useAnimatedStyle, + useDerivedValue, + useReducedMotion, + useSharedValue, + withDelay, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +import { OrbScene } from "@/src/components/orb-scene/OrbScene"; +import { palette, sharedStyles as ss } from "@/src/styles"; + +export interface HostedProvisioningStatusStage { + message: string; + durationMs?: number; +} + +let sharedProvisioningStatusStartedAtMs: number | null = null; + +const CONFIRMING_DURATION_MS = 2_000; +const LAUNCHING_DURATION_MS = 5_000; +const WAITING_PROVIDER_DURATION_MS = 10_000; +const PROVISIONING_DURATION_MS = 10_000; +const SCENE_END_SECONDS = 32; +const TAU = Math.PI * 2; +const ORBITING_ORBS_ENTRY_DELAY_MS = 900; +const ORBITING_ORBS_ENTRY_DURATION_MS = 1_250; +const ORBITING_ORBS_ORBIT_DURATION_MS = 7_200; +const PROVISIONING_SCENE_BACKGROUND_COLOR = "#3269BA"; + +export const HOSTED_PROVISIONING_STAGE_DURATIONS_MS = { + confirming: CONFIRMING_DURATION_MS, + launching: LAUNCHING_DURATION_MS, + waitingProvider: WAITING_PROVIDER_DURATION_MS, + provisioning: PROVISIONING_DURATION_MS, +}; + +export function useHostedProvisioningStages(): HostedProvisioningStatusStage[] { + const { t } = useTranslation(); + + return React.useMemo( + () => [ + { + message: t("HOSTED_PROVISIONING_CONFIRMING_I18N.string"), + durationMs: HOSTED_PROVISIONING_STAGE_DURATIONS_MS.confirming, + }, + { + message: t("HOSTED_PROVISIONING_LAUNCHING_I18N.string"), + durationMs: HOSTED_PROVISIONING_STAGE_DURATIONS_MS.launching, + }, + { + message: t("HOSTED_PROVISIONING_WAITING_PROVIDER_I18N.string"), + durationMs: + HOSTED_PROVISIONING_STAGE_DURATIONS_MS.waitingProvider, + }, + { + message: t("HOSTED_PROVISIONING_PROVISIONING_I18N.string"), + durationMs: HOSTED_PROVISIONING_STAGE_DURATIONS_MS.provisioning, + }, + { + message: t("HOSTED_PROVISIONING_WAITING_METRICS_I18N.string"), + }, + ], + [t], + ); +} + +export function useSharedHostedProvisioningStatusStartedAtMs( + active = true, +): number | undefined { + const [startedAtMs, setStartedAtMs] = React.useState( + () => (active ? getSharedProvisioningStatusStartedAtMs() : undefined), + ); + + React.useEffect(() => { + if (!active) { + return; + } + + setStartedAtMs(getSharedProvisioningStatusStartedAtMs()); + }, [active]); + + return active + ? (startedAtMs ?? getSharedProvisioningStatusStartedAtMs()) + : undefined; +} + +function getSharedProvisioningStatusStartedAtMs(): number { + if (sharedProvisioningStatusStartedAtMs == null) { + sharedProvisioningStatusStartedAtMs = Date.now(); + } + + return sharedProvisioningStatusStartedAtMs; +} + +export function HostedProvisioningHero({ + width, + height, + stages, + statusStartedAtMs, + fullBleed = false, + showStatus = true, + statusContainerStyle, +}: { + width: number; + height: number; + stages: HostedProvisioningStatusStage[]; + statusStartedAtMs?: number; + fullBleed?: boolean; + showStatus?: boolean; + statusContainerStyle?: ViewStyle; +}) { + const sceneHeight = Math.max(250, height); + + return ( + + + + + + + + {showStatus ? ( + + + + ) : null} + + ); +} + +export function AnimatedProvisioningStatusText({ + stages, + startedAtMs, + compact = false, + containerStyle, + textStyle, +}: { + stages: HostedProvisioningStatusStage[]; + startedAtMs?: number; + compact?: boolean; + containerStyle?: ViewStyle; + textStyle?: TextStyle; +}) { + const reducedMotion = useReducedMotion(); + const [stageIndex, setStageIndex] = React.useState(() => + getProvisioningStageIndex(stages, startedAtMs), + ); + const opacity = useSharedValue(1); + const translateY = useSharedValue(0); + const currentStage = stages[stageIndex]; + const swapTimerRef = React.useRef | null>( + null, + ); + const nextStageDelayMs = getProvisioningNextStageDelayMs( + stages, + stageIndex, + startedAtMs, + ); + + React.useEffect(() => { + setStageIndex(getProvisioningStageIndex(stages, startedAtMs)); + opacity.value = 1; + translateY.value = 0; + }, [opacity, stages, startedAtMs, translateY]); + + React.useEffect(() => { + if (nextStageDelayMs == null) { + return; + } + + const transitionMs = reducedMotion ? 0 : 220; + const stageTimer = setTimeout(() => { + const nextStageIndex = startedAtMs + ? getProvisioningStageIndex(stages, startedAtMs) + : Math.min(stageIndex + 1, stages.length - 1); + + if (transitionMs === 0) { + setStageIndex(nextStageIndex); + return; + } + + opacity.value = withTiming(0, { + duration: transitionMs, + easing: Easing.out(Easing.quad), + }); + translateY.value = withTiming(compact ? -5 : -8, { + duration: transitionMs, + easing: Easing.out(Easing.quad), + }); + + swapTimerRef.current = setTimeout(() => { + setStageIndex(nextStageIndex); + translateY.value = compact ? 5 : 8; + opacity.value = withTiming(1, { + duration: 280, + easing: Easing.out(Easing.cubic), + }); + translateY.value = withTiming(0, { + duration: 280, + easing: Easing.out(Easing.cubic), + }); + }, transitionMs); + }, nextStageDelayMs); + + return () => { + clearTimeout(stageTimer); + if (swapTimerRef.current) { + clearTimeout(swapTimerRef.current); + swapTimerRef.current = null; + } + }; + }, [ + compact, + nextStageDelayMs, + opacity, + reducedMotion, + stageIndex, + startedAtMs, + stages, + stages.length, + translateY, + ]); + + const animatedTextStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateY: translateY.value }], + })); + + if (!currentStage?.message) { + return null; + } + + return ( + + + {currentStage.message} + + + ); +} + +function getProvisioningStageIndex( + stages: HostedProvisioningStatusStage[], + startedAtMs?: number, +): number { + if (!startedAtMs) { + return 0; + } + + let elapsedMs = Math.max(0, Date.now() - startedAtMs); + for (let index = 0; index < stages.length; index++) { + const durationMs = stages[index]?.durationMs; + if (!durationMs || elapsedMs < durationMs) { + return index; + } + elapsedMs -= durationMs; + } + + return Math.max(0, stages.length - 1); +} + +function getProvisioningNextStageDelayMs( + stages: HostedProvisioningStatusStage[], + stageIndex: number, + startedAtMs?: number, +): number | null { + const currentStage = stages[stageIndex]; + if (!currentStage?.durationMs || stageIndex >= stages.length - 1) { + return null; + } + if (!startedAtMs) { + return currentStage.durationMs; + } + + const stageEndElapsedMs = stages + .slice(0, stageIndex + 1) + .reduce((sum, stage) => sum + (stage.durationMs ?? 0), 0); + + return Math.max(0, stageEndElapsedMs - (Date.now() - startedAtMs)); +} + +function OrbitingProvisioningOrbs({ + width, + height, +}: { + width: number; + height: number; +}) { + const reducedMotion = useReducedMotion(); + const orbit = useSharedValue(reducedMotion ? 0.12 : 0); + const entryProgress = useSharedValue(reducedMotion ? 1 : 0); + const sceneScale = Math.min(width, height); + const orbBoxSize = Math.max(82, Math.min(118, sceneScale * 0.38)); + const centerX = width * 0.58; + const centerY = height * 0.25; + const orbitRadius = Math.max(22, Math.min(40, sceneScale * 0.12)); + + React.useEffect(() => { + cancelAnimation(orbit); + cancelAnimation(entryProgress); + + if (reducedMotion) { + orbit.value = 0.12; + entryProgress.value = 1; + return; + } + + orbit.value = 0; + entryProgress.value = 0; + orbit.value = withRepeat( + withTiming(1, { + duration: ORBITING_ORBS_ORBIT_DURATION_MS, + easing: Easing.linear, + }), + -1, + false, + ); + entryProgress.value = withDelay( + ORBITING_ORBS_ENTRY_DELAY_MS, + withTiming(1, { + duration: ORBITING_ORBS_ENTRY_DURATION_MS, + easing: Easing.linear, + }), + ); + + return () => { + cancelAnimation(orbit); + cancelAnimation(entryProgress); + }; + }, [entryProgress, orbit, reducedMotion]); + + const firstOrbStyle = useAnimatedStyle(() => { + const entry = springyEase(entryProgress.value); + const opacityProgress = entryProgress.value; + const angle = orbit.value * TAU; + const scale = interpolate(entry, [0, 1], [0.18, 1]); + + return { + opacity: interpolate(opacityProgress, [0, 0.24, 1], [0, 0.72, 1]), + transform: [ + { + translateX: + centerX - + orbBoxSize / 2 + + Math.cos(angle) * orbitRadius * entry, + }, + { + translateY: + centerY - + orbBoxSize / 2 + + Math.sin(angle) * orbitRadius * 0.56 * entry + + (1 - entry) * height * 0.04, + }, + { scale }, + ], + }; + }); + const secondOrbStyle = useAnimatedStyle(() => { + const entry = springyEase(entryProgress.value); + const opacityProgress = entryProgress.value; + const angle = orbit.value * TAU + Math.PI; + const scale = interpolate(entry, [0, 1], [0.14, 1]); + + return { + opacity: interpolate(opacityProgress, [0, 0.24, 1], [0, 0.72, 1]), + transform: [ + { + translateX: + centerX - + orbBoxSize / 2 + + Math.cos(angle) * orbitRadius * entry, + }, + { + translateY: + centerY - + orbBoxSize / 2 + + Math.sin(angle) * orbitRadius * 0.56 * entry + + (1 - entry) * height * 0.04, + }, + { scale }, + ], + }; + }); + + return ( + + + + + + + + + ); +} + +function HostedProvisioningTimelineScene({ + width, + height, +}: { + width: number; + height: number; +}) { + const reducedMotion = useReducedMotion(); + const timeline = useSharedValue(reducedMotion ? 28 : 0); + + React.useEffect(() => { + cancelAnimation(timeline); + + if (reducedMotion) { + timeline.value = 28; + return; + } + + timeline.value = 0; + timeline.value = withTiming(SCENE_END_SECONDS, { + duration: SCENE_END_SECONDS * 1000, + easing: Easing.linear, + }); + + return () => { + cancelAnimation(timeline); + }; + }, [reducedMotion, timeline]); + + const backgroundColors = [ + "#0B58A4", + PROVISIONING_SCENE_BACKGROUND_COLOR, + "#7A74CD", + "#FFFFFF", + "#FFFFFF", + ]; + return ( + + + + + + + + + + + + ); +} + +function IsometricLandscape({ + width, + height, + timeline, +}: { + width: number; + height: number; + timeline: SharedValue; +}) { + const tileW = width * 0.105; + const tileH = tileW * 0.44; + const originX = width * 0.5; + const originY = height * 0.58; + const horizonY = height * 0.32; + const tileFill = "rgba(255,255,255,0.16)"; + const alternateTileFill = "rgba(255,255,255,0.09)"; + const tileStroke = "rgba(255,238,230,0.44)"; + const floorPath = React.useMemo(() => { + const topY = horizonY - tileH * 0.5; + const shoulderY = height * 0.76; + const bottomY = height + tileH * 4; + + return `M ${originX} ${topY} L ${width + tileW * 5.2} ${shoulderY} L ${width + tileW * 3.2} ${bottomY} L ${-tileW * 3.2} ${bottomY} L ${-tileW * 5.2} ${shoulderY} Z`; + }, [height, horizonY, originX, tileH, tileW, width]); + const cellSpecs = React.useMemo(() => { + const renderedCells: { + key: string; + cx: number; + cy: number; + path: string; + alt: boolean; + opacity: number; + strokeWidth: number; + entryDelay: number; + entryDuration: number; + entryOffsetX: number; + entryOffsetY: number; + }[] = []; + + for (let row = -22; row <= 28; row++) { + for (let col = -22; col <= 28; col++) { + const cx = originX + ((col - row) * tileW) / 2; + const cy = originY + ((col + row) * tileH) / 2; + if ( + cx < -tileW * 3.8 || + cx > width + tileW * 3.8 || + cy < horizonY || + cy > height + tileH * 2.8 + ) { + continue; + } + + const distance = Math.min( + 1, + Math.max(0, (cy - horizonY) / (height - horizonY)), + ); + const entryNoise = cellNoise(col, row, 3); + const speedNoise = cellNoise(col, row, 4); + + renderedCells.push({ + key: `${col}:${row}`, + cx, + cy, + path: diamondPath(cx, cy, tileW, tileH), + alt: Math.abs(col + row) % 2 === 0, + opacity: 0.08 + distance * 0.92, + strokeWidth: 0.6 + distance * 0.55, + entryDelay: 0.02 + entryNoise * 0.5 + distance * 0.2, + entryDuration: 0.9 + speedNoise * 0.9, + entryOffsetX: (cellNoise(col, row, 5) - 0.5) * tileW * 1.2, + entryOffsetY: + height * 0.12 + + (cellNoise(col, row, 6) - 0.5) * tileH * 2.8, + }); + } + } + return renderedCells.sort((a, b) => a.cy - b.cy || a.cx - b.cx); + }, [height, horizonY, originX, originY, tileH, tileW, width]); + + return ( + + + + + {cellSpecs.map((cell) => ( + + ))} + + ); +} + +function IsometricCell({ + cell, + tileFill, + tileStroke, + timeline, +}: { + cell: { + cx: number; + cy: number; + path: string; + opacity: number; + strokeWidth: number; + entryDelay: number; + entryDuration: number; + entryOffsetX: number; + entryOffsetY: number; + }; + tileFill: string; + tileStroke: string; + timeline: SharedValue; +}) { + const cellProgress = useDerivedValue(() => { + const rawProgress = interpolate( + timeline.value, + [cell.entryDelay, cell.entryDelay + cell.entryDuration], + [0, 1], + Extrapolation.CLAMP, + ); + + return springyEase(rawProgress); + }); + const transform = useDerivedValue(() => [ + { translateX: (1 - cellProgress.value) * cell.entryOffsetX }, + { translateY: (1 - cellProgress.value) * cell.entryOffsetY }, + ]); + const opacity = useDerivedValue(() => cellProgress.value * cell.opacity); + + return ( + + + + + ); +} + +function diamondPath( + cx: number, + cy: number, + width: number, + height: number, +): string { + return `M ${cx} ${cy - height / 2} L ${cx + width / 2} ${cy} L ${cx} ${cy + height / 2} L ${cx - width / 2} ${cy} Z`; +} + +function cellNoise(col: number, row: number, salt = 0): number { + const value = Math.sin(col * 12.9898 + row * 78.233 + salt * 37.719); + + return value * 43758.5453 - Math.floor(value * 43758.5453); +} + +function springyEase(progress: number): number { + "worklet"; + + const clampedProgress = Math.max(0, Math.min(1, progress)); + const overshoot = 1.36; + + return ( + 1 + + (overshoot + 1) * (clampedProgress - 1) ** 3 + + overshoot * (clampedProgress - 1) ** 2 + ); +} diff --git a/src/components/PersonalPairingShareModal.tsx b/src/components/PersonalPairingShareModal.tsx index 6e87fe87..10d7fa23 100644 --- a/src/components/PersonalPairingShareModal.tsx +++ b/src/components/PersonalPairingShareModal.tsx @@ -17,7 +17,6 @@ * */ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { Canvas, LinearGradient, Rect, vec } from "@shopify/react-native-skia"; import * as Clipboard from "expo-clipboard"; import { Image } from "expo-image"; import React from "react"; @@ -127,7 +126,7 @@ const LANGUAGE_COPY: Record = { }, }; -const PRIMARY_GRADIENT_COLORS = ["#A475E3", "rgba(156, 129, 201, 0.69)"]; +const PRIMARY_CTA_PURPLE = "#A475E3"; export function PersonalPairingShareModal({ personalCompartmentId, @@ -141,7 +140,6 @@ export function PersonalPairingShareModal({ const { closeModal, pushModal } = useModal(); const [selectedLanguage, setSelectedLanguageState] = React.useState("en"); - const [shareButtonWidth, setShareButtonWidth] = React.useState(0); const [notice, setNotice] = React.useState(null); const normalizedName = normalizeNickname(storedConduitName ?? ""); @@ -403,11 +401,6 @@ export function PersonalPairingShareModal({ }} > { - setShareButtonWidth( - event.nativeEvent.layout.width, - ); - }} disabled={!canSharePairingLink} onPress={() => { void sharePairingLink(); @@ -419,7 +412,7 @@ export function PersonalPairingShareModal({ { alignSelf: "stretch", borderRadius: 12, - backgroundColor: palette.transparent, + backgroundColor: PRIMARY_CTA_PURPLE, height: 52, marginTop: 4, overflow: "hidden", @@ -427,34 +420,6 @@ export function PersonalPairingShareModal({ }, ]} > - - - - - - - - - + const overlay = ( + ); + + if (!enabled) { + return highlighted ? overlay : null; + } + + return {overlay}; } interface ProvisioningMarkerProps { diff --git a/src/git-hash.ts b/src/git-hash.ts index 01ee21ca..4f7d014a 100644 --- a/src/git-hash.ts +++ b/src/git-hash.ts @@ -1 +1 @@ -export const GIT_HASH = "v2.1.2"; +export const GIT_HASH = "2.2.0"; diff --git a/src/hosted/experience/context.test.tsx b/src/hosted/experience/context.test.tsx index 0d2d7aa8..df7736ff 100644 --- a/src/hosted/experience/context.test.tsx +++ b/src/hosted/experience/context.test.tsx @@ -682,6 +682,87 @@ describe("hosted experience context", () => { expect(contextValue!.lastAuthProvider).toBe("google"); }); + it("persists the auth provider hint before hosted login completes", async () => { + const signInDeferred = + createDeferred>>(); + const session = makeSession({ + accessToken: "access.login", + accessTokenExpiresAtMs: 90_000, + refreshTokenExpiresAtMs: 1_000_000, + }); + const authService = makeAuthService({ + signIn: jest.fn().mockReturnValue(signInDeferred.promise), + }); + const sessionClient = makeSessionClient({ + loadHostedSession: jest.fn().mockResolvedValue(null), + login: jest.fn().mockResolvedValue(session), + }); + const hostedClient = makeHostedClient({ + getConduitsSnapshot: jest.fn().mockResolvedValue({ + entitlement: { + status: "inactive", + product_id: "test.product.primary", + }, + conduits: [], + }), + }); + + let contextValue: HostedExperienceContextValue | null = null; + function Consumer() { + contextValue = useHostedExperienceContext(); + return null; + } + + await act(async () => { + renderHostedExperience( + { + baseUrl: "https://hcb.example.test", + now: () => 10_000, + authService, + sessionClient, + hostedClient, + revenueCat: makeRevenueCatContext(), + }, + , + ); + }); + + let signInPromise: Promise | null = null; + await act(async () => { + signInPromise = contextValue!.signIn("google"); + await flushPromises(); + }); + + await waitFor(() => { + expect(contextValue!.lastAuthProvider).toBe("google"); + }); + await expect( + SecureStore.getItemAsync(SECURESTORE_HOSTED_LAST_AUTH_PROVIDER_KEY), + ).resolves.toContain('"provider":"google"'); + expect(sessionClient.login).not.toHaveBeenCalled(); + + await act(async () => { + signInDeferred.resolve({ + provider: "google", + tokenType: "clerk_broker_jwt", + brokerToken: "clerk.broker.jwt", + platform: "android", + clientVersion: "2.0.0", + }); + await signInPromise!; + }); + + expect(sessionClient.login).toHaveBeenCalledWith({ + token_type: "clerk_broker_jwt", + broker_token: "clerk.broker.jwt", + platform: "android", + client_version: "2.0.0", + }); + await waitFor(() => { + expect(contextValue!.state.authPhase).toBe("authenticated"); + }); + }); + it("clears the auth hint and upstream auth session on explicit sign out", async () => { const session = makeSession({ accessToken: "access.login", @@ -1736,6 +1817,20 @@ async function waitFor(assertion: () => void): Promise { throw lastError; } +function createDeferred(): { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + function makeRevenueCatContext( overrides?: Partial, ): RevenueCatContextValue { diff --git a/src/hosted/experience/context.tsx b/src/hosted/experience/context.tsx index 3a1c30df..57237571 100644 --- a/src/hosted/experience/context.tsx +++ b/src/hosted/experience/context.tsx @@ -48,6 +48,7 @@ import { useOptionalHostedAuthService } from "@/src/hosted/auth/provider"; import { createStubHostedAuthService } from "@/src/hosted/auth/service"; import { HostedAuthService, + HostedAuthServiceError, HostedAuthSignInResult, } from "@/src/hosted/auth/types"; import { @@ -117,6 +118,7 @@ export interface HostedExperienceContextValue extends HostedExperienceActions { state: HostedExperienceState; initialSessionResolved: boolean; hostedSnapshotBootstrapPending: boolean; + revenueCatNativeActionPending: boolean; lastAuthProvider: OAuthProvider | null; } @@ -273,6 +275,23 @@ function HostedExperienceProviderInner( }, }); + const rememberAuthProvider = React.useCallback( + async (provider: OAuthProvider) => { + queryClient.setQueryData( + hostedQueryKeys.authProviderHint(baseUrl), + provider, + ); + try { + await persistHostedLastAuthProvider(baseUrl, provider); + } catch (error) { + timedLog( + `Hosted auth provider hint persistence deferred: ${toErrorMessage(error)}`, + ); + } + }, + [baseUrl, queryClient], + ); + const completeHostedAuth = React.useCallback( async ( authResult: HostedAuthSignInResult, @@ -326,14 +345,7 @@ function HostedExperienceProviderInner( } if (options.persistAuthProviderHint) { - await persistHostedLastAuthProvider( - baseUrl, - authResult.provider, - ); - queryClient.setQueryData( - hostedQueryKeys.authProviderHint(baseUrl), - authResult.provider, - ); + await rememberAuthProvider(authResult.provider); } await queryClient.fetchQuery({ queryKey: hostedQueryKeys.revenueCat( @@ -368,6 +380,7 @@ function HostedExperienceProviderInner( props.revenueCat, props.revenueCatPublicKeys, queryClient, + rememberAuthProvider, sessionClient, sessionDeps, ], @@ -380,8 +393,27 @@ function HostedExperienceProviderInner( persistAuthProviderHint: true, }); }, - onMutate: () => { + onMutate: async (provider) => { + const previousAuthProvider = authProviderHintQuery.data ?? null; setRevenuecatNotice(null); + await rememberAuthProvider(provider); + return { previousAuthProvider }; + }, + onError: async (error, _provider, context) => { + if ( + error instanceof HostedAuthServiceError && + error.code === "cancelled" + ) { + if (context?.previousAuthProvider) { + await rememberAuthProvider(context.previousAuthProvider); + return; + } + await clearHostedLastAuthProvider(); + queryClient.setQueryData( + hostedQueryKeys.authProviderHint(baseUrl), + null, + ); + } }, }); const restoreSignInMutation = useMutation({ @@ -404,11 +436,19 @@ function HostedExperienceProviderInner( const [purchaseInflight, setPurchaseInflight] = React.useState(false); const [purchaseNeedsFreshEntitlement, setPurchaseNeedsFreshEntitlement] = React.useState(false); + const [revenueCatNativeActionPending, setRevenueCatNativeActionPending] = + React.useState(false); const purchaseMutation = useMutation({ mutationFn: async (aPackage: PurchasesPackage) => { const previousEntitlementStatus = currentEntitlementStatus; - const purchaseResult = - await props.revenueCat.purchasePackage(aPackage); + let purchaseResult; + setRevenueCatNativeActionPending(true); + try { + purchaseResult = + await props.revenueCat.purchasePackage(aPackage); + } finally { + setRevenueCatNativeActionPending(false); + } const session = await ensureHostedSession(queryClient, sessionDeps); queryClient.setQueryData( hostedQueryKeys.revenueCat(baseUrl, session.accountId), @@ -457,7 +497,13 @@ function HostedExperienceProviderInner( const [restoreInflight, setRestoreInflight] = React.useState(false); const restoreMutation = useMutation({ mutationFn: async () => { - const restoreResult = await props.revenueCat.restorePurchases(); + let restoreResult; + setRevenueCatNativeActionPending(true); + try { + restoreResult = await props.revenueCat.restorePurchases(); + } finally { + setRevenueCatNativeActionPending(false); + } const session = await ensureHostedSession(queryClient, sessionDeps); queryClient.setQueryData( hostedQueryKeys.revenueCat(baseUrl, session.accountId), @@ -694,6 +740,7 @@ function HostedExperienceProviderInner( purchaseMutation.reset(); restoreMutation.reset(); updateAccountAliasMutation.reset(); + setRevenueCatNativeActionPending(false); setPurchaseInflight(false); setPurchaseNeedsFreshEntitlement(false); setRestoreInflight(false); @@ -739,6 +786,7 @@ function HostedExperienceProviderInner( state, initialSessionResolved, hostedSnapshotBootstrapPending, + revenueCatNativeActionPending, lastAuthProvider, signIn, signOut, @@ -759,6 +807,7 @@ function HostedExperienceProviderInner( lastAuthProvider, pollConduitsOnce, purchaseMutation, + revenueCatNativeActionPending, refreshSession, refreshSessionIfNeeded, restoreMutation, diff --git a/src/hosted/experience/hooks.ts b/src/hosted/experience/hooks.ts index 224811f5..7491c611 100644 --- a/src/hosted/experience/hooks.ts +++ b/src/hosted/experience/hooks.ts @@ -76,6 +76,10 @@ export function useHostedExperienceSnapshotBootstrapPending(): boolean { return useHostedExperienceContext().hostedSnapshotBootstrapPending; } +export function useHostedExperienceRevenueCatNativeActionPending(): boolean { + return useHostedExperienceContext().revenueCatNativeActionPending; +} + export function useHostedExperienceLastAuthProvider(): OAuthProvider | null { return useHostedExperienceContext().lastAuthProvider; } diff --git a/src/i18n/locales/ar-xb/translation.json b/src/i18n/locales/ar-xb/translation.json index 54b02420..898ffc2d 100644 --- a/src/i18n/locales/ar-xb/translation.json +++ b/src/i18n/locales/ar-xb/translation.json @@ -455,6 +455,21 @@ "SIGN_OUT_I18N": { "string": "‮Sıƃu‬ ‮onʇ‬" }, + "DELETE_ACCOUNT_I18N": { + "string": "‮pǝʅǝʇǝ‬ ‮ɐɔɔonuʇ‬" + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "‮pǝʅǝʇǝ‬ ‮ɐɔɔonuʇ‬ ‮ʍıʅʅ‬ ‮pǝʅǝʇǝ‬ ‮ʎonɹ‬ ‮Hosʇǝp‬ ‮Ↄoupnıʇ‬ ‮ɐup‬ ‮ɹǝɯoʌǝ‬ ‮ʎonɹ‬ ‮ɐɔɔonuʇ‬ ‮ɟɹoɯ‬ ‮onɹ‬ ‮sǝɹʌǝɹs‬. ‮Iɟ‬ ‮ʎon‬ ‮ɔnɹɹǝuʇʅʎ‬ ‮ɥɐʌǝ‬ ‮ɐu‬ ‮ɐɔʇıʌǝ‬ ‮snqsɔɹıdʇıou‬, ‮ʎonɹ‬ ‮snqsɔɹıdʇıou‬ ‮ɯɐʎ‬ ‮ɔouʇıunǝ‬ ‮qıʅʅıuƃ‬ ‮nuʇıʅ‬ ‮ʎon‬ ‮ɔɐuɔǝʅ‬ ‮ıʇ‬ ‮ʇɥɹonƃɥ‬ ‮ʇɥǝ‬ ‮∀dd‬ ‮Sʇoɹǝ‬ ‮oɹ‬ ‮פooƃʅǝ‬ ‮Ԁʅɐʎ‬." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "‮pǝʅǝʇǝ‬ ‮ɐɔɔonuʇ‬" + }, + "ACCOUNT_DELETED_I18N": { + "string": "‮∀ɔɔonuʇ‬ ‮pǝʅǝʇǝp‬." + }, + "PURCHASES_RESTORED_I18N": { + "string": "‮Ԁnɹɔɥɐsǝs‬ ‮ɹǝsʇoɹǝp‬." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "‮Ↄʅosǝ‬ ‮ɔoupnıʇ‬ ‮pǝʇɐıʅs‬" }, @@ -551,6 +566,9 @@ "HOSTED_CONDUIT_ORB_TAP_ACCESSIBILITY_I18N": { "string": "‮Hosʇǝp‬ ‮ɔoupnıʇ‬ ‮oɹq‬. ‮⊥ɐd‬ ‮ɟoɹ‬ ‮pǝʇɐıʅs‬" }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "‮Hosʇǝp‬ ‮Ↄoupnıʇ‬, ‮dɹoʌısıouıuƃ‬ ‮ɥosʇ‬" + }, "SIGN_IN_TO_SYNC_I18N": { "string": "‮Sıƃu‬ ‮ıu‬ ‮ʇo‬ ‮sʎuɔ‬ ‮ʎonɹ‬ ‮ɥosʇǝp‬ ‮ɐɔɔonuʇ‬" }, @@ -588,7 +606,7 @@ "string": "‮Hosʇǝp‬ ‮ɔoupnıʇ‬ ‮sɔǝuǝ‬ ‮qnʇʇou‬" }, "PREPARING_HOSTED_CONDUIT_I18N": { - "string": "‮Ԁɹǝdɐɹıuƃ‬ ‮ʎonɹ‬ ‮Hosʇǝp‬ ‮Ↄoupnıʇ‬..." + "string": "‮Ԁɹǝdɐɹıuƃ‬ ‮Hosʇǝp‬ ‮Ↄoupnıʇ‬" }, "PREPARING_I18N": { "string": "‮Ԁɹǝdɐɹıuƃ‬..." @@ -600,7 +618,22 @@ "string": "‮Sǝʇʇıuƃ‬ ‮nd‬ ‮ʎonɹ‬ ‮ıuɟɹɐsʇɹnɔʇnɹǝ‬..." }, "PROVISIONING_LEAVE_HINT_I18N": { - "string": "‮ʎon‬ ‮ɯɐʎ‬ ‮ʅǝɐʌǝ‬ ‮ʇɥıs‬ ‮sɔɹǝǝu‬ ‮ʍɥıʅǝ‬ ‮ʎonɹ‬ ‮ıuɟɹɐsʇɹnɔʇnɹǝ‬ ‮ıs‬ ‮dɹoʌısıouıuƃ‬." + "string": "‮ʎon‬ ‮ɯɐʎ‬ ‮ʅǝɐʌǝ‬ ‮ʇɥıs‬ ‮dɐƃǝ‬ ‮ʍɥıʅǝ‬ ‮ʇɥǝ‬ ‮ɥosʇ‬ ‮ıs‬ ‮dɹoʌısıouǝp‬." + }, + "HOSTED_PROVISIONING_CONFIRMING_I18N": { + "string": "‮Ↄouɟıɹɯıuƃ‬..." + }, + "HOSTED_PROVISIONING_LAUNCHING_I18N": { + "string": "‮˥ɐnuɔɥıuƃ‬ ‮Hosʇǝp‬ ‮Ↄoupnıʇ‬..." + }, + "HOSTED_PROVISIONING_WAITING_PROVIDER_I18N": { + "string": "‮Mɐıʇıuƃ‬ ‮ɟoɹ‬ ‮Hosʇıuƃ‬ ‮Ԁɹoʌıpǝɹ‬..." + }, + "HOSTED_PROVISIONING_PROVISIONING_I18N": { + "string": "‮Ԁɹoʌısıouıuƃ‬ ‮Hosʇǝp‬ ‮Ↄoupnıʇ‬..." + }, + "HOSTED_PROVISIONING_WAITING_METRICS_I18N": { + "string": "‮Mɐıʇıuƃ‬ ‮ɟoɹ‬ ‮Wǝʇɹıɔs‬..." }, "OPENING_YOUR_DASHBOARD_I18N": { "string": "‮Odǝuıuƃ‬ ‮ʎonɹ‬ ‮pɐsɥqoɐɹp‬..." diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 130f7f88..35eef7cc 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -505,6 +505,26 @@ "string": "تسجيل الخروج", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "حذف الحساب", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "سيؤدي حذف الحساب إلى حذف Conduit المستضاف الخاص بك وإزالة حسابك من خوادمنا. إذا كان لديك حاليًا اشتراك نشط، فقد يستمر تحصيل رسوم اشتراكك إلى أن تلغيه من خلال App Store أو Google Play.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "حذف الحساب", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "تم حذف الحساب.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "تمت استعادة المشتريات.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "إغلاق تفاصيل conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "كرة conduit المستضاف. اضغط للتفاصيل", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit مستضاف، جارٍ تجهيز المضيف", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "سجّل الدخول لمزامنة حسابك المستضاف", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index c80b0d09..df120f9d 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -505,6 +505,26 @@ "string": "Abmelden", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Konto löschen", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Durch das Löschen des Kontos wird Ihr gehosteter Conduit gelöscht und Ihr Konto von unseren Servern entfernt. Wenn Sie derzeit ein aktives Abonnement haben, kann Ihr Abonnement weiter abgerechnet werden, bis Sie es über den App Store oder Google Play kündigen.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Konto löschen", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Konto gelöscht.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Käufe wiederhergestellt.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Conduit-Details schließen", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Gehosteter Conduit-Orb. Für Details tippen", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Gehosteter Conduit, Host wird bereitgestellt", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Melden Sie sich an, um Ihr gehostetes Konto zu synchronisieren", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/en-xa/translation.json b/src/i18n/locales/en-xa/translation.json index 90a782cf..c3a6a320 100644 --- a/src/i18n/locales/en-xa/translation.json +++ b/src/i18n/locales/en-xa/translation.json @@ -455,6 +455,21 @@ "SIGN_OUT_I18N": { "string": "Şīīɠƞ ǿǿŭŭŧ" }, + "DELETE_ACCOUNT_I18N": { + "string": "Ḓḗḗŀḗḗŧḗḗ ȧȧƈƈǿǿŭŭƞŧ" + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Ḓḗḗŀḗḗŧḗḗ ȧȧƈƈǿǿŭŭƞŧ ẇīīŀŀ ḓḗḗŀḗḗŧḗḗ ẏǿǿŭŭř Ħǿǿşŧḗḗḓ Ƈǿǿƞḓŭŭīīŧ ȧȧƞḓ řḗḗḿǿǿṽḗḗ ẏǿǿŭŭř ȧȧƈƈǿǿŭŭƞŧ ƒřǿǿḿ ǿǿŭŭř şḗḗřṽḗḗřş. Īīƒ ẏǿǿŭŭ ƈŭŭřřḗḗƞŧŀẏ ħȧȧṽḗḗ ȧȧƞ ȧȧƈŧīīṽḗḗ şŭŭƀşƈřīīƥŧīīǿǿƞ, ẏǿǿŭŭř şŭŭƀşƈřīīƥŧīīǿǿƞ ḿȧȧẏ ƈǿǿƞŧīīƞŭŭḗḗ ƀīīŀŀīīƞɠ ŭŭƞŧīīŀ ẏǿǿŭŭ ƈȧȧƞƈḗḗŀ īīŧ ŧħřǿǿŭŭɠħ ŧħḗḗ Ȧȧƥƥ Şŧǿǿřḗḗ ǿǿř Ɠǿǿǿǿɠŀḗḗ Ƥŀȧȧẏ." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Ḓḗḗŀḗḗŧḗḗ ȧȧƈƈǿǿŭŭƞŧ" + }, + "ACCOUNT_DELETED_I18N": { + "string": "Ȧȧƈƈǿǿŭŭƞŧ ḓḗḗŀḗḗŧḗḗḓ." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Ƥŭŭřƈħȧȧşḗḗş řḗḗşŧǿǿřḗḗḓ." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Ƈŀǿǿşḗḗ ƈǿǿƞḓŭŭīīŧ ḓḗḗŧȧȧīīŀş" }, @@ -551,6 +566,9 @@ "HOSTED_CONDUIT_ORB_TAP_ACCESSIBILITY_I18N": { "string": "Ħǿǿşŧḗḗḓ ƈǿǿƞḓŭŭīīŧ ǿǿřƀ. Ŧȧȧƥ ƒǿǿř ḓḗḗŧȧȧīīŀş" }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Ħǿǿşŧḗḗḓ Ƈǿǿƞḓŭŭīīŧ, ƥřǿǿṽīīşīīǿǿƞīīƞɠ ħǿǿşŧ" + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Şīīɠƞ īīƞ ŧǿǿ şẏƞƈ ẏǿǿŭŭř ħǿǿşŧḗḗḓ ȧȧƈƈǿǿŭŭƞŧ" }, @@ -588,7 +606,7 @@ "string": "Ħǿǿşŧḗḗḓ ƈǿǿƞḓŭŭīīŧ şƈḗḗƞḗḗ ƀŭŭŧŧǿǿƞ" }, "PREPARING_HOSTED_CONDUIT_I18N": { - "string": "Ƥřḗḗƥȧȧřīīƞɠ ẏǿǿŭŭř Ħǿǿşŧḗḗḓ Ƈǿǿƞḓŭŭīīŧ..." + "string": "Ƥřḗḗƥȧȧřīīƞɠ Ħǿǿşŧḗḗḓ Ƈǿǿƞḓŭŭīīŧ" }, "PREPARING_I18N": { "string": "Ƥřḗḗƥȧȧřīīƞɠ..." @@ -600,7 +618,22 @@ "string": "Şḗḗŧŧīīƞɠ ŭŭƥ ẏǿǿŭŭř īīƞƒřȧȧşŧřŭŭƈŧŭŭřḗḗ..." }, "PROVISIONING_LEAVE_HINT_I18N": { - "string": "Ẏǿǿŭŭ ḿȧȧẏ ŀḗḗȧȧṽḗḗ ŧħīīş şƈřḗḗḗḗƞ ẇħīīŀḗḗ ẏǿǿŭŭř īīƞƒřȧȧşŧřŭŭƈŧŭŭřḗḗ īīş ƥřǿǿṽīīşīīǿǿƞīīƞɠ." + "string": "Ẏǿǿŭŭ ḿȧȧẏ ŀḗḗȧȧṽḗḗ ŧħīīş ƥȧȧɠḗḗ ẇħīīŀḗḗ ŧħḗḗ ħǿǿşŧ īīş ƥřǿǿṽīīşīīǿǿƞḗḗḓ." + }, + "HOSTED_PROVISIONING_CONFIRMING_I18N": { + "string": "Ƈǿǿƞƒīīřḿīīƞɠ..." + }, + "HOSTED_PROVISIONING_LAUNCHING_I18N": { + "string": "Ŀȧȧŭŭƞƈħīīƞɠ Ħǿǿşŧḗḗḓ Ƈǿǿƞḓŭŭīīŧ..." + }, + "HOSTED_PROVISIONING_WAITING_PROVIDER_I18N": { + "string": "Ẇȧȧīīŧīīƞɠ ƒǿǿř Ħǿǿşŧīīƞɠ Ƥřǿǿṽīīḓḗḗř..." + }, + "HOSTED_PROVISIONING_PROVISIONING_I18N": { + "string": "Ƥřǿǿṽīīşīīǿǿƞīīƞɠ Ħǿǿşŧḗḗḓ Ƈǿǿƞḓŭŭīīŧ..." + }, + "HOSTED_PROVISIONING_WAITING_METRICS_I18N": { + "string": "Ẇȧȧīīŧīīƞɠ ƒǿǿř Ḿḗḗŧřīīƈş..." }, "OPENING_YOUR_DASHBOARD_I18N": { "string": "Ǿǿƥḗḗƞīīƞɠ ẏǿǿŭŭř ḓȧȧşħƀǿǿȧȧřḓ..." diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index bdbec5c6..43c8b8e8 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -485,6 +485,46 @@ "string": "Account", "developer_comment": "Header label for the account section." }, + "ACCOUNT_SETTINGS_DESCRIPTION_I18N": { + "string": "Manage your account settings", + "developer_comment": "Settings row subtitle for the account screen." + }, + "ACCOUNT_SUBSCRIPTION_I18N": { + "string": "Subscription", + "developer_comment": "Account screen row label for subscription management." + }, + "ACCOUNT_STATUS_ACTIVE_I18N": { + "string": "Active", + "developer_comment": "Subscription status chip for an active hosted subscription." + }, + "ACCOUNT_STATUS_EXPIRES_SOON_I18N": { + "string": "Expires soon", + "developer_comment": "Subscription status chip for a hosted subscription that is ending soon." + }, + "ACCOUNT_STATUS_EXPIRED_I18N": { + "string": "Expired", + "developer_comment": "Subscription status chip for an expired hosted subscription." + }, + "ACCOUNT_RENEWS_DATE_I18N": { + "string": "Renews {{date}}", + "developer_comment": "Subscription status chip showing an upcoming renewal date." + }, + "ACCOUNT_RENEWS_ON_I18N": { + "string": "Renews {{date}}", + "developer_comment": "Subscription detail text showing the renewal date." + }, + "ACCOUNT_EXPIRES_ON_I18N": { + "string": "Expires {{date}}", + "developer_comment": "Subscription detail text showing the expiration date." + }, + "ACCOUNT_PLAN_YEARLY_I18N": { + "string": "Yearly", + "developer_comment": "Subscription billing interval label for yearly plans." + }, + "ACCOUNT_PLAN_MONTHLY_I18N": { + "string": "Monthly", + "developer_comment": "Subscription billing interval label for monthly plans." + }, "RENEW_SUBSCRIPTION_I18N": { "string": "Renew Subscription", "developer_comment": "Button label to renew a subscription." @@ -510,7 +550,7 @@ "developer_comment": "Button label to initiate account deletion." }, "DELETE_ACCOUNT_CONFIRMATION_I18N": { - "string": "You currently have an active subscription. Delete account will delete your hosted servers, but your subscription may continue billing until you cancel it through the App Store or Google Play.", + "string": "Delete account will delete your Hosted Conduit and remove your account from our servers. If you currently have an active subscription, your subscription may continue billing until you cancel it through the App Store or Google Play.", "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." }, "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { @@ -521,10 +561,6 @@ "string": "Account deleted.", "developer_comment": "Confirmation shown after account deletion succeeds." }, - "RESTORE_PURCHASES_I18N": { - "string": "Restore Purchases", - "developer_comment": "Button label for restoring App Store or Play Store purchases." - }, "PURCHASES_RESTORED_I18N": { "string": "Purchases restored.", "developer_comment": "Confirmation shown after purchases restore successfully." @@ -708,8 +744,8 @@ "developer_comment": "Accessibility label for the hosted conduit scene selector." }, "PREPARING_HOSTED_CONDUIT_I18N": { - "string": "Preparing your Hosted Conduit...", - "developer_comment": "Loading message during hosted conduit setup." + "string": "Preparing Hosted Conduit", + "developer_comment": "Title shown on the hosted conduit provisioning screen." }, "PREPARING_I18N": { "string": "Preparing...", @@ -724,9 +760,29 @@ "developer_comment": "Loading message while hosted infrastructure is being provisioned." }, "PROVISIONING_LEAVE_HINT_I18N": { - "string": "You may leave this screen while your infrastructure is provisioning.", + "string": "You may leave this page while the host is provisioned.", "developer_comment": "Helper text on the provisioning screen indicating the user does not need to wait." }, + "HOSTED_PROVISIONING_CONFIRMING_I18N": { + "string": "Confirming...", + "developer_comment": "Animated provisioning status message shown for the initial confirmation step." + }, + "HOSTED_PROVISIONING_LAUNCHING_I18N": { + "string": "Launching Hosted Conduit...", + "developer_comment": "Animated provisioning status message shown while the hosted conduit launch animation is playing." + }, + "HOSTED_PROVISIONING_WAITING_PROVIDER_I18N": { + "string": "Waiting for Hosting Provider...", + "developer_comment": "Animated provisioning status message shown while waiting for the hosting provider." + }, + "HOSTED_PROVISIONING_PROVISIONING_I18N": { + "string": "Provisioning Hosted Conduit...", + "developer_comment": "Animated provisioning status message shown while hosted conduit infrastructure is provisioning." + }, + "HOSTED_PROVISIONING_WAITING_METRICS_I18N": { + "string": "Waiting for Metrics...", + "developer_comment": "Animated provisioning status message shown after provisioning while waiting for metrics." + }, "OPENING_YOUR_DASHBOARD_I18N": { "string": "Opening your dashboard...", "developer_comment": "Loading message while dashboard is being opened." @@ -912,7 +968,7 @@ "developer_comment": "Hosted onboarding helper when the plan needs reactivation." }, "HOSTED_ONBOARDING_WAIT_HEADLINE_I18N": { - "string": "We are preparing your secure infrastructure", + "string": "Preparing your infrastructure", "developer_comment": "Hosted onboarding headline while setup is in progress." }, "HOSTED_ONBOARDING_WAIT_DETAIL_I18N": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 273c1615..5c85d8f8 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -505,6 +505,26 @@ "string": "Cerrar sesión", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Eliminar cuenta", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Al eliminar la cuenta se eliminará tu Conduit alojado y se quitará tu cuenta de nuestros servidores. Si actualmente tienes una suscripción activa, es posible que la suscripción se siga cobrando hasta que la canceles a través de App Store o Google Play.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Eliminar cuenta", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Cuenta eliminada.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Compras restauradas.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Cerrar detalles de Conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Orbe de Conduit alojado. Toca para ver detalles", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit alojado, host en aprovisionamiento", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Inicia sesión para sincronizar tu cuenta alojada", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/fa/translation.json b/src/i18n/locales/fa/translation.json index ab0b84db..718367de 100644 --- a/src/i18n/locales/fa/translation.json +++ b/src/i18n/locales/fa/translation.json @@ -505,6 +505,26 @@ "string": "خروج", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "حذف حساب", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "حذف حساب، Conduit میزبانی‌شده شما را حذف می‌کند و حساب شما را از سرورهای ما حذف می‌کند. اگر در حال حاضر اشتراک فعالی دارید، ممکن است صورت‌حساب اشتراک شما تا زمانی که آن را از طریق App Store یا Google Play لغو کنید ادامه یابد.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "حذف حساب", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "حساب حذف شد.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "خریدها بازیابی شدند.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "بستن جزئیات conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "گوی conduit میزبانی‌شده. برای جزئیات ضربه بزنید", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit میزبانی‌شده، میزبان در حال آماده‌سازی است", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "برای همگام‌سازی حساب میزبانی‌شده خود وارد شوید", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index ecbe6aee..36b98d6e 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -505,6 +505,26 @@ "string": "Se déconnecter", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Supprimer le compte", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "La suppression du compte supprimera votre Conduit hébergé et retirera votre compte de nos serveurs. Si vous avez actuellement un abonnement actif, votre abonnement peut continuer à être facturé jusqu'à ce que vous l'annuliez via l'App Store ou Google Play.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Supprimer le compte", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Compte supprimé.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Achats restaurés.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Fermer les détails du conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Orbe Conduit hébergé. Appuyez pour les détails", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit hébergé, hôte en cours de provisionnement", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Connectez-vous pour synchroniser votre compte hébergé", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index 48177b6e..69cf89b4 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -505,6 +505,26 @@ "string": "साइन आउट करें", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "खाता हटाएं", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "खाता हटाने से आपका होस्टेड कान्डूइट हट जाएगा और आपका खाता हमारे सर्वर से हटा दिया जाएगा। यदि आपके पास अभी सक्रिय सदस्यता है, तो आपकी सदस्यता का बिल तब तक जारी रह सकता है जब तक आप उसे App Store या Google Play के माध्यम से रद्द नहीं करते।", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "खाता हटाएं", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "खाता हटाया गया।", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "खरीदारी पुनर्स्थापित हुई।", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "कान्डूइट विवरण बंद करें", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "होस्टेड कान्डूइट ऑर्ब। विवरण के लिए टैप करें", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "होस्टेड कान्डूइट, होस्ट का प्रावधान हो रहा है", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "अपना होस्टेड खाता सिंक करने के लिए साइन इन करें", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/id/translation.json b/src/i18n/locales/id/translation.json index 46fae783..26a19074 100644 --- a/src/i18n/locales/id/translation.json +++ b/src/i18n/locales/id/translation.json @@ -505,6 +505,26 @@ "string": "Keluar", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Hapus akun", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Menghapus akun akan menghapus Conduit yang dihosting milik Anda dan menghapus akun Anda dari server kami. Jika saat ini Anda memiliki langganan aktif, tagihan langganan Anda dapat terus berjalan hingga Anda membatalkannya melalui App Store atau Google Play.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Hapus akun", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Akun dihapus.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Pembelian dipulihkan.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Tutup detail conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Orb conduit yang dihosting. Ketuk untuk detail", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit yang dihosting, host sedang diprovisikan", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Masuk untuk menyinkronkan akun yang dihosting", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/pt_BR/translation.json b/src/i18n/locales/pt_BR/translation.json index a673c3f3..4f899082 100644 --- a/src/i18n/locales/pt_BR/translation.json +++ b/src/i18n/locales/pt_BR/translation.json @@ -505,6 +505,26 @@ "string": "Sair", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Excluir conta", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Excluir a conta excluirá seu Conduit hospedado e removerá sua conta dos nossos servidores. Se você tem uma assinatura ativa no momento, a cobrança da sua assinatura pode continuar até que você a cancele pela App Store ou pelo Google Play.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Excluir conta", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Conta excluída.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Compras restauradas.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Fechar detalhes do conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Orbe do Conduit hospedado. Toque para detalhes", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit hospedado, host sendo provisionado", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Entre para sincronizar sua conta hospedada", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/pt_PT/translation.json b/src/i18n/locales/pt_PT/translation.json index 5a313587..02fb1d69 100644 --- a/src/i18n/locales/pt_PT/translation.json +++ b/src/i18n/locales/pt_PT/translation.json @@ -505,6 +505,26 @@ "string": "Terminar sessão", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Eliminar conta", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Eliminar a conta irá eliminar o seu Conduit alojado e remover a sua conta dos nossos servidores. Se tiver uma subscrição ativa atualmente, a cobrança da sua subscrição poderá continuar até a cancelar através da App Store ou do Google Play.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Eliminar conta", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Conta eliminada.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Compras restauradas.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Fechar detalhes do conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Orbe Conduit alojado. Toque para detalhes", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit alojado, host a ser provisionado", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Inicie sessão para sincronizar a sua conta alojada", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/sw/translation.json b/src/i18n/locales/sw/translation.json index 90bbc91a..55f3b677 100644 --- a/src/i18n/locales/sw/translation.json +++ b/src/i18n/locales/sw/translation.json @@ -505,6 +505,26 @@ "string": "Ondoka", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Futa akaunti", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Ukifuta akaunti, Conduit yako Iliyohifadhiwa itafutwa na akaunti yako itaondolewa kwenye seva zetu. Ikiwa kwa sasa una usajili unaotumika, usajili wako unaweza kuendelea kutozwa hadi uufute kupitia App Store au Google Play.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Futa akaunti", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Akaunti imefutwa.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Ununuzi umerejeshwa.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Funga maelezo ya conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Tufe la conduit iliyohifadhiwa. Gusa kwa maelezo", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit Iliyohifadhiwa, mwenyeji anaandaliwa", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Ingia ili kusawazisha akaunti yako iliyohifadhiwa", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index d435aa0d..9ebb8dfc 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -505,6 +505,26 @@ "string": "Oturumu kapat", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Hesabı sil", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Hesabı silmek Barındırılan Conduit'inizi siler ve hesabınızı sunucularımızdan kaldırır. Şu anda aktif bir aboneliğiniz varsa, aboneliğiniz siz App Store veya Google Play üzerinden iptal edene kadar faturalandırılmaya devam edebilir.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Hesabı sil", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Hesap silindi.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Satın alımlar geri yüklendi.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Conduit ayrıntılarını kapat", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Barındırılan conduit küresi. Ayrıntılar için dokunun", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Barındırılan Conduit, ana makine hazırlanıyor", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Barındırılan hesabınızı senkronize etmek için oturum açın", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/ur/translation.json b/src/i18n/locales/ur/translation.json index 08b26c64..69d8cffa 100644 --- a/src/i18n/locales/ur/translation.json +++ b/src/i18n/locales/ur/translation.json @@ -505,6 +505,26 @@ "string": "سائن آؤٹ کریں", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "اکاؤنٹ حذف کریں", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "اکاؤنٹ حذف کرنے سے آپ کا ہوسٹڈ Conduit حذف ہو جائے گا اور آپ کا اکاؤنٹ ہمارے سرورز سے ہٹا دیا جائے گا۔ اگر فی الحال آپ کی فعال سبسکرپشن ہے، تو آپ کی سبسکرپشن کی بلنگ اس وقت تک جاری رہ سکتی ہے جب تک آپ اسے App Store یا Google Play کے ذریعے منسوخ نہیں کرتے۔", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "اکاؤنٹ حذف کریں", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "اکاؤنٹ حذف ہو گیا۔", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "خریداریاں بحال ہو گئیں۔", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "conduit کی تفصیلات بند کریں", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "ہوسٹڈ conduit آرب۔ تفصیلات کے لیے ٹیپ کریں", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "ہوسٹڈ Conduit، ہوسٹ پروویژن ہو رہا ہے", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "اپنا ہوسٹڈ اکاؤنٹ سنک کرنے کے لیے سائن ان کریں", "developer_comment": "Hint shown when user needs to sign in for hosted account sync." diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 84023704..f23597ef 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -505,6 +505,26 @@ "string": "Đăng xuất", "developer_comment": "Button label to sign out of the account." }, + "DELETE_ACCOUNT_I18N": { + "string": "Xóa tài khoản", + "developer_comment": "Button label to initiate account deletion." + }, + "DELETE_ACCOUNT_CONFIRMATION_I18N": { + "string": "Xóa tài khoản sẽ xóa Conduit được Lưu trữ của bạn và gỡ tài khoản của bạn khỏi máy chủ của chúng tôi. Nếu bạn hiện có gói đăng ký đang hoạt động, gói đăng ký của bạn có thể tiếp tục bị tính phí cho đến khi bạn hủy gói qua App Store hoặc Google Play.", + "developer_comment": "Confirmation warning shown before deleting an account with an active subscription." + }, + "DELETE_ACCOUNT_CONFIRM_BUTTON_I18N": { + "string": "Xóa tài khoản", + "developer_comment": "Final confirmation button for account deletion." + }, + "ACCOUNT_DELETED_I18N": { + "string": "Tài khoản đã bị xóa.", + "developer_comment": "Confirmation shown after account deletion succeeds." + }, + "PURCHASES_RESTORED_I18N": { + "string": "Giao dịch mua đã được khôi phục.", + "developer_comment": "Confirmation shown after purchases restore successfully." + }, "CLOSE_CONDUIT_DETAILS_ACCESSIBILITY_I18N": { "string": "Đóng chi tiết conduit", "developer_comment": "Accessibility label for the close button in the hosted conduit modal." @@ -633,6 +653,10 @@ "string": "Quả cầu conduit được lưu trữ. Chạm để xem chi tiết", "developer_comment": "Accessibility label for the hosted conduit orb." }, + "HOSTED_CONDUIT_PROVISIONING_ORB_ACCESSIBILITY_I18N": { + "string": "Conduit được Lưu trữ, máy chủ đang được cung cấp", + "developer_comment": "Accessibility label for a hosted conduit orb whose host is still provisioning." + }, "SIGN_IN_TO_SYNC_I18N": { "string": "Đăng nhập để đồng bộ tài khoản được lưu trữ của bạn", "developer_comment": "Hint shown when user needs to sign in for hosted account sync."