diff --git a/app.config.js b/app.config.js
index abc980e..85f1fe5 100644
--- a/app.config.js
+++ b/app.config.js
@@ -62,6 +62,10 @@ export default {
],
"expo-secure-store",
["@config-plugins/react-native-dynamic-app-icon", appIconConfig],
+ [
+ "expo-local-authentication",
+ { faceIDPermission: "Allow $(PRODUCT_NAME) to use Face ID." },
+ ],
],
},
};
diff --git a/eas.json b/eas.json
index def632f..2c0d93a 100644
--- a/eas.json
+++ b/eas.json
@@ -13,7 +13,8 @@
"production": {
"env": {
"EXPO_PUBLIC_API_BASE": "https://hcb.hackclub.com/api/v4",
- "EXPO_PUBLIC_CLIENT_ID": "yt8JHmPDmmYYLUmoEiGtocYwg5fSOGCrcIY3G-vkMRs"
+ "EXPO_PUBLIC_CLIENT_ID": "yt8JHmPDmmYYLUmoEiGtocYwg5fSOGCrcIY3G-vkMRs",
+ "EXPO_PUBLIC_STRIPE_API_KEY": "pk_live_UAjIP1Kss29XZ6tW0MFWkjUQ" // "pk" stands for "publishable key" you wannabe hacker
}
}
},
diff --git a/package-lock.json b/package-lock.json
index 0875e27..2f12851 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"expo-image-picker": "~14.7.1",
"expo-linear-gradient": "~12.7.2",
"expo-linking": "~6.2.2",
+ "expo-local-authentication": "~13.8.0",
"expo-notifications": "~0.27.6",
"expo-secure-store": "~12.8.1",
"expo-splash-screen": "~0.26.4",
@@ -9175,6 +9176,17 @@
"invariant": "^2.2.4"
}
},
+ "node_modules/expo-local-authentication": {
+ "version": "13.8.0",
+ "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-13.8.0.tgz",
+ "integrity": "sha512-h0YA7grVdo3834AS70EUCsalaXrrEnoq+yTvIhRTxiPmzWxUv7rNo5ff+XsIEYNElKPmT/wh/xPV1yo3l3fhGg==",
+ "dependencies": {
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-manifests": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.13.2.tgz",
diff --git a/package.json b/package.json
index 86955ca..581d810 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,8 @@
"react-native-svg": "14.1.0",
"react-native-web": "^0.19.8",
"swr": "^2.2.1",
- "ts-pattern": "^5.0.8"
+ "ts-pattern": "^5.0.8",
+ "expo-local-authentication": "~13.8.0"
},
"devDependencies": {
"@babel/core": "^7.22.11",
diff --git a/src/components/PaymentCard.tsx b/src/components/PaymentCard.tsx
index 48e6e6a..7fd2d95 100644
--- a/src/components/PaymentCard.tsx
+++ b/src/components/PaymentCard.tsx
@@ -7,7 +7,9 @@ import { Text, View, ViewProps } from "react-native";
// } from "react-native-reanimated";
import Card from "../lib/types/Card";
+import { CardDetails } from "../lib/useStripeCardDetails";
import { palette } from "../theme";
+import { redactedCardNumber, renderCardNumber } from "../util";
import CardChip from "./cards/CardChip";
@@ -21,8 +23,9 @@ import CardChip from "./cards/CardChip";
export default function PaymentCard({
card,
+ details,
...props
-}: ViewProps & { card: Card }) {
+}: ViewProps & { card: Card; details?: CardDetails }) {
const { colors: themeColors, dark } = useTheme();
return (
@@ -34,7 +37,7 @@ export default function PaymentCard({
borderRadius: 16,
flexDirection: "column",
justifyContent: "flex-end",
- alignItems: "flex-start",
+ alignItems: "stretch",
position: "relative",
borderWidth: 1,
borderColor: dark ? palette.slate : palette.muted,
@@ -53,6 +56,7 @@ export default function PaymentCard({
paddingVertical: 6,
borderColor: "rgb(91, 192, 222)",
borderWidth: 1,
+ alignSelf: "flex-start",
}}
>
- ···· ···· ···· {card.last4 || "····"}
-
-
- {card.organization.name}
+ {details
+ ? renderCardNumber(details.number)
+ : redactedCardNumber(card.last4)}
+
+
+ {card.organization.name}
+
+
+ Exp
+
+ {card.exp_month?.toLocaleString("en-US", {
+ minimumIntegerDigits: 2,
+ })}
+ /{card.exp_year?.toString().slice(-2)}
+
+
+
+ CVC
+
+ {details?.cvc || "***"}
+
+
+
);
}
diff --git a/src/lib/types/Card.ts b/src/lib/types/Card.ts
index bd8cd54..afa4a0d 100644
--- a/src/lib/types/Card.ts
+++ b/src/lib/types/Card.ts
@@ -3,6 +3,8 @@ import Organization from "./Organization";
export default interface Card extends HcbApiObject<"crd"> {
last4?: string;
+ exp_month?: number;
+ exp_year?: number;
type: "virtual" | "physical";
status: "inactive" | "frozen" | "active" | "canceled";
name?: string;
diff --git a/src/lib/useStripeCardDetails.ts b/src/lib/useStripeCardDetails.ts
new file mode 100644
index 0000000..3ae3f0d
--- /dev/null
+++ b/src/lib/useStripeCardDetails.ts
@@ -0,0 +1,79 @@
+import * as LocalAuthentication from "expo-local-authentication";
+import ky from "ky";
+import { useEffect, useState } from "react";
+
+import useClient from "./client";
+
+export interface CardDetails {
+ number: string;
+ cvc: string;
+ exp_month: number;
+ exp_year: number;
+}
+
+export default function useStripeCardDetails(cardId: string) {
+ const hcb = useClient();
+ const [revealed, setRevealed] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [details, setDetails] = useState(undefined);
+
+ useEffect(() => {
+ (async () => {
+ if (revealed) {
+ try {
+ setLoading(true);
+
+ const { private_nonce, public_nonce } = await ky
+ .post("https://api.stripe.com/v1/ephemeral_key_nonces", {
+ headers: {
+ Authorization: `Bearer ${process.env.EXPO_PUBLIC_STRIPE_API_KEY}`,
+ },
+ })
+ .json<{ private_nonce: string; public_nonce: string }>();
+
+ const { ephemeralKeySecret, stripe_id: stripeId } = await hcb(
+ `cards/${cardId}/ephemeral_keys?nonce=${public_nonce}`,
+ ).json<{ ephemeralKeySecret: string; stripe_id: string }>();
+
+ const { number, cvc, exp_month, exp_year } = await ky(
+ `https://api.stripe.com/v1/issuing/cards/${stripeId}`,
+ {
+ searchParams: {
+ "expand[0]": "number",
+ "expand[1]": "cvc",
+ ephemeral_key_private_nonce: private_nonce,
+ },
+ headers: {
+ Authorization: `Bearer ${ephemeralKeySecret}`,
+ "Stripe-Version": "2020-03-02",
+ },
+ },
+ ).json();
+
+ setDetails({ cvc, number, exp_month, exp_year });
+ } finally {
+ setLoading(false);
+ }
+ } else {
+ setDetails(undefined);
+ }
+ })();
+ }, [revealed, cardId, setDetails, hcb]);
+
+ return {
+ details,
+ revealed,
+ loading,
+ toggle: async () => {
+ if (revealed) {
+ setRevealed(false);
+ } else {
+ const result = await LocalAuthentication.authenticateAsync();
+
+ if (result.success) {
+ setRevealed(true);
+ }
+ }
+ },
+ };
+}
diff --git a/src/pages/card.tsx b/src/pages/card.tsx
index ecb6992..5a6a2c2 100644
--- a/src/pages/card.tsx
+++ b/src/pages/card.tsx
@@ -19,7 +19,9 @@ import useClient from "../lib/client";
import { CardsStackParamList } from "../lib/NavigatorParamList";
import Card from "../lib/types/Card";
import ITransaction from "../lib/types/Transaction";
+import useStripeCardDetails from "../lib/useStripeCardDetails";
import { palette } from "../theme";
+import { renderMoney } from "../util";
type Props = NativeStackScreenProps;
@@ -32,6 +34,13 @@ export default function CardPage({
const { colors: themeColors } = useTheme();
const hcb = useClient();
+ const {
+ details,
+ toggle: toggleDetailsRevealed,
+ revealed: detailsRevealed,
+ loading: detailsLoading,
+ } = useStripeCardDetails(_card.id);
+
const { data: card } = useSWR(`cards/${_card.id}`, {
fallbackData: _card,
});
@@ -78,7 +87,7 @@ export default function CardPage({
contentContainerStyle={{ padding: 20, paddingBottom: tabBarHeight + 20 }}
scrollIndicatorInsets={{ bottom: tabBarHeight }}
>
-
+
{card.status != "canceled" && (
- {/* {card.type == "virtual" && (
+ {card.type == "virtual" && (
- )} */}
+ )}
)}
@@ -133,17 +143,41 @@ export default function CardPage({
) : (
<>
-
- Transactions
-
+
+ Transactions
+
+ {card.total_spent_cents && (
+
+
+ {renderMoney(card.total_spent_cents)}
+ {" "}
+ spent
+
+ )}
+
{transactions.data.map((transaction, index) => (