Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/cool-rockets-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

💄 drop unfreeze card label
5 changes: 5 additions & 0 deletions .changeset/frank-snails-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

✨ add card freeze confirmation sheet
5 changes: 5 additions & 0 deletions .changeset/six-breads-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

✨ show frozen overlay on card
5 changes: 5 additions & 0 deletions .changeset/social-icons-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

✨ add freeze controls to home card
5 changes: 5 additions & 0 deletions .changeset/spotty-rules-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

✨ create shared switch component
5 changes: 5 additions & 0 deletions .changeset/tangy-deer-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

💄 add haptic feedback to card actions
6 changes: 5 additions & 1 deletion .maestro/subflows/activateCard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ appId: ${APP_ID ?? "app.exactly"}
- assertVisible: Manually add your card to Apple Pay & Google Pay to make contactless payments.
- tapOn: Close
- tapOn: Freeze card
- tapOn: Unfreeze card
- assertVisible: Freeze your card?
- tapOn: Freeze card
- assertNotVisible: Freeze your card?
- waitForAnimationToEnd
- tapOn: Freeze card
Comment on lines +14 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make freeze/unfreeze assertions deterministic to avoid false positives.

tapOn: Freeze card is reused for both the sheet CTA and the card row, and the second tap can be ignored if isSettingCardStatus is still true (src/components/card/Card.tsx, onPress guard). This can pass the flow without actually validating unfreeze.

Proposed flow hardening
 - assertVisible: Freeze your card?
-- tapOn: Freeze card
+- tapOn:
+    text: Freeze card
+    below: Freeze your card?
 - assertNotVisible: Freeze your card?
 - waitForAnimationToEnd
 - tapOn: Freeze card
+- waitForAnimationToEnd
+- tapOn: Freeze card
+- assertVisible: Freeze your card?
+- tapOn: Cancel
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- assertVisible: Freeze your card?
- tapOn: Freeze card
- assertNotVisible: Freeze your card?
- waitForAnimationToEnd
- tapOn: Freeze card
- assertVisible: Freeze your card?
- tapOn:
text: Freeze card
below: Freeze your card?
- assertNotVisible: Freeze your card?
- waitForAnimationToEnd
- tapOn: Freeze card
- waitForAnimationToEnd
- tapOn: Freeze card
- assertVisible: Freeze your card?
- tapOn: Cancel

