Skip to content

Commit

Permalink
feat(EMI-2128): adds payment failure banner (#11006)
Browse files Browse the repository at this point in the history
* feat: add payment banner banner component

* chore: add feature flag

* use payment failed filters param

* tests: add tests for banner

* update tests

* fix tests and add suspense

* chore: add tracking

* add links and useFocusEffect

* move tracking and add useFretchableFragment

* minor updates

* fix text alignment

* update fetchPolicy

* test: add tests for links and tracking

* update tracking calls

* memoize handleBannerLinkClick

* add order type to tracking
  • Loading branch information
rquartararo authored Oct 29, 2024
1 parent 269ad2a commit 0416b85
Show file tree
Hide file tree
Showing 5 changed files with 308 additions and 14 deletions.
37 changes: 23 additions & 14 deletions src/app/Scenes/HomeView/Components/HomeHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import { ArtsyLogoBlackIcon, Flex, Box, useSpace } from "@artsy/palette-mobile"
import { ArtsyLogoBlackIcon, Flex, Box } from "@artsy/palette-mobile"
import { PaymentFailureBanner } from "app/Scenes/HomeView/Components/PaymentFailureBanner"
import { GlobalStore } from "app/store/GlobalStore"
import { useFeatureFlag } from "app/utils/hooks/useFeatureFlag"
import { Suspense } from "react"
import { ActivityIndicator } from "./ActivityIndicator"

export const HomeHeader: React.FC = () => {
const showPaymentFailureBanner = useFeatureFlag("AREnablePaymentFailureBanner")
const hasUnseenNotifications = GlobalStore.useAppState(
(state) => state.bottomTabs.hasUnseenNotifications
)

const space = useSpace()

return (
<Box style={{ paddingTop: space(2), paddingBottom: space(2) }}>
<Flex flexDirection="row" px={2} justifyContent="space-between" alignItems="center">
<Box flex={1} />
<Box>
<ArtsyLogoBlackIcon scale={0.75} />
</Box>
<Box flex={1} alignItems="flex-end">
<ActivityIndicator hasUnseenNotifications={hasUnseenNotifications} />
</Box>
</Flex>
</Box>
<>
{!!showPaymentFailureBanner && (
<Suspense fallback={null}>
<PaymentFailureBanner />
</Suspense>
)}
<Box py={2}>
<Flex flexDirection="row" px={2} justifyContent="space-between" alignItems="center">
<Box flex={1} />
<Box>
<ArtsyLogoBlackIcon scale={0.75} />
</Box>
<Box flex={1} alignItems="flex-end">
<ActivityIndicator hasUnseenNotifications={hasUnseenNotifications} />
</Box>
</Flex>
</Box>
</>
)
}
102 changes: 102 additions & 0 deletions src/app/Scenes/HomeView/Components/PaymentFailureBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Banner, LinkText, Text } from "@artsy/palette-mobile"
import { useIsFocused } from "@react-navigation/native"
import { PaymentFailureBannerQuery } from "__generated__/PaymentFailureBannerQuery.graphql"
import { PaymentFailureBannerRefetchQuery } from "__generated__/PaymentFailureBannerRefetchQuery.graphql"
import { PaymentFailureBanner_Fragment$key } from "__generated__/PaymentFailureBanner_Fragment.graphql"
import { useHomeViewTracking } from "app/Scenes/HomeView/useHomeViewTracking"
import { navigate } from "app/system/navigation/navigate"
import { extractNodes } from "app/utils/extractNodes"
import { useCallback, useEffect } from "react"
import { graphql, useLazyLoadQuery, useRefetchableFragment } from "react-relay"

