Skip to content

Commit b731529

Browse files
feat: MessagingCard
1 parent 776916b commit b731529

File tree

11 files changed

+1324
-0
lines changed

11 files changed

+1324
-0
lines changed

apps/mobile-app/scripts/utils/routes.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,11 @@ export const routes = [
349349
key: 'MediaChip',
350350
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
351351
},
352+
{
353+
key: 'MessagingCard',
354+
getComponent: () =>
355+
require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default,
356+
},
352357
{
353358
key: 'ModalBackButton',
354359
getComponent: () =>

apps/mobile-app/src/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,11 @@ export const routes = [
349349
key: 'MediaChip',
350350
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
351351
},
352+
{
353+
key: 'MessagingCard',
354+
getComponent: () =>
355+
require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default,
356+
},
352357
{
353358
key: 'ModalBackButton',
354359
getComponent: () =>
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import React, { memo, useMemo } from 'react';
2+
import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native';
3+
4+
import { IconButton } from '../../buttons/IconButton';
5+
import { HStack } from '../../layout/HStack';
6+
import { VStack } from '../../layout/VStack';
7+
import { Tag } from '../../tag/Tag';
8+
import { Text } from '../../typography/Text';
9+
10+
export type MessagingCardLayoutProps = {
11+
/** Type of messaging card. Determines background color and text color. */
12+
type: 'upsell' | 'nudge';
13+
/** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component with appropriate color based on type. */
14+
title?: React.ReactNode;
15+
/** Text or React node to display as the card subtitle. */
16+
subtitle?: React.ReactNode;
17+
/** Text or React node to display as the card description. When a string is provided, it will be rendered in a CardDescription component with appropriate color based on type. */
18+
description?: React.ReactNode;
19+
/** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */
20+
tag?: React.ReactNode;
21+
/** React node to display as actions (typically buttons) at the bottom of the content area. */
22+
actions?: React.ReactNode;
23+
/** React node to display as the dismiss button. When provided, a dismiss button will be rendered in the top-right corner. */
24+
dismissButton?: React.ReactNode;
25+
/** Callback fired when the dismiss button is pressed. When provided, a dismiss button will be rendered in the top-right corner. */
26+
onDismiss?: (event: GestureResponderEvent) => void;
27+
/** Accessibility label for the dismiss button.
28+
* @default 'Dismiss card'
29+
*/
30+
dismissButtonAccessibilityLabel?: string;
31+
/** Placement of the media content relative to the text content. */
32+
mediaPlacement: 'start' | 'end';
33+
/** React node to display as the main media content. When provided, it will be rendered in an HStack container. */
34+
media?: React.ReactNode;
35+
styles?: {
36+
layoutContainer?: StyleProp<ViewStyle>;
37+
contentContainer?: StyleProp<ViewStyle>;
38+
textContainer?: StyleProp<ViewStyle>;
39+
mediaContainer?: StyleProp<ViewStyle>;
40+
dismissButtonContainer?: StyleProp<ViewStyle>;
41+
};
42+
};
43+
44+
export const MessagingCardLayout = memo(
45+
({
46+
type,
47+
title,
48+
description,
49+
tag,
50+
actions,
51+
onDismiss,
52+
dismissButtonAccessibilityLabel = 'Dismiss card',
53+
mediaPlacement = 'end',
54+
media,
55+
styles = {},
56+
dismissButton,
57+
}: MessagingCardLayoutProps) => {
58+
const titleNode = useMemo(() => {
59+
if (typeof title === 'string') {
60+
return (
61+
<Text color={type === 'upsell' ? 'fgInverse' : 'fg'} font="headline" numberOfLines={2}>
62+
{title}
63+
</Text>
64+
);
65+
}
66+
return title;
67+
}, [title, type]);
68+
69+
const descriptionNode = useMemo(() => {
70+
if (typeof description === 'string') {
71+
return (
72+
<Text color={type === 'upsell' ? 'fgInverse' : 'fg'} font="label2" numberOfLines={3}>
73+
{description}
74+
</Text>
75+
);
76+
}
77+
return description;
78+
}, [description, type]);
79+
80+
const tagNode = useMemo(() => {
81+
if (typeof tag === 'string') {
82+
return <Tag>{tag}</Tag>;
83+
}
84+
return tag;
85+
}, [tag]);
86+
87+
const dismissButtonNode = useMemo(() => {
88+
if (dismissButton) {
89+
return dismissButton;
90+
}
91+
if (onDismiss) {
92+
const handleDismiss = (event: GestureResponderEvent) => {
93+
event.preventDefault();
94+
event.stopPropagation();
95+
onDismiss(event);
96+
};
97+
98+
return (
99+
<HStack
100+
paddingEnd={1}
101+
paddingTop={1}
102+
position="absolute"
103+
right={0}
104+
style={styles?.dismissButtonContainer}
105+
top={0}
106+
>
107+
<IconButton
108+
compact
109+
accessibilityLabel={dismissButtonAccessibilityLabel}
110+
name="close"
111+
onPress={handleDismiss}
112+
variant="secondary"
113+
/>
114+
</HStack>
115+
);
116+
}
117+
return null;
118+
}, [dismissButton, dismissButtonAccessibilityLabel, onDismiss, styles?.dismissButtonContainer]);
119+
120+
const contentContainerPaddingProps = useMemo(() => {
121+
if (mediaPlacement === 'start' && onDismiss) {
122+
// needs to add additional padding to the end of the content area when media is placed at the start and there is a dismiss button
123+
// this is to avoid dismiss button from overlapping with the content area
124+
return {
125+
paddingY: 2,
126+
paddingStart: 2,
127+
paddingEnd: 6,
128+
} as const;
129+
}
130+
return {
131+
padding: 2,
132+
} as const;
133+
}, [mediaPlacement, onDismiss]);
134+
135+
const mediaContainerPaddingProps = useMemo(() => {
136+
if (type === 'upsell') return;
137+
if (mediaPlacement === 'start') {
138+
return { paddingStart: 3, paddingEnd: 1 } as const;
139+
}
140+
// when media is placed at the end, we need to add additional padding to the end of the media container
141+
// this is to avoid the dismiss button from overlapping with the media
142+
return onDismiss
143+
? ({ paddingStart: 1, paddingEnd: 6 } as const)
144+
: ({ paddingStart: 1, paddingEnd: 3 } as const);
145+
}, [mediaPlacement, onDismiss, type]);
146+
147+
return (
148+
<HStack
149+
flexDirection={mediaPlacement === 'start' ? 'row-reverse' : 'row'}
150+
flexGrow={1}
151+
position="relative"
152+
style={styles?.layoutContainer}
153+
>
154+
<VStack
155+
alignItems="flex-start"
156+
flexBasis={0}
157+
flexGrow={1}
158+
flexShrink={1}
159+
gap={2}
160+
justifyContent="space-between"
161+
{...contentContainerPaddingProps}
162+
style={styles?.contentContainer}
163+
>
164+
<VStack alignItems="flex-start" gap={0.5} style={styles?.textContainer}>
165+
{tagNode}
166+
{titleNode}
167+
{descriptionNode}
168+
</VStack>
169+
{actions}
170+
</VStack>
171+
{media && (
172+
<HStack
173+
alignItems="center"
174+
justifyContent="flex-end"
175+
style={styles?.mediaContainer}
176+
{...mediaContainerPaddingProps}
177+
>
178+
{media}
179+
</HStack>
180+
)}
181+
{dismissButtonNode}
182+
</HStack>
183+
);
184+
},
185+
);
186+
187+
MessagingCardLayout.displayName = 'MessagingCardLayout';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { forwardRef, memo } from 'react';
2+
import type { StyleProp, View, ViewStyle } from 'react-native';
3+
import type { ThemeVars } from '@coinbase/cds-common';
4+
5+
import { CardRoot, type CardRootProps } from '../CardRoot';
6+
7+
import { MessagingCardLayout, type MessagingCardLayoutProps } from './MessagingCardLayout';
8+
9+
export type MessagingCardBaseProps = MessagingCardLayoutProps;
10+
11+
export type MessagingCardProps = Omit<CardRootProps, 'children'> &
12+
MessagingCardBaseProps & {
13+
styles?: {
14+
root?: StyleProp<ViewStyle>;
15+
};
16+
};
17+
18+
const messagingCardContainerProps = {
19+
borderRadius: 500 as ThemeVars.BorderRadius,
20+
overflow: 'hidden' as const,
21+
};
22+
23+
export const MessagingCard = memo(
24+
forwardRef<View, MessagingCardProps>(
25+
(
26+
{
27+
type,
28+
title,
29+
description,
30+
tag,
31+
actions,
32+
dismissButton,
33+
onDismiss,
34+
dismissButtonAccessibilityLabel,
35+
mediaPlacement,
36+
media,
37+
styles: { root: rootStyle, ...layoutStyles } = {},
38+
...props
39+
},
40+
ref,
41+
) => {
42+
const background = type === 'upsell' ? 'bgPrimary' : 'bgAlternate';
43+
return (
44+
<CardRoot
45+
ref={ref}
46+
background={background}
47+
borderWidth={0}
48+
{...messagingCardContainerProps}
49+
style={rootStyle}
50+
{...props}
51+
>
52+
<MessagingCardLayout
53+
actions={actions}
54+
description={description}
55+
dismissButton={dismissButton}
56+
dismissButtonAccessibilityLabel={dismissButtonAccessibilityLabel}
57+
media={media}
58+
mediaPlacement={mediaPlacement}
59+
onDismiss={onDismiss}
60+
styles={layoutStyles}
61+
tag={tag}
62+
title={title}
63+
type={type}
64+
/>
65+
</CardRoot>
66+
);
67+
},
68+
),
69+
);
70+
71+
MessagingCard.displayName = 'MessagingCard';

0 commit comments

Comments
 (0)