- tapOn: View PIN number
- tapOn: Close
- tapOn: Weekly spending limit
Expand Down
55 changes: 29 additions & 26 deletions src/components/card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import React, { useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { RefreshControl } from "react-native";

import { selectionAsync } from "expo-haptics";
import { useRouter } from "expo-router";

import { ChevronRight, CircleHelp, CreditCard, DollarSign, Eye, EyeOff, Hash, Snowflake } from "@tamagui/lucide-icons";
import { useToastController } from "@tamagui/toast";
import { ScrollView, Separator, Spinner, Square, Switch, XStack, YStack } from "tamagui";
import { ScrollView, Separator, Spinner, Square, XStack, YStack } from "tamagui";

import { useMutation, useQuery } from "@tanstack/react-query";

Expand All @@ -16,6 +17,7 @@ import { useReadUpgradeableModularAccountGetInstalledPlugins } from "@exactly/co

import CardDetails from "./CardDetails";
import CardDisclaimer from "./CardDisclaimer";
import CardFreezeSheet from "./CardFreezeSheet";
import CardPIN from "./CardPIN";
import ExaCard from "./exa-card/ExaCard";
import SpendingLimits from "./SpendingLimits";
Expand Down Expand Up @@ -43,6 +45,7 @@ import LatestActivity from "../shared/LatestActivity";
import PluginUpgrade from "../shared/PluginUpgrade";
import SafeView from "../shared/SafeView";
import Skeleton from "../shared/Skeleton";
import Switch from "../shared/Switch";
import Text from "../shared/Text";
import View from "../shared/View";

Expand All @@ -58,6 +61,7 @@ export default function Card() {
} = useTranslation();
const [disclaimerShown, setDisclaimerShown] = useState(false);
const [verificationFailureShown, setVerificationFailureShown] = useState(false);
const [freezeConfirmOpen, setFreezeConfirmOpen] = useState(false);

const { data: cardDetailsOpen } = useQuery<boolean>({ queryKey: ["card-details-open"] });
const [spendingLimitsOpen, setSpendingLimitsOpen] = useState(false);
Expand Down Expand Up @@ -301,7 +305,7 @@ export default function Card() {
<PluginUpgrade />
<ExaCard
revealing={isRevealing || isGeneratingCard || beginKYC.isPending}
frozen={cardDetails?.status === "FROZEN"}
frozen={displayStatus === "FROZEN"}
onPress={() => {
if (isRevealing || isGeneratingCard || beginKYC.isPending) return;
revealCard().catch(reportError);
Expand All @@ -320,6 +324,7 @@ export default function Card() {
justifyContent="space-between"
cursor="pointer"
onPress={() => {
selectionAsync().catch(reportError);
revealCard().catch(reportError);
}}
>
Expand All @@ -343,7 +348,12 @@ export default function Card() {
cursor="pointer"
onPress={() => {
if (isFetchingCard || isSettingCardStatus) return;
changeCardStatus(cardDetails.status === "FROZEN" ? "ACTIVE" : "FROZEN").catch(reportError);
selectionAsync().catch(reportError);
if (cardDetails.status === "FROZEN") {
changeCardStatus("ACTIVE").catch(reportError);
return;
}
setFreezeConfirmOpen(true);
}}
>
<XStack alignItems="center" gap="$s3">
Expand All @@ -355,31 +365,12 @@ export default function Card() {
)}
</Square>
<Text subHeadline color="$uiNeutralPrimary">
{displayStatus === "FROZEN" ? t("Unfreeze card") : t("Freeze card")}
{t("Freeze card")}
</Text>
</XStack>
<XStack alignItems="center" justifyContent="center" height={24}>
<Switch
scale={0.9}
margin={0}
padding={0}
pointerEvents="none"
checked={displayStatus === "FROZEN"}
backgroundColor="$backgroundMild"
borderColor="$borderNeutralSoft"
height={24}
width={60}
>
<Switch.Thumb
checked={displayStatus === "FROZEN"}
shadowColor="$uiNeutralSecondary"
animation="default"
backgroundColor={
displayStatus === "ACTIVE" ? "$interactiveDisabled" : "$interactiveBaseBrandDefault"
}
/>
</Switch>
</XStack>
<Switch checked={displayStatus === "FROZEN"}>
<Switch.Thumb />
</Switch>
</XStack>
<Separator borderColor="$borderNeutralSoft" />
</>
Expand All @@ -393,6 +384,7 @@ export default function Card() {
justifyContent="space-between"
cursor="pointer"
onPress={() => {
selectionAsync().catch(reportError);
setDisplayPIN(true);
}}
>
Expand All @@ -416,6 +408,7 @@ export default function Card() {
gap="$s3"
onPress={() => {
if (!limit) return;
selectionAsync().catch(reportError);
setSpendingLimitsOpen(true);
}}
>
Expand Down Expand Up @@ -526,6 +519,16 @@ export default function Card() {
setVerificationFailureShown(false);
}}
/>
<CardFreezeSheet
open={freezeConfirmOpen}
onClose={() => {
setFreezeConfirmOpen(false);
}}
onConfirm={() => {
setFreezeConfirmOpen(false);
changeCardStatus("FROZEN").catch(reportError);
}}
/>
</View>
</SafeView>
);
Expand Down
55 changes: 55 additions & 0 deletions src/components/card/CardFreezeSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Pressable } from "react-native";

import { Snowflake } from "@tamagui/lucide-icons";
import { YStack } from "tamagui";

import ModalSheet from "../shared/ModalSheet";
import Button from "../shared/StyledButton";
import Text from "../shared/Text";

export default function CardFreezeSheet({
onClose,
onConfirm,
open,
}: {
onClose: () => void;
onConfirm: () => void;
open: boolean;
}) {
const { t } = useTranslation();
return (
<ModalSheet open={open} onClose={onClose}>
<YStack
gap="$s7"
borderTopLeftRadius="$r5"
borderTopRightRadius="$r5"
backgroundColor="$backgroundSoft"
$platform-android={{ paddingBottom: "$s5" }}
>
<YStack gap="$s4" paddingTop="$s5" paddingHorizontal="$s5">
<Text emphasized headline>
{t("Freeze your card?")}
</Text>
<Text subHeadline secondary>
{t("Your card will be temporarily paused. You can unfreeze it anytime.")}
</Text>
</YStack>
<YStack gap="$s4" paddingHorizontal="$s4" paddingBottom="$s7">
<Button primary onPress={onConfirm}>
<Button.Text>{t("Freeze card")}</Button.Text>
<Button.Icon>
<Snowflake strokeWidth={2.5} />
</Button.Icon>
</Button>
<Pressable onPress={onClose}>
<Text emphasized footnote color="$interactiveBaseBrandDefault" alignSelf="center">
{t("Cancel")}
</Text>
</Pressable>
</YStack>
</YStack>
</ModalSheet>
);
}
134 changes: 82 additions & 52 deletions src/components/card/exa-card/CardContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,62 +54,55 @@ export default function CardContents({
>
<YStack height="100%" justifyContent="space-between" alignItems="flex-start" flex={1} width="100%" zIndex={1}>
<AnimatePresence exitBeforeEnter>
<>
{disabled ? (
<LockKeyhole size={40} strokeWidth={2} color="white" />
) : revealing ? (
<AnimatedView style={rStyle}>
<Loader size={40} strokeWidth={2} color="white" />
</AnimatedView>
) : frozen ? (
<Snowflake size={40} strokeWidth={2} color="white" />
) : isCredit ? (
<View
key="credit"
animation="default"
enterStyle={{ opacity: 0, transform: [{ translateX: -100 }] }}
exitStyle={{ opacity: 0, transform: [{ translateX: -100 }] }}
transform={[{ translateX: 0 }]}
>
<Text sensitive color="white" title maxFontSizeMultiplier={1} numberOfLines={1}>
{`$${(markets ? Number(borrowLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, {
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`}
{disabled ? (
<LockKeyhole size={40} strokeWidth={2} color="white" />
) : revealing ? (
<AnimatedView style={rStyle}>
<Loader size={40} strokeWidth={2} color="white" />
</AnimatedView>
) : frozen ? null : isCredit ? (
<View
key="credit"
animation="default"
enterStyle={{ opacity: 0, transform: [{ translateX: -100 }] }}
exitStyle={{ opacity: 0, transform: [{ translateX: -100 }] }}
transform={[{ translateX: 0 }]}
>
<Text sensitive color="white" title maxFontSizeMultiplier={1} numberOfLines={1}>
{`$${(markets ? Number(borrowLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, {
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`}
</Text>
<View>
<Text color="white" emphasized caption maxFontSizeMultiplier={1} textTransform="uppercase">
{t("Available balance")}
</Text>
<View>
<Text color="white" emphasized caption maxFontSizeMultiplier={1} textTransform="uppercase">
{t("Available balance")}
</Text>
</View>
</View>
) : (
<View
key="debit"
animation="default"
enterStyle={{ opacity: 0, transform: [{ translateX: 100 }] }}
exitStyle={{ opacity: 0, transform: [{ translateX: 100 }] }}
transform={[{ translateX: 0 }]}
>
<Text sensitive color="white" title maxFontSizeMultiplier={1} numberOfLines={1}>
{`$${(markets ? Number(withdrawLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(
language,
{
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
)}`}
</View>
) : (
<View
key="debit"
animation="default"
enterStyle={{ opacity: 0, transform: [{ translateX: 100 }] }}
exitStyle={{ opacity: 0, transform: [{ translateX: 100 }] }}
transform={[{ translateX: 0 }]}
>
<Text sensitive color="white" title maxFontSizeMultiplier={1} numberOfLines={1}>
{`$${(markets ? Number(withdrawLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, {
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`}
</Text>
<View>
<Text color="white" emphasized caption maxFontSizeMultiplier={1} textTransform="uppercase">
{t("Available balance")}
</Text>
<View>
<Text color="white" emphasized caption maxFontSizeMultiplier={1} textTransform="uppercase">
{t("Available balance")}
</Text>
</View>
</View>
)}
</>
</View>
)}
</AnimatePresence>
</YStack>
<XStack animation="default" position="absolute" right={0} left={0} top={0} bottom={0} justifyContent="flex-end">
Expand All @@ -129,6 +122,43 @@ export default function CardContents({
/>
)}
</XStack>
<AnimatePresence>
{frozen && !disabled && !revealing && (
<View
key="frozen-overlay"
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
backgroundColor="rgba(0,0,0,0.4)"
zIndex={2}
pointerEvents="none"
animation="default"
animateOnly={["opacity"]}
opacity={1}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
)}
{frozen && !disabled && !revealing && (
<View
key="frozen-icon"
position="absolute"
top="$s4"
left="$s4"
zIndex={3}
pointerEvents="none"
animation="default"
animateOnly={["opacity"]}
opacity={1}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
>
<Snowflake size={40} strokeWidth={2} color="white" />
</View>
)}
Comment thread
dieguezguille marked this conversation as resolved.
</AnimatePresence>
</XStack>
);
}
Loading
Loading