Skip to content

Commit

Permalink
card details!!!
Browse files Browse the repository at this point in the history
  • Loading branch information
cjdenio committed Jun 7, 2024
1 parent 24ac751 commit 9269b84
Show file tree
Hide file tree
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
Expand Up @@ -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
Expand Up @@ -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
}
}
},
Expand Down
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
Expand Up @@ -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",
Expand Down
61 changes: 49 additions & 12 deletions src/components/PaymentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 (
Expand All @@ -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,
Expand All @@ -53,6 +56,7 @@ export default function PaymentCard({
paddingVertical: 6,
borderColor: "rgb(91, 192, 222)",
borderWidth: 1,
alignSelf: "flex-start",
}}
>
<Text
Expand All @@ -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
Expand Up @@ -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;
Expand Down
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
Expand Up @@ -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">;

Expand All @@ -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,
});
Expand Down Expand Up @@ -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>
)}

Expand All @@ -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}
Expand Down
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) {
Expand Down Expand Up @@ -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.