Skip to content

Commit

Permalink
card details!!!
Browse files Browse the repository at this point in the history
cjdenio committed Jun 7, 2024

Verified

This commit was signed with the committer’s verified signature.
cjdenio Caleb Denio
1 parent 24ac751 commit 9269b84
Showing 9 changed files with 212 additions and 32 deletions.
4 changes: 4 additions & 0 deletions app.config.js
Original file line number Diff line number Diff line change
@@ -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." },
],
],
},
};
3 changes: 2 additions & 1 deletion eas.json
Original file line number Diff line number Diff line change
@@ -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
}
}
},
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
61 changes: 49 additions & 12 deletions src/components/PaymentCard.tsx
Original file line number Diff line number Diff line change
@@ -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",
}}
>
<Text
@@ -70,20 +74,53 @@ export default function PaymentCard({
<Text
style={{
color: themeColors.text,
fontSize: 24,
fontSize: 23,
marginBottom: 4,
fontFamily: "JetBrains Mono",
}}
>
···· ···· ···· {card.last4 || "····"}
</Text>
<Text
style={{
color: palette.muted,
fontSize: 18,
}}
>
{card.organization.name}
{details
? renderCardNumber(details.number)
: redactedCardNumber(card.last4)}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<Text
style={{
color: palette.muted,
fontSize: 18,
}}
>
{card.organization.name}
</Text>
<View style={{ marginLeft: "auto" }}>
<Text style={{ color: palette.muted, fontSize: 10 }}>Exp</Text>
<Text
style={{
color: themeColors.text,
fontFamily: "JetBrains Mono",
fontSize: 14,
}}
>
{card.exp_month?.toLocaleString("en-US", {
minimumIntegerDigits: 2,
})}
/{card.exp_year?.toString().slice(-2)}
</Text>
</View>
<View>
<Text style={{ color: palette.muted, fontSize: 10 }}>CVC</Text>
<Text
style={{
color: themeColors.text,
fontFamily: "JetBrains Mono",
fontSize: 14,
fontVariant: ["no-contextual"], // JetBrains Mono has a ligature for "***" lol
}}
>
{details?.cvc || "***"}
</Text>
</View>
</View>
</View>
);
}
2 changes: 2 additions & 0 deletions src/lib/types/Card.ts
Original file line number Diff line number Diff line change
@@ -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;
79 changes: 79 additions & 0 deletions src/lib/useStripeCardDetails.ts
Original file line number Diff line number Diff line change
@@ -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<CardDetails | undefined>(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<CardDetails>();

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);
}
}
},
};
}
70 changes: 52 additions & 18 deletions src/pages/card.tsx
Original file line number Diff line number Diff line change
@@ -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<CardsStackParamList, "Card">;

@@ -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<Card>(`cards/${_card.id}`, {
fallbackData: _card,
});
@@ -78,43 +87,44 @@ export default function CardPage({
contentContainerStyle={{ padding: 20, paddingBottom: tabBarHeight + 20 }}
scrollIndicatorInsets={{ bottom: tabBarHeight }}
>
<PaymentCard card={card} style={{ marginBottom: 20 }} />
<PaymentCard details={details} card={card} style={{ marginBottom: 20 }} />

{card.status != "canceled" && (
<View
style={{
flexDirection: "row",
marginBottom: 20,
justifyContent: "center",
gap: 20,
}}
>
<Button
style={{
// flexBasis: 0,
// flexGrow: 1,
// marginHorizontal: 10,
flexBasis: 0,
flexGrow: 1,
// marginR: 10,
backgroundColor: "#5bc0de",
borderTopWidth: 0,
minWidth: 150,
}}
color="#186177"
onPress={() => toggleCardFrozen()}
loading={isMutating}
>
{card.status == "active" ? "Freeze" : "Unfreeze"} card
</Button>
{/* {card.type == "virtual" && (
{card.type == "virtual" && (
<Button
style={{
flexBasis: 0,
flexGrow: 1,
marginHorizontal: 10,
opacity: 0.6,
// marginHorizontal: 10,
}}
onPress={() => toggleDetailsRevealed()}
loading={detailsLoading}
>
Reveal details
{detailsRevealed ? "Hide" : "Reveal"} details
</Button>
)} */}
)}
</View>
)}

@@ -133,17 +143,41 @@ export default function CardPage({
</Text>
) : (
<>
<Text
<View
style={{
color: palette.muted,
fontSize: 12,
textTransform: "uppercase",
marginBottom: 8,
marginTop: 10,
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
}}
>
Transactions
</Text>
<Text
style={{
color: palette.muted,
fontSize: 12,
textTransform: "uppercase",
marginBottom: 8,
marginTop: 10,
}}
>
Transactions
</Text>
{card.total_spent_cents && (
<Text
style={{
color: palette.muted,
fontSize: 12,
textTransform: "uppercase",
marginBottom: 8,
marginTop: 10,
}}
>
<Text style={{ color: themeColors.text }}>
{renderMoney(card.total_spent_cents)}
</Text>{" "}
spent
</Text>
)}
</View>
{transactions.data.map((transaction, index) => (
<TouchableHighlight
key={transaction.id}
10 changes: 10 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import words from "lodash/words";

import { palette } from "./theme";

export function renderMoney(cents: number) {
@@ -45,3 +47,11 @@ export function orgColor(orgId: string) {

return colors[Math.floor(orgId.charCodeAt(4) % colors.length)];
}

export function redactedCardNumber(last4?: string) {
return `**** **** **** ${last4 || "****"}`;
}

export function renderCardNumber(number: string) {
return words(number, /\d{4}/g).join(" ");
}

0 comments on commit 9269b84

Please sign in to comment.