From fb93244269303cace5a81975fe896d461d70956c Mon Sep 17 00:00:00 2001 From: zel-kass Date: Tue, 17 Mar 2026 14:34:35 +0100 Subject: [PATCH 1/6] feat(MediaCard): add MediaCard component with leading and trailing content support - Introduced MediaCard component to display images with customizable content. - Added MediaCardLeadingContent and MediaCardTrailingContent for flexible layouts. - Updated App.tsx to include MediaCards in the sandbox. - Enhanced internationalization files with new close button labels in multiple languages. - Added tests and documentation for MediaCard component functionality. --- apps/app-sandbox-rnative/src/app/App.tsx | 4 + .../src/app/blocks/MediaCards.tsx | 46 ++++++ .../src/app/blocks/index.ts | 1 + libs/ui-rnative/src/i18n/locales/de.json | 3 + libs/ui-rnative/src/i18n/locales/en.json | 3 + libs/ui-rnative/src/i18n/locales/es.json | 3 + libs/ui-rnative/src/i18n/locales/fr.json | 3 + libs/ui-rnative/src/i18n/locales/ja.json | 3 + libs/ui-rnative/src/i18n/locales/ko.json | 3 + libs/ui-rnative/src/i18n/locales/pt.json | 3 + libs/ui-rnative/src/i18n/locales/ru.json | 3 + libs/ui-rnative/src/i18n/locales/th.json | 3 + libs/ui-rnative/src/i18n/locales/tr.json | 3 + libs/ui-rnative/src/i18n/locales/zh.json | 3 + .../lib/Components/MediaCard/MediaCard.mdx | 113 +++++++++++++ .../MediaCard/MediaCard.stories.tsx | 150 ++++++++++++++++++ .../Components/MediaCard/MediaCard.test.tsx | 148 +++++++++++++++++ .../src/lib/Components/MediaCard/index.ts | 8 + .../src/lib/Components/MediaCard/types.ts | 68 ++++++++ libs/ui-rnative/src/lib/Components/index.ts | 1 + 20 files changed, 572 insertions(+) create mode 100644 apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx create mode 100644 libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx create mode 100644 libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx create mode 100644 libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx create mode 100644 libs/ui-rnative/src/lib/Components/MediaCard/index.ts create mode 100644 libs/ui-rnative/src/lib/Components/MediaCard/types.ts diff --git a/apps/app-sandbox-rnative/src/app/App.tsx b/apps/app-sandbox-rnative/src/app/App.tsx index 9efe48d3e..8694970dc 100644 --- a/apps/app-sandbox-rnative/src/app/App.tsx +++ b/apps/app-sandbox-rnative/src/app/App.tsx @@ -45,6 +45,7 @@ import { Banners, CardButtons, ContentBanners, + MediaCards, Tooltips, ListItems, Gradients, @@ -177,6 +178,9 @@ const AppContent = ({ + + + diff --git a/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx b/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx new file mode 100644 index 000000000..d0190aacf --- /dev/null +++ b/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx @@ -0,0 +1,46 @@ +import { + Box, + MediaCard, + MediaCardDescription, + MediaCardLeadingContent, + MediaCardTitle, + MediaCardTrailingContent, + Tag, +} from '@ledgerhq/lumen-ui-rnative'; + +const EXAMPLE_SRC = + 'https://plus.unsplash.com/premium_photo-1689551670902-19b441a6afde?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'; + +export const MediaCards = () => { + return ( + + ({})} + onClose={() => ({})} + > + + + + + + Black Friday sale. 3 days with no fees on your transactions. + + + + + ({})} + onClose={() => ({})} + > + + Secure your crypto assets + + Get started with Ledger and protect your digital assets today. + + + + + ); +}; diff --git a/apps/app-sandbox-rnative/src/app/blocks/index.ts b/apps/app-sandbox-rnative/src/app/blocks/index.ts index 8b3aa5bb8..9bcc0af11 100644 --- a/apps/app-sandbox-rnative/src/app/blocks/index.ts +++ b/apps/app-sandbox-rnative/src/app/blocks/index.ts @@ -28,5 +28,6 @@ export * from './InteractiveIcons'; export * from './Banners'; export * from './CardButtons'; export * from './ContentBanners'; +export * from './MediaCards'; export * from './Tooltips'; export * from './Gradients'; diff --git a/libs/ui-rnative/src/i18n/locales/de.json b/libs/ui-rnative/src/i18n/locales/de.json index 7d5e822fb..9ddf818bd 100644 --- a/libs/ui-rnative/src/i18n/locales/de.json +++ b/libs/ui-rnative/src/i18n/locales/de.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Schließen" + }, "components": { "addressInput": { "qrCodeAriaLabel": "QR-Code scannen" diff --git a/libs/ui-rnative/src/i18n/locales/en.json b/libs/ui-rnative/src/i18n/locales/en.json index 4fba1aa86..f488514ee 100644 --- a/libs/ui-rnative/src/i18n/locales/en.json +++ b/libs/ui-rnative/src/i18n/locales/en.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Close" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Scan QR code" diff --git a/libs/ui-rnative/src/i18n/locales/es.json b/libs/ui-rnative/src/i18n/locales/es.json index bfa44557d..cfa38549c 100644 --- a/libs/ui-rnative/src/i18n/locales/es.json +++ b/libs/ui-rnative/src/i18n/locales/es.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Cerrar" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Escanear código QR" diff --git a/libs/ui-rnative/src/i18n/locales/fr.json b/libs/ui-rnative/src/i18n/locales/fr.json index e01c21f7b..35b22fecd 100644 --- a/libs/ui-rnative/src/i18n/locales/fr.json +++ b/libs/ui-rnative/src/i18n/locales/fr.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Fermer" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Scanner le code QR" diff --git a/libs/ui-rnative/src/i18n/locales/ja.json b/libs/ui-rnative/src/i18n/locales/ja.json index 05e9d499d..457413add 100644 --- a/libs/ui-rnative/src/i18n/locales/ja.json +++ b/libs/ui-rnative/src/i18n/locales/ja.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "閉じる" + }, "components": { "addressInput": { "qrCodeAriaLabel": "QRコードをスキャン" diff --git a/libs/ui-rnative/src/i18n/locales/ko.json b/libs/ui-rnative/src/i18n/locales/ko.json index eda729c7d..8e7235ec8 100644 --- a/libs/ui-rnative/src/i18n/locales/ko.json +++ b/libs/ui-rnative/src/i18n/locales/ko.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "닫기" + }, "components": { "addressInput": { "qrCodeAriaLabel": "QR 코드 스캔" diff --git a/libs/ui-rnative/src/i18n/locales/pt.json b/libs/ui-rnative/src/i18n/locales/pt.json index d217d715d..8e7acb9f3 100644 --- a/libs/ui-rnative/src/i18n/locales/pt.json +++ b/libs/ui-rnative/src/i18n/locales/pt.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Fechar" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Escanear QR Code" diff --git a/libs/ui-rnative/src/i18n/locales/ru.json b/libs/ui-rnative/src/i18n/locales/ru.json index 3cc7a7fe3..f48742aae 100644 --- a/libs/ui-rnative/src/i18n/locales/ru.json +++ b/libs/ui-rnative/src/i18n/locales/ru.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Закрыть" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Сканировать QR-код" diff --git a/libs/ui-rnative/src/i18n/locales/th.json b/libs/ui-rnative/src/i18n/locales/th.json index bd77a6503..142b91853 100644 --- a/libs/ui-rnative/src/i18n/locales/th.json +++ b/libs/ui-rnative/src/i18n/locales/th.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "ปิด" + }, "components": { "addressInput": { "qrCodeAriaLabel": "สแกน QR Code" diff --git a/libs/ui-rnative/src/i18n/locales/tr.json b/libs/ui-rnative/src/i18n/locales/tr.json index 0130f76ca..be0d0bfa2 100644 --- a/libs/ui-rnative/src/i18n/locales/tr.json +++ b/libs/ui-rnative/src/i18n/locales/tr.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Kapat" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Karekodu okut" diff --git a/libs/ui-rnative/src/i18n/locales/zh.json b/libs/ui-rnative/src/i18n/locales/zh.json index 74415432b..4509c3fba 100644 --- a/libs/ui-rnative/src/i18n/locales/zh.json +++ b/libs/ui-rnative/src/i18n/locales/zh.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "关闭" + }, "components": { "addressInput": { "qrCodeAriaLabel": "扫描二维码" diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx new file mode 100644 index 000000000..eac892fee --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx @@ -0,0 +1,113 @@ +import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'; +import * as MediaCardStories from './MediaCard.stories'; +import { CustomTabs, Tab } from '../../../../.storybook/components'; +import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/CommonRulesDoAndDont.mdx'; + + + +# MediaCard + + + + +## Introduction + +MediaCard is a composable card component that displays a full-bleed background image with gradient overlays for text readability. It supports a leading content slot (e.g. tags), a trailing content slot (title + description), and a close button. + +## Anatomy + + + +- **MediaCard**: Root pressable container with background image and gradient overlays +- **MediaCardLeadingContent**: Slot for secondary content above the title (tags, badges) +- **MediaCardTrailingContent**: Container for title and description +- **MediaCardTitle**: Primary title text (clamps at 2 lines) +- **MediaCardDescription**: Secondary description text (clamps at 2 lines) +- **Close button**: Rendered via the `onClose` prop (positioned absolute top-right) + +## Properties + + + + +### Layout + +The card fills its parent width by default. + + + +### Compositions + +All slots are optional. Combine leading content and trailing content to match your use case. + + + +## Accessibility + +- The root element uses `accessibilityRole='button'` and responds to press events +- The close button includes an `accessibilityLabel` (auto-translated via i18n) +- The background image is not accessible (`accessible={false}`) since it is decorative +- All sub-components forward refs and spread View/Text props for accessibility support +- Image loading errors are gracefully handled by hiding the broken image + + + + +## Setup + +Install and set up the library with our [Setup Guide →](?path=/docs/getting-started-setup--docs). + +## Basic Usage + +```tsx +import { + MediaCard, + MediaCardLeadingContent, + MediaCardTrailingContent, + MediaCardTitle, + MediaCardDescription, + Tag, +} from '@ledgerhq/lumen-ui-rnative'; + +function MyComponent() { + return ( + console.log('pressed')} + onClose={() => console.log('closed')} + > + + + + + Card title + + Card description + + + + ); +} +``` + +### Layout Adjustments with lx + +Use the `lx` prop for layout adjustments like margins or positioning: + +```tsx + console.log('pressed')} + onClose={() => console.log('closed')} + lx={{ marginTop: 's16', marginBottom: 's8' }} +> + + With margin + + +``` + + + + + diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx new file mode 100644 index 000000000..c9bef79d6 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx @@ -0,0 +1,150 @@ +import { CryptoIcon } from '@ledgerhq/crypto-icons'; +import type { Meta, StoryObj } from '@storybook/react-native-web-vite'; +import React from 'react'; +import { Tag } from '../Tag'; +import { Box } from '../Utility'; +import { + MediaCard, + MediaCardDescription, + MediaCardLeadingContent, + MediaCardTitle, + MediaCardTrailingContent, +} from './MediaCard'; + +const meta = { + component: MediaCard, + title: 'Communication/MediaCard', + parameters: { + layout: 'centered', + backgrounds: { default: 'light' }, + docs: { + source: { + language: 'tsx', + format: true, + type: 'code', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const EXAMPLE_SRC = + 'https://ledger-wp-website-s3-prd.ledger.com/uploads/2026/03/hero_visual-1.webp'; + +export const Base: Story = { + args: { + imageUrl: EXAMPLE_SRC, + onPress: () => ({}), + onClose: () => ({}), + }, + render: (args) => ( + + + + + + + + Black Friday sale. 3 days with no fees on your transactions. + + + + + ), + parameters: { + docs: { + source: { + code: ` + {}} onClose={() => {}}> + + + + + Card title + + +`, + }, + }, + }, +}; + +export const LayoutShowcase: Story = { + args: { + imageUrl: EXAMPLE_SRC, + onPress: () => ({}), + onClose: () => ({}), + }, + render: (args) => ( + + + + Full width card + + + + ), +}; + +export const CompositionShowcase: Story = { + args: { + imageUrl: EXAMPLE_SRC, + onPress: () => ({}), + onClose: () => ({}), + }, + render: (args) => ( + + + + Title only + + + + + + + + + With tag and title + + + + + + Title and description + + A short description below the main title. + + + + + + + + + + With crypto icon + + Leading content can be any element. + + + + + ), +}; diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx new file mode 100644 index 000000000..4ba4c3922 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core'; +import { fireEvent, render } from '@testing-library/react-native'; +import type { ReactNode } from 'react'; +import { ThemeProvider } from '../ThemeProvider/ThemeProvider'; +import { + MediaCard, + MediaCardDescription, + MediaCardLeadingContent, + MediaCardTitle, + MediaCardTrailingContent, +} from './MediaCard'; + +const TestWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const makeProps = () => ({ + imageUrl: 'https://example.com/image.jpg', + onPress: jest.fn(), + onClose: jest.fn(), +}); + +describe('MediaCard', () => { + it('should render children', () => { + const { getByText } = render( + + + + Title + + + , + ); + + getByText('Title'); + }); + + it('should render leading and trailing content', () => { + const { getByText } = render( + + + + Tag + + + Title + + + , + ); + + getByText('Tag'); + getByText('Title'); + }); + + it('should render title and description', () => { + const { getByText } = render( + + + + Card Title + Card description + + + , + ); + + getByText('Card Title'); + getByText('Card description'); + }); + + it('should fire onPress on press', () => { + const props = makeProps(); + const { getByTestId } = render( + + + + Title + + + , + ); + + fireEvent.press(getByTestId('media-card')); + expect(props.onPress).toHaveBeenCalledTimes(1); + }); + + it('should show image after successful load', () => { + const { getByTestId } = render( + + + + Title + + + , + ); + + const img = getByTestId('media-card-image'); + expect(img.props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); + + fireEvent(img, 'onLoad'); + expect(img.props.style).not.toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); + }); + + it('should hide image on error', () => { + const { getByTestId } = render( + + + + Title + + + , + ); + + const img = getByTestId('media-card-image'); + fireEvent(img, 'onLoad'); + fireEvent(img, 'onError'); + + expect(img.props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); + }); + + it('should fire onClose on close button press', () => { + const props = makeProps(); + const { getByTestId } = render( + + + + Title + + + , + ); + + fireEvent.press(getByTestId('media-card-close-button')); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/index.ts b/libs/ui-rnative/src/lib/Components/MediaCard/index.ts new file mode 100644 index 000000000..6f362bbf9 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/index.ts @@ -0,0 +1,8 @@ +export { + MediaCard, + MediaCardLeadingContent, + MediaCardTrailingContent, + MediaCardTitle, + MediaCardDescription, +} from './MediaCard'; +export * from './types'; diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/types.ts b/libs/ui-rnative/src/lib/Components/MediaCard/types.ts new file mode 100644 index 000000000..7fe43b52a --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/types.ts @@ -0,0 +1,68 @@ +import React from 'react'; +import { + StyledPressableProps, + StyledTextProps, + StyledViewProps, +} from '../../../styles'; + +/** + * Props for the MediaCard root component + */ +export type MediaCardProps = { + /** + * The source URL for the background image. + */ + imageUrl: string; + /** + * Callback fired when the card is pressed. + */ + onPress: () => void; + /** + * Callback fired when the close button is pressed. + */ + onClose: () => void; + /** + * The content of the card (MediaCardLeadingContent, MediaCardTrailingContent). + */ + children: React.ReactNode; +} & Omit; + +/** + * Props for the MediaCardLeadingContent component + */ +export type MediaCardLeadingContentProps = { + /** + * The leading content (tags, badges, icons). + */ + children: React.ReactNode; +} & Omit; + +/** + * Props for the MediaCardTrailingContent component + */ +export type MediaCardTrailingContentProps = { + /** + * The trailing content (MediaCardTitle, MediaCardDescription). + */ + children: React.ReactNode; +} & Omit; + +/** + * Props for the MediaCardTitle component + */ +export type MediaCardTitleProps = { + /** + * The title text or custom content. + */ + children: React.ReactNode; +} & Omit; + +/** + * Props for the MediaCardDescription component + */ +export type MediaCardDescriptionProps = { + /** + * The description text or custom content. + */ + children: React.ReactNode; +} & Omit; diff --git a/libs/ui-rnative/src/lib/Components/index.ts b/libs/ui-rnative/src/lib/Components/index.ts index c394456f5..5ed1e0b59 100644 --- a/libs/ui-rnative/src/lib/Components/index.ts +++ b/libs/ui-rnative/src/lib/Components/index.ts @@ -15,6 +15,7 @@ export * from './IconButton'; export * from './InteractiveIcon'; export * from './Link'; export * from './ListItem'; +export * from './MediaCard'; export * from './NavBar'; export * from './PageIndicator'; export * from './SearchInput'; From e5b13a6929aade92e4c981b60d35f2c6daa070d5 Mon Sep 17 00:00:00 2001 From: zel-kass Date: Tue, 17 Mar 2026 14:35:46 +0100 Subject: [PATCH 2/6] feat(ui-rnative): implement MediaCard component with customizable content slots --- .../lib/Components/MediaCard/MediaCard.tsx | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx new file mode 100644 index 000000000..49f59655a --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx @@ -0,0 +1,272 @@ +import { useState } from 'react'; +import { Image, StyleSheet, View } from 'react-native'; +import { useCommonTranslation } from '../../../i18n'; +import { useStyleSheet } from '../../../styles'; +import { Close } from '../../Symbols'; +import { + Box, + LinearGradient, + Pressable, + RadialGradient, + Text, +} from '../Utility'; +import { + MediaCardDescriptionProps, + MediaCardLeadingContentProps, + MediaCardProps, + MediaCardTitleProps, + MediaCardTrailingContentProps, +} from './types'; + +const CARD_HEIGHT = 164; +const WHITE = '#FFFFFF'; + +/** + * Slot for secondary content displayed above the trailing content, such as tags or icons. + */ +export const MediaCardLeadingContent = ({ + children, + lx = {}, + style, + ref, + ...viewProps +}: MediaCardLeadingContentProps) => { + return ( + + {children} + + ); +}; + +MediaCardLeadingContent.displayName = 'MediaCardLeadingContent'; + +/** + * Text content displayed at the bottom of the card. + */ +export const MediaCardTrailingContent = ({ + children, + lx = {}, + style, + ref, + ...viewProps +}: MediaCardTrailingContentProps) => { + const styles = useStyleSheet( + (t) => ({ + root: { + flexDirection: 'column', + gap: t.spacings.s4, + }, + }), + [], + ); + + return ( + + {children} + + ); +}; + +MediaCardTrailingContent.displayName = 'MediaCardTrailingContent'; + +/** + * Title text for the card, rendered inside `MediaCardTrailingContent`. + */ +export const MediaCardTitle = ({ + children, + lx = {}, + style, + ref, +}: MediaCardTitleProps) => { + const styles = useStyleSheet( + (t) => ({ + title: StyleSheet.flatten([ + t.typographies.heading3SemiBold, + { color: WHITE }, + ]), + }), + [], + ); + + return ( + + {children} + + ); +}; + +MediaCardTitle.displayName = 'MediaCardTitle'; + +/** + * Description text displayed below the title. + */ +export const MediaCardDescription = ({ + children, + lx = {}, + style, + ref, +}: MediaCardDescriptionProps) => { + const styles = useStyleSheet( + (t) => ({ + description: StyleSheet.flatten([t.typographies.body3, { color: WHITE }]), + }), + [], + ); + + return ( + + {children} + + ); +}; + +MediaCardDescription.displayName = 'MediaCardDescription'; + +/** + * A media card component for displaying a full-bleed background image with + * composable text content and a close button, using gradient overlays to + * ensure readability. + * + * @example + * import { MediaCard, MediaCardLeadingContent, MediaCardTrailingContent, MediaCardTitle, MediaCardDescription } from '@ledgerhq/lumen-ui-rnative'; + * + * {}} onClose={() => {}}> + * + * + * + * + * Card title + * Card description + * + * + */ +export const MediaCard = ({ + ref, + children, + imageUrl, + onPress, + onClose, + lx = {}, + style, + ...pressableProps +}: MediaCardProps) => { + const { t } = useCommonTranslation(); + const [imageLoaded, setImageLoaded] = useState(false); + + const styles = useStyleSheet( + (t) => ({ + root: { + position: 'relative', + width: t.sizes.full, + height: CARD_HEIGHT, + borderRadius: t.borderRadius.md, + overflow: 'hidden', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'flex-end', + padding: t.spacings.s12, + backgroundColor: t.colors.bg.interactive, + }, + image: { + ...StyleSheet.absoluteFillObject, + width: '100%', + height: '100%', + overflow: 'hidden', + }, + content: { + flexDirection: 'column', + alignItems: 'flex-start', + gap: t.spacings.s8, + width: t.sizes.full, + minWidth: 0, + }, + closeButton: { + position: 'absolute', + top: t.spacings.s12, + right: t.spacings.s12, + }, + }), + [], + ); + + return ( + + setImageLoaded(true)} + onError={() => setImageLoaded(false)} + testID='media-card-image' + /> + + {imageLoaded && ( + <> + + + + + )} + + {children} + + [ + styles.closeButton, + pressed && { opacity: 0.6 }, + ]} + onPress={onClose} + accessibilityRole='button' + accessibilityLabel={t('common.closeAriaLabel')} + hitSlop={8} + testID='media-card-close-button' + > + + + + ); +}; + +MediaCard.displayName = 'MediaCard'; From 923bd064a8560f375de1ff0f009c5d19187a015f Mon Sep 17 00:00:00 2001 From: zel-kass Date: Wed, 18 Mar 2026 17:01:09 +0100 Subject: [PATCH 3/6] refactor(MediaCard): simplify component structure by removing leading and trailing content slots --- .../src/app/blocks/MediaCards.tsx | 25 +-- .../lib/Components/MediaCard/MediaCard.mdx | 37 +--- .../MediaCard/MediaCard.stories.tsx | 158 +++++++------- .../Components/MediaCard/MediaCard.test.tsx | 56 +---- .../lib/Components/MediaCard/MediaCard.tsx | 203 +++++------------- .../src/lib/Components/MediaCard/index.ts | 8 +- .../src/lib/Components/MediaCard/types.ts | 39 +--- 7 files changed, 160 insertions(+), 366 deletions(-) diff --git a/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx b/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx index d0190aacf..efc555e0a 100644 --- a/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx +++ b/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx @@ -1,15 +1,12 @@ import { Box, MediaCard, - MediaCardDescription, - MediaCardLeadingContent, MediaCardTitle, - MediaCardTrailingContent, Tag, } from '@ledgerhq/lumen-ui-rnative'; const EXAMPLE_SRC = - 'https://plus.unsplash.com/premium_photo-1689551670902-19b441a6afde?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'; + 'https://ledger-wp-website-s3-prd.ledger.com/uploads/2026/03/hero_visual-1.webp'; export const MediaCards = () => { return ( @@ -19,14 +16,10 @@ export const MediaCards = () => { onPress={() => ({})} onClose={() => ({})} > - - - - - - Black Friday sale. 3 days with no fees on your transactions. - - + + + Black Friday sale. 3 days with no fees on your transactions. + { onPress={() => ({})} onClose={() => ({})} > - - Secure your crypto assets - - Get started with Ledger and protect your digital assets today. - - + Secure your crypto assets + Get started with Ledger and protect your digital assets today. ); diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx index eac892fee..1aa1d7220 100644 --- a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx @@ -12,17 +12,15 @@ import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/Com ## Introduction -MediaCard is a composable card component that displays a full-bleed background image with gradient overlays for text readability. It supports a leading content slot (e.g. tags), a trailing content slot (title + description), and a close button. +MediaCard is a promotional card component that displays a full-bleed background image with gradient overlays for text readability. It uses a simplified compound pattern with `MediaCardTitle` for the text and free-form children for leading content (e.g. tags, icons). ## Anatomy - **MediaCard**: Root pressable container with background image and gradient overlays -- **MediaCardLeadingContent**: Slot for secondary content above the title (tags, badges) -- **MediaCardTrailingContent**: Container for title and description -- **MediaCardTitle**: Primary title text (clamps at 2 lines) -- **MediaCardDescription**: Secondary description text (clamps at 2 lines) +- **MediaCardTitle**: Styled title text (clamps at 3 lines) +- **Leading content**: Any element rendered before `MediaCardTitle` (tags, badges, icons) — no wrapper needed - **Close button**: Rendered via the `onClose` prop (positioned absolute top-right) ## Properties @@ -38,7 +36,7 @@ The card fills its parent width by default. ### Compositions -All slots are optional. Combine leading content and trailing content to match your use case. +Leading content is optional — just place any element before `MediaCardTitle` inside `MediaCard`. @@ -47,8 +45,7 @@ All slots are optional. Combine leading content and trailing content to match yo - The root element uses `accessibilityRole='button'` and responds to press events - The close button includes an `accessibilityLabel` (auto-translated via i18n) - The background image is not accessible (`accessible={false}`) since it is decorative -- All sub-components forward refs and spread View/Text props for accessibility support -- Image loading errors are gracefully handled by hiding the broken image +- Components forward refs and spread props for accessibility support @@ -60,14 +57,7 @@ Install and set up the library with our [Setup Guide →](?path=/docs/getting-st ## Basic Usage ```tsx -import { - MediaCard, - MediaCardLeadingContent, - MediaCardTrailingContent, - MediaCardTitle, - MediaCardDescription, - Tag, -} from '@ledgerhq/lumen-ui-rnative'; +import { MediaCard, MediaCardTitle, Tag } from '@ledgerhq/lumen-ui-rnative'; function MyComponent() { return ( @@ -76,15 +66,8 @@ function MyComponent() { onPress={() => console.log('pressed')} onClose={() => console.log('closed')} > - - - - - Card title - - Card description - - + + Card title ); } @@ -101,9 +84,7 @@ Use the `lx` prop for layout adjustments like margins or positioning: onClose={() => console.log('closed')} lx={{ marginTop: 's16', marginBottom: 's8' }} > - - With margin - + With margin ``` diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx index c9bef79d6..b7df7a5b5 100644 --- a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx @@ -2,14 +2,8 @@ import { CryptoIcon } from '@ledgerhq/crypto-icons'; import type { Meta, StoryObj } from '@storybook/react-native-web-vite'; import React from 'react'; import { Tag } from '../Tag'; -import { Box } from '../Utility'; -import { - MediaCard, - MediaCardDescription, - MediaCardLeadingContent, - MediaCardTitle, - MediaCardTrailingContent, -} from './MediaCard'; +import { Box, Text } from '../Utility'; +import { MediaCard, MediaCardTitle } from './MediaCard'; const meta = { component: MediaCard, @@ -33,23 +27,26 @@ type Story = StoryObj; const EXAMPLE_SRC = 'https://ledger-wp-website-s3-prd.ledger.com/uploads/2026/03/hero_visual-1.webp'; +const baseArgs = { + imageUrl: EXAMPLE_SRC, + onPress: () => ({}), + onClose: () => ({}), +}; + export const Base: Story = { - args: { - imageUrl: EXAMPLE_SRC, - onPress: () => ({}), - onClose: () => ({}), - }, + args: baseArgs, render: (args) => ( - + - - - - - - Black Friday sale. 3 days with no fees on your transactions. - - + + + Black Friday sale. + + {' '} + 3 days with no fees{' '} + + on your transactions. + ), @@ -57,94 +54,97 @@ export const Base: Story = { docs: { source: { code: ` - {}} onClose={() => {}}> - - - - - Card title - - -`, + {}} onClose={() => {}}> + + + Black Friday sale. + + {' '} + 3 days with no fees{' '} + + on your transactions. + +`, }, }, }, }; export const LayoutShowcase: Story = { - args: { - imageUrl: EXAMPLE_SRC, - onPress: () => ({}), - onClose: () => ({}), - }, - render: (args) => ( + render: () => ( - - - Full width card - + + + Full width card ), + parameters: { + docs: { + source: { + code: ` + {}} onClose={() => {}}> + + Full width card +`, + }, + }, + }, }; export const CompositionShowcase: Story = { - args: { - imageUrl: EXAMPLE_SRC, - onPress: () => ({}), - onClose: () => ({}), - }, - render: (args) => ( + render: () => ( - - - Title only - + + Title only - - - - - - With tag and title - - - - - - Title and description - - A short description below the main title. - - + + + With tag and title - - - - - - With crypto icon - - Leading content can be any element. - - + + + With crypto icon ), + parameters: { + docs: { + source: { + code: ` +{/* Title only */} + {}} onClose={() => {}}> + Title only + + +{/* With tag */} + {}} onClose={() => {}}> + + With tag and title + + +{/* With crypto icon */} + {}} onClose={() => {}}> + + With crypto icon +`, + }, + }, + }, }; diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx index 4ba4c3922..f9b67239a 100644 --- a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx @@ -3,13 +3,7 @@ import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core'; import { fireEvent, render } from '@testing-library/react-native'; import type { ReactNode } from 'react'; import { ThemeProvider } from '../ThemeProvider/ThemeProvider'; -import { - MediaCard, - MediaCardDescription, - MediaCardLeadingContent, - MediaCardTitle, - MediaCardTrailingContent, -} from './MediaCard'; +import { MediaCard, MediaCardTitle } from './MediaCard'; const TestWrapper = ({ children }: { children: ReactNode }) => ( @@ -24,13 +18,11 @@ const makeProps = () => ({ }); describe('MediaCard', () => { - it('should render children', () => { + it('should render title', () => { const { getByText } = render( - - Title - + Title , ); @@ -38,16 +30,12 @@ describe('MediaCard', () => { getByText('Title'); }); - it('should render leading and trailing content', () => { + it('should render leading content and title', () => { const { getByText } = render( - - Tag - - - Title - + Tag + Title , ); @@ -56,30 +44,12 @@ describe('MediaCard', () => { getByText('Title'); }); - it('should render title and description', () => { - const { getByText } = render( - - - - Card Title - Card description - - - , - ); - - getByText('Card Title'); - getByText('Card description'); - }); - it('should fire onPress on press', () => { const props = makeProps(); const { getByTestId } = render( - - Title - + Title , ); @@ -92,9 +62,7 @@ describe('MediaCard', () => { const { getByTestId } = render( - - Title - + Title , ); @@ -114,9 +82,7 @@ describe('MediaCard', () => { const { getByTestId } = render( - - Title - + Title , ); @@ -135,9 +101,7 @@ describe('MediaCard', () => { const { getByTestId } = render( - - Title - + Title , ); diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx index 49f59655a..f3e52b26b 100644 --- a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx @@ -3,79 +3,55 @@ import { Image, StyleSheet, View } from 'react-native'; import { useCommonTranslation } from '../../../i18n'; import { useStyleSheet } from '../../../styles'; import { Close } from '../../Symbols'; -import { - Box, - LinearGradient, - Pressable, - RadialGradient, - Text, -} from '../Utility'; -import { - MediaCardDescriptionProps, - MediaCardLeadingContentProps, - MediaCardProps, - MediaCardTitleProps, - MediaCardTrailingContentProps, -} from './types'; +import { LinearGradient, Pressable, RadialGradient, Text } from '../Utility'; +import { MediaCardProps, MediaCardTitleProps } from './types'; const CARD_HEIGHT = 164; const WHITE = '#FFFFFF'; -/** - * Slot for secondary content displayed above the trailing content, such as tags or icons. - */ -export const MediaCardLeadingContent = ({ - children, - lx = {}, - style, - ref, - ...viewProps -}: MediaCardLeadingContentProps) => { - return ( - - {children} - - ); -}; - -MediaCardLeadingContent.displayName = 'MediaCardLeadingContent'; - -/** - * Text content displayed at the bottom of the card. - */ -export const MediaCardTrailingContent = ({ - children, - lx = {}, - style, - ref, - ...viewProps -}: MediaCardTrailingContentProps) => { - const styles = useStyleSheet( +const useStyles = () => + useStyleSheet( (t) => ({ root: { + position: 'relative', + width: t.sizes.full, + height: CARD_HEIGHT, + borderRadius: t.borderRadius.md, + overflow: 'hidden', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'flex-end', + backgroundColor: t.colors.bg.muted, + }, + image: { + ...StyleSheet.absoluteFillObject, + width: '100%', + height: '100%', + overflow: 'hidden', + }, + content: { flexDirection: 'column', - gap: t.spacings.s4, + alignItems: 'flex-start', + gap: t.spacings.s8, + width: t.sizes.full, + minWidth: 0, + padding: t.spacings.s12, + }, + title: { + ...t.typographies.heading3SemiBold, + color: WHITE, + }, + closeButton: { + position: 'absolute', + top: t.spacings.s12, + right: t.spacings.s12, }, }), [], ); - return ( - - {children} - - ); -}; - -MediaCardTrailingContent.displayName = 'MediaCardTrailingContent'; - /** - * Title text for the card, rendered inside `MediaCardTrailingContent`. + * Title text for the card, styled with heading typography and white color. */ export const MediaCardTitle = ({ children, @@ -83,22 +59,14 @@ export const MediaCardTitle = ({ style, ref, }: MediaCardTitleProps) => { - const styles = useStyleSheet( - (t) => ({ - title: StyleSheet.flatten([ - t.typographies.heading3SemiBold, - { color: WHITE }, - ]), - }), - [], - ); + const styles = useStyles(); return ( {children} @@ -108,53 +76,18 @@ export const MediaCardTitle = ({ MediaCardTitle.displayName = 'MediaCardTitle'; -/** - * Description text displayed below the title. - */ -export const MediaCardDescription = ({ - children, - lx = {}, - style, - ref, -}: MediaCardDescriptionProps) => { - const styles = useStyleSheet( - (t) => ({ - description: StyleSheet.flatten([t.typographies.body3, { color: WHITE }]), - }), - [], - ); - - return ( - - {children} - - ); -}; - -MediaCardDescription.displayName = 'MediaCardDescription'; - /** * A media card component for displaying a full-bleed background image with - * composable text content and a close button, using gradient overlays to - * ensure readability. + * composable content and a close button, using gradient overlays to ensure + * readability. * * @example - * import { MediaCard, MediaCardLeadingContent, MediaCardTrailingContent, MediaCardTitle, MediaCardDescription } from '@ledgerhq/lumen-ui-rnative'; + * import { MediaCard, MediaCardTitle } from '@ledgerhq/lumen-ui-rnative'; + * import { Tag } from '@ledgerhq/lumen-ui-rnative'; * * {}} onClose={() => {}}> - * - * - * - * - * Card title - * Card description - * + * + * Card title * */ export const MediaCard = ({ @@ -170,41 +103,7 @@ export const MediaCard = ({ const { t } = useCommonTranslation(); const [imageLoaded, setImageLoaded] = useState(false); - const styles = useStyleSheet( - (t) => ({ - root: { - position: 'relative', - width: t.sizes.full, - height: CARD_HEIGHT, - borderRadius: t.borderRadius.md, - overflow: 'hidden', - flexDirection: 'column', - alignItems: 'flex-start', - justifyContent: 'flex-end', - padding: t.spacings.s12, - backgroundColor: t.colors.bg.interactive, - }, - image: { - ...StyleSheet.absoluteFillObject, - width: '100%', - height: '100%', - overflow: 'hidden', - }, - content: { - flexDirection: 'column', - alignItems: 'flex-start', - gap: t.spacings.s8, - width: t.sizes.full, - minWidth: 0, - }, - closeButton: { - position: 'absolute', - top: t.spacings.s12, - right: t.spacings.s12, - }, - }), - [], - ); + const styles = useStyles(); return ( setImageLoaded(true)} onError={() => setImageLoaded(false)} @@ -229,8 +128,8 @@ export const MediaCard = ({ void; /** - * The content of the card (MediaCardLeadingContent, MediaCardTrailingContent). + * The card content — typically a `MediaCardTitle` and optional + * leading content such as tags or icons. */ children: React.ReactNode; } & Omit; -/** - * Props for the MediaCardLeadingContent component - */ -export type MediaCardLeadingContentProps = { - /** - * The leading content (tags, badges, icons). - */ - children: React.ReactNode; -} & Omit; - -/** - * Props for the MediaCardTrailingContent component - */ -export type MediaCardTrailingContentProps = { - /** - * The trailing content (MediaCardTitle, MediaCardDescription). - */ - children: React.ReactNode; -} & Omit; - /** * Props for the MediaCardTitle component */ @@ -56,13 +33,3 @@ export type MediaCardTitleProps = { */ children: React.ReactNode; } & Omit; - -/** - * Props for the MediaCardDescription component - */ -export type MediaCardDescriptionProps = { - /** - * The description text or custom content. - */ - children: React.ReactNode; -} & Omit; From 310ea0b9c7e11a142f9b03bbe85695cf56314b54 Mon Sep 17 00:00:00 2001 From: zel-kass Date: Wed, 18 Mar 2026 17:02:46 +0100 Subject: [PATCH 4/6] nx release plan --- .nx/version-plans/version-plan-1773849698407.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .nx/version-plans/version-plan-1773849698407.md diff --git a/.nx/version-plans/version-plan-1773849698407.md b/.nx/version-plans/version-plan-1773849698407.md new file mode 100644 index 000000000..d3652d2ee --- /dev/null +++ b/.nx/version-plans/version-plan-1773849698407.md @@ -0,0 +1,5 @@ +--- +'@ledgerhq/lumen-ui-rnative': patch +--- + +feat(MediaCard): introduce MediaCard component with docs, tests, and figma code connect. From 9026c04926cea8c8b74857432cfb0012eb92d2c9 Mon Sep 17 00:00:00 2001 From: zel-kass Date: Wed, 18 Mar 2026 17:27:55 +0100 Subject: [PATCH 5/6] chore(dependencies): bump @ledgerhq/lumen-design-core to 0.1.5 and @ledgerhq/lumen-ui-rnative to 0.1.11 --- apps/app-sandbox-rnative/package.json | 4 +-- package-lock.json | 38 ++------------------------- 2 files changed, 4 insertions(+), 38 deletions(-) diff --git a/apps/app-sandbox-rnative/package.json b/apps/app-sandbox-rnative/package.json index 72743841e..ec491c79b 100644 --- a/apps/app-sandbox-rnative/package.json +++ b/apps/app-sandbox-rnative/package.json @@ -11,8 +11,8 @@ }, "dependencies": { "@gorhom/bottom-sheet": "^5.2.6", - "@ledgerhq/lumen-design-core": "0.1.3", - "@ledgerhq/lumen-ui-rnative": "0.1.9", + "@ledgerhq/lumen-design-core": "0.1.5", + "@ledgerhq/lumen-ui-rnative": "0.1.11", "@sbaiahmed1/react-native-blur": "^4.5.5", "expo": "~53.0.0", "expo-haptics": "~14.1.4", diff --git a/package-lock.json b/package-lock.json index 02bdbbb05..1cd3326ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -152,8 +152,8 @@ "version": "0.0.1", "dependencies": { "@gorhom/bottom-sheet": "^5.2.6", - "@ledgerhq/lumen-design-core": "0.1.3", - "@ledgerhq/lumen-ui-rnative": "0.1.9", + "@ledgerhq/lumen-design-core": "0.1.5", + "@ledgerhq/lumen-ui-rnative": "0.1.11", "@sbaiahmed1/react-native-blur": "^4.5.5", "expo": "~53.0.0", "expo-haptics": "~14.1.4", @@ -229,40 +229,6 @@ } } }, - "apps/app-sandbox-rnative/node_modules/@ledgerhq/lumen-design-core": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@ledgerhq/lumen-design-core/-/lumen-design-core-0.1.3.tgz", - "integrity": "sha512-8/QgqcKfKpukwkplQEqTK+clkC5VPTWuQ7dOw8hfiRBxmqxEmP9hHYfYaC4XyesvToUjeSnD5IIRyTESnMWr6A==", - "license": "Apache-2.0", - "dependencies": { - "tailwindcss": "^4.1.17", - "tslib": "^2.3.0" - } - }, - "apps/app-sandbox-rnative/node_modules/@ledgerhq/lumen-ui-rnative": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@ledgerhq/lumen-ui-rnative/-/lumen-ui-rnative-0.1.9.tgz", - "integrity": "sha512-SUFBtbobd+w8uQUurx66x8JSylw3fOZ3pFTuH8v1tOGfBJa7ZeAbuuMWNN80gotAO8DLbRjuLFQ2Rwe2j3Mapw==", - "license": "Apache-2.0", - "dependencies": { - "@ledgerhq/lumen-utils-shared": "0.1.1", - "i18next": "^23.7.0", - "react-i18next": "^14.0.0" - }, - "peerDependencies": { - "@gorhom/bottom-sheet": "^5.0.0", - "@ledgerhq/lumen-design-core": "0.1.3", - "@sbaiahmed1/react-native-blur": "^4.5.5", - "@types/react": "^19.0.0", - "expo": ">=53.0.0", - "expo-haptics": "~14.1.4", - "react": "^19.0.0", - "react-native": "~0.79.7", - "react-native-reanimated": "^3.0.0", - "react-native-safe-area-context": "^4.0.0 || ^5.0.0", - "react-native-svg": "^15.0.0" - } - }, "apps/app-sandbox-rnative/node_modules/@types/jest": { "version": "29.5.14", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", From 10e158518c073134e9fdbab4779da4b58331ec8e Mon Sep 17 00:00:00 2001 From: zel-kass Date: Wed, 18 Mar 2026 18:52:34 +0100 Subject: [PATCH 6/6] refactor(MediaCard): replace RadialGradient with LinearGradient and adjust gradient colors for design fidelity --- .../src/lib/Components/MediaCard/MediaCard.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx index f3e52b26b..ce2ec1c13 100644 --- a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx @@ -3,7 +3,7 @@ import { Image, StyleSheet, View } from 'react-native'; import { useCommonTranslation } from '../../../i18n'; import { useStyleSheet } from '../../../styles'; import { Close } from '../../Symbols'; -import { LinearGradient, Pressable, RadialGradient, Text } from '../Utility'; +import { LinearGradient, Pressable, Text } from '../Utility'; import { MediaCardProps, MediaCardTitleProps } from './types'; const CARD_HEIGHT = 164; @@ -128,19 +128,19 @@ export const MediaCard = ({ -