diff --git a/packages/manager/.changeset/pr-13247-upcoming-features-1767773811535.md b/packages/manager/.changeset/pr-13247-upcoming-features-1767773811535.md new file mode 100644 index 00000000000..bae9b95c584 --- /dev/null +++ b/packages/manager/.changeset/pr-13247-upcoming-features-1767773811535.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add reusable Product Selection Card component for Marketplace ([#13247](https://github.com/linode/manager/pull/13247)) diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/MarketplaceLanding.tsx similarity index 100% rename from packages/manager/src/features/Marketplace/MarketplaceLanding.tsx rename to packages/manager/src/features/Marketplace/MarketplaceLanding/MarketplaceLanding.tsx diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx new file mode 100644 index 00000000000..8f75454b5af --- /dev/null +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx @@ -0,0 +1,136 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ProductSelectionCard } from './ProductSelectionCard'; + +describe('ProductSelectionCard', () => { + const baseData = { + companyName: 'Test Company', + description: 'This is a test product description', + logoUrl: '/test-logo.png', + productName: 'Test Product', + type: 'SaaS & APIs', + }; + + it('renders all - logo image, product name, company name, description and type chip', () => { + const { getByAltText, getByText } = renderWithTheme( + {}} + /> + ); + + const logo = getByAltText('Test Product logo'); + expect(logo).toBeVisible(); + expect(logo).toHaveAttribute('src', '/test-logo.png'); + + expect(getByText('Test Product')).toBeVisible(); + expect(getByText('Test Company')).toBeVisible(); + expect(getByText('This is a test product description')).toBeVisible(); + expect(getByText('SaaS & APIs')).toBeVisible(); + }); + + it('truncates long descriptions and appends an ellipsis', () => { + const longDescription = Array(300).fill('word').join(' '); + const { getByText } = renderWithTheme( + {}} + /> + ); + + const displayedText = getByText(/word/); + // Truncate adds "..." so length should be less than original + expect(displayedText.textContent?.length).toBeLessThan( + longDescription.length + ); + expect(displayedText.textContent).toContain('...'); + }); + + it('renders product tag chip when provided', () => { + const { getByText } = renderWithTheme( + {}} + /> + ); + + expect(getByText('New')).toBeVisible(); + }); + + it('calls onClick when card is clicked', async () => { + const handleClick = vi.fn(); + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('Test Product')); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders disabled state correctly', () => { + const { getByTestId } = renderWithTheme( + {}} + /> + ); + + expect(getByTestId('selection-card')).toBeDisabled(); + }); + + it('renders all elements together', () => { + const { getByText, getByAltText } = renderWithTheme( + {}} + /> + ); + + expect(getByText('Complete Product')).toBeVisible(); + expect(getByText('Test Company')).toBeVisible(); + expect(getByText('Full product description')).toBeVisible(); + expect(getByText('New')).toBeVisible(); + expect(getByText('SaaS & APIs')).toBeVisible(); + expect(getByAltText('Complete Product logo')).toBeVisible(); + }); + + it('does not render optional elements when not provided', () => { + const { getByText, queryByText } = renderWithTheme( + {}} + /> + ); + + expect(getByText('Minimal Product')).toBeVisible(); + expect(getByText('Test Company')).toBeVisible(); + expect(getByText('This is a test product description')).toBeVisible(); + expect(getByText('SaaS & APIs')).toBeVisible(); + + // optional elements should not be in the document + expect(queryByText('New')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx new file mode 100644 index 00000000000..a72ea49a6f8 --- /dev/null +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx @@ -0,0 +1,186 @@ +import { Box, Chip, Typography } from '@linode/ui'; +import { truncate } from '@linode/utilities'; +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; + +export interface ProductCardData { + /** + * Company name displayed below the product name + */ + companyName: string; + /** + * Product description text + */ + description: string; + /** + * URL or path to the product logo image + */ + logoUrl: string; + /** + * Product name/title + */ + productName: string; + /** + * Product tag chip displayed in top right corner (e.g., "New", "30 days free trial") + */ + productTag?: string; + /** + * Bottom left type chip label (e.g., "SaaS and APIs") + */ + type: string; +} + +export interface ProductSelectionCardProps { + /** + * Product data to display + */ + data: ProductCardData; + /** + * If true, the card will be disabled + * @default false + */ + disabled?: boolean; + /** + * Callback fired when the card is clicked + */ + onClick?: () => void; +} + +/** + * A reusable product selection card component for displaying marketplace products. + * Built on top of SelectionCard for consistency. + */ +export const ProductSelectionCard = React.memo( + (props: ProductSelectionCardProps) => { + const { data, disabled = false, onClick } = props; + const { type, companyName, description, logoUrl, productName, productTag } = + data; + + const subheadings = React.useMemo( + () => [ + // Company name as first subheading + ({ + color: theme.tokens.alias.Content.Text.Secondary.Default, + font: theme.font.semibold, + fontSize: theme.tokens.font.FontSize.Xxxs, // Must come after font + })} + > + {companyName} + , + // Description + ({ + color: theme.tokens.alias.Content.Text.Primary.Default, + fontSize: theme.tokens.font.FontSize.Xs, + marginTop: theme.spacingFunction(12), + paddingBottom: theme.spacingFunction(36), // Always space for type chip at bottom + })} + variant="body1" + > + {truncate(description, 200)} + , + // Type chip (as last element with absolute positioning at bottom) + ({ + bottom: theme.spacingFunction(16), + left: theme.spacingFunction(20), + position: 'absolute', + })} + > + ({ + backgroundColor: theme.tokens.alias.Background.Informativesubtle, + })} + /> + , + ], + [companyName, description, type] + ); + + // Render header row with logo and optional Product tag chip + const renderHeader = React.useCallback(() => { + return ( + + {/* Logo */} + {logoUrl && ( + + {`${productName} + + )} + + {/* Product Tag Chip */} + {productTag && ( + ({ + '& .MuiChip-label': { + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, // Must come after font + padding: `${theme.spacingFunction(4)} ${theme.spacingFunction(6)}`, + }, + backgroundColor: + theme.tokens.component.Badge.Positive.Subtle.Background, + color: theme.tokens.component.Badge.Positive.Subtle.Text, + flexShrink: 0, + })} + /> + )} + + ); + }, [logoUrl, productName, productTag]); + + return ( + ({ + alignItems: 'flex-start', + flexDirection: 'column', + minHeight: '280px', + padding: `${theme.spacingFunction(16)} ${theme.spacingFunction(20)}`, + position: 'relative', + gap: theme.spacingFunction(12), + })} + sxCardBaseIcon={{ + alignItems: 'flex-start', + justifyContent: 'flex-start', + width: '100%', + }} + /> + ); + } +); + +const StyledLogoBox = styled(Box)({ + height: '48px', + maxWidth: '96px', + overflow: 'hidden', + width: 'auto', +}); diff --git a/packages/manager/src/features/Marketplace/marketplaceLazyRoute.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/marketplaceLazyRoute.tsx similarity index 100% rename from packages/manager/src/features/Marketplace/marketplaceLazyRoute.tsx rename to packages/manager/src/features/Marketplace/MarketplaceLanding/marketplaceLazyRoute.tsx diff --git a/packages/manager/src/routes/marketplace/index.ts b/packages/manager/src/routes/marketplace/index.ts index 9ef9c903270..cf8afd10a87 100644 --- a/packages/manager/src/routes/marketplace/index.ts +++ b/packages/manager/src/routes/marketplace/index.ts @@ -21,9 +21,9 @@ export const marketplaceCatlogRoute = createRoute({ getParentRoute: () => marketplaceRoute, path: '/catalog', }).lazy(() => - import('src/features/Marketplace/marketplaceLazyRoute').then( - (m) => m.marketplaceLazyRoute - ) + import( + 'src/features/Marketplace/MarketplaceLanding/marketplaceLazyRoute' + ).then((m) => m.marketplaceLazyRoute) ); export const marketplaceRouteTree = marketplaceRoute.addChildren([