export const PaymentFailureBanner: React.FC = () => {
const tracking = useHomeViewTracking()
const isFocused = useIsFocused()

const initialData = useLazyLoadQuery<PaymentFailureBannerQuery>(
graphql`
query PaymentFailureBannerQuery {
...PaymentFailureBanner_Fragment
}
`,
{}
)

const [data, refetch] = useRefetchableFragment<
PaymentFailureBannerRefetchQuery,
PaymentFailureBanner_Fragment$key
>(
graphql`
fragment PaymentFailureBanner_Fragment on Query
@refetchable(queryName: "PaymentFailureBannerRefetchQuery") {
commerceMyOrders(first: 10, filters: [PAYMENT_FAILED]) {
edges {
node {
code
internalID
}
}
}
}
`,
initialData
)

useEffect(() => {
if (isFocused) {
refetch(
{},
{
fetchPolicy: "store-and-network",
}
)
}
}, [isFocused])

const failedPayments = extractNodes(data?.commerceMyOrders)

useEffect(() => {
if (failedPayments.length > 0) {
tracking.bannerViewed(failedPayments)
}
}, [failedPayments, tracking])

const handleBannerLinkClick = useCallback(() => {
tracking.tappedChangePaymentMethod(failedPayments)

const route =
failedPayments.length === 1
? `orders/${failedPayments[0].internalID}/payment/new`
: `settings/purchases`

navigate(route)
}, [failedPayments, tracking])

if (failedPayments.length === 0) {
return null
}

const bannerText =
failedPayments.length === 1
? "Payment failed for your recent order."
: "Payment failed for your recent orders."

const linkText =
failedPayments.length === 1 ? "Update payment method." : "Update payment method for each order."

return (
<Banner data-testid="PaymentFailureBanner" variant="error" dismissable>
<Text textAlign="left" variant="xs" color="white100">
{bannerText}
</Text>
<LinkText
variant="xs"
textAlign="left"
color="white100"
onPress={() => handleBannerLinkClick()}
>
{linkText}
</LinkText>
</Banner>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { fireEvent, screen } from "@testing-library/react-native"
import { PaymentFailureBanner } from "app/Scenes/HomeView/Components/PaymentFailureBanner"
import { useHomeViewTracking } from "app/Scenes/HomeView/useHomeViewTracking"
import { navigate } from "app/system/navigation/navigate"
import { setupTestWrapper } from "app/utils/tests/setupTestWrapper"
import { graphql } from "react-relay"

const mockUseIsFocusedMock = jest.fn()

jest.mock("app/Scenes/HomeView/useHomeViewTracking", () => ({
useHomeViewTracking: jest.fn(),
}))

jest.mock("@react-navigation/native", () => ({
useIsFocused: () => mockUseIsFocusedMock(),
}))

describe("PaymentFailureBanner", () => {
const { renderWithRelay } = setupTestWrapper({
Component: PaymentFailureBanner,
query: graphql`
query PaymentFailureBannerTestsQuery {
commerceMyOrders(first: 10, filters: [PAYMENT_FAILED]) {
edges {
node {
code
internalID
}
}
}
}
`,
variables: {},
})

const mockTracking = {
bannerViewed: jest.fn(),
tappedChangePaymentMethod: jest.fn(),
}

beforeEach(() => {
mockUseIsFocusedMock.mockReturnValue(false)
jest.clearAllMocks()
;(useHomeViewTracking as jest.Mock).mockReturnValue(mockTracking)
})

it("renders the error banner when a single payment has failed", () => {
renderWithRelay({
CommerceOrderConnectionWithTotalCount: () => ({
edges: [
{
node: {
code: "order-1",
internalID: "1",
},
},
],
}),
})

const link = screen.getByText("Update payment method.")
const text = screen.getByText("Payment failed for your recent order.")

expect(link).toBeTruthy()
expect(text).toBeTruthy()

fireEvent.press(link)
expect(navigate).toHaveBeenCalledWith("orders/1/payment/new")
})

it("renders the error banner when multiple payments have failed", () => {
renderWithRelay({
CommerceOrderConnectionWithTotalCount: () => ({
edges: [
{
node: {
code: "order-1",
internalID: "1",
},
},
{
node: {
code: "order-2",
internalID: "2",
},
},
],
}),
})
const link = screen.getByText("Update payment method for each order.")
const text = screen.getByText("Payment failed for your recent orders.")

expect(link).toBeTruthy()
expect(text).toBeTruthy()

fireEvent.press(link)
expect(navigate).toHaveBeenCalledWith("settings/purchases")
})

it("does not render the banner when there are no payment failures", () => {
renderWithRelay({
CommerceOrderConnectionWithTotalCount: () => ({
edges: [],
}),
})

expect(screen.queryByTestId("PaymentFailureBanner")).toBeNull()
})

it("calls bannerViewed tracking event when the banner is visible", () => {
renderWithRelay({
CommerceOrderConnectionWithTotalCount: () => ({
edges: [
{
node: {
code: "order-1",
internalID: "1",
},
},
],
}),
})

expect(mockTracking.bannerViewed).toHaveBeenCalledWith([{ code: "order-1", internalID: "1" }])
})

it("calls tappedChangePaymentMethod tracking event when the link is clicked", () => {
renderWithRelay({
CommerceOrderConnectionWithTotalCount: () => ({
edges: [
{
node: {
code: "order-1",
internalID: "1",
},
},
],
}),
})

const link = screen.getByText("Update payment method.")
fireEvent.press(link)

expect(mockTracking.bannerViewed).toHaveBeenCalledWith([{ code: "order-1", internalID: "1" }])
})
})
32 changes: 32 additions & 0 deletions src/app/Scenes/HomeView/useHomeViewTracking.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ActionType,
BannerViewed,
ContextModule,
OwnerType,
RailViewed,
Expand All @@ -12,6 +13,7 @@ import {
TappedAuctionGroup,
TappedAuctionResultGroup,
TappedCardGroup,
TappedChangePaymentMethod,
TappedClearNotification,
TappedCollectionGroup,
TappedFairGroup,
Expand All @@ -22,8 +24,10 @@ import {
TappedShowMore,
TappedViewingRoomGroup,
} from "@artsy/cohesion"
import { PaymentFailureBanner_Fragment$data } from "__generated__/PaymentFailureBanner_Fragment.graphql"
import { getArtworkSignalTrackingFields } from "app/utils/getArtworkSignalTrackingFields"
import { useFeatureFlag } from "app/utils/hooks/useFeatureFlag"
import { ExtractNodeType } from "app/utils/relayHelpers"
import { useTracking } from "react-tracking"

export const useHomeViewTracking = () => {
Expand All @@ -40,6 +44,20 @@ export const useHomeViewTracking = () => {
trackEvent(payload)
},

bannerViewed: (
orders: Array<ExtractNodeType<PaymentFailureBanner_Fragment$data["commerceMyOrders"]>>
) => {
const payload: BannerViewed = {
action: ActionType.bannerViewed,
context_screen: OwnerType.home,
context_module: ContextModule.paymentFailed,
item_type: orders.length === 1 ? "order" : "orders",
item_id: orders.length === 1 ? orders[0].internalID : "",
}

trackEvent(payload)
},

tappedNotificationBell: () => {
const payload: TappedNotificationsBell = {
action: ActionType.tappedNotificationsBell,
Expand Down Expand Up @@ -484,5 +502,19 @@ export const useHomeViewTracking = () => {

trackEvent(payload)
},

tappedChangePaymentMethod: (
orders: Array<ExtractNodeType<PaymentFailureBanner_Fragment$data["commerceMyOrders"]>>
) => {
const payload: TappedChangePaymentMethod = {
action: ActionType.tappedChangePaymentMethod,
context_screen: OwnerType.home,
context_module: ContextModule.paymentFailed,
item_type: orders.length === 1 ? "order" : "orders",
item_id: orders.length === 1 ? orders[0].internalID : "",
}

trackEvent(payload)
},
}
}
5 changes: 5 additions & 0 deletions src/app/store/config/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@ export const features = {
readyForRelease: false,
showInDevMenu: true,
},
AREnablePaymentFailureBanner: {
description: "Enable payment failure banner",
readyForRelease: false,
showInDevMenu: true,
},
} satisfies { [key: string]: FeatureDescriptor }

export interface DevToggleDescriptor {
Expand Down

0 comments on commit 0416b85

Please sign in to comment.