Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Marketplace details and added tabs to the Products details page ([#13271](https://github.com/linode/manager/pull/13271))
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
} from '@linode/utilities';
import { act, renderHook } from '@testing-library/react';

import { alertFactory, notificationChannelFactory, serviceTypesFactory } from 'src/factories';
import {
alertFactory,
notificationChannelFactory,
serviceTypesFactory,
} from 'src/factories';

import { useContextualAlertsState } from '../../Utils/utils';
import { transformDimensionValue } from '../CreateAlert/Criteria/DimensionFilterValue/utils';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface CategorySectionProps {
}

export interface ProductCardItem extends ProductCardData {
id: number;
id: string;
}

const PRODUCTS_PER_BATCH = 6;
Expand All @@ -34,7 +34,7 @@ export const CategorySection = (props: CategorySectionProps) => {
const productsToDisplay = products.slice(0, displayCount);
const hasMoreProducts = products.length > displayCount;

const handleProductClick = (productId: number) => {
const handleProductClick = (productId: string) => {
navigate({ to: `/cloud-marketplace/catalog/${productId}` });
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('CategorySectionView', () => {
logoUrl: 'https://www.akamai.com/site/akamai-logo-v5.svg',
productName: 'Akamai Compute',
type: 'Saas & APIs',
id: 1,
id: 'akamai-compute',
},
];
const mockProps = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface CategorySectionViewProps {
hasMoreProducts: boolean;
isLoading?: boolean;
onLoadMore: () => void;
onProductClick: (productId: number) => void;
onProductClick: (productId: string) => void;
}

const SkeletonGrid = ({ count }: { count: number }) => (
Expand All @@ -31,7 +31,7 @@ const ProductsGrid = ({
onProductClick,
}: {
cardData: ProductCardItem[];
onProductClick: (productId: number) => void;
onProductClick: (productId: string) => void;
}) => (
<Grid container spacing={3}>
{cardData.map((item) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Product } from '../shared';
describe('filterProducts', () => {
const products: Product[] = [
{
id: 1,
id: 'titan-edge',
name: 'TITAN-Edge',
shortDescription: 'Edge compute for media and entertainment',
partner: {
Expand All @@ -20,7 +20,7 @@ describe('filterProducts', () => {
categories: ['Media & Entertainment, Gaming', 'Compute'],
},
{
id: 2,
id: 'apimetrics',
name: 'APImetrics',
shortDescription: 'API monitoring and analytics',
partner: {
Expand All @@ -33,7 +33,7 @@ describe('filterProducts', () => {
categories: ['Development Tools'],
},
{
id: 3,
id: 'spinkube',
name: 'SpinKube',
shortDescription: 'Kubernetes operator for Spin apps',
partner: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Box, Chip, Notice } from '@linode/ui';
import { styled } from '@mui/material/styles';

export const ProductDetailsContainer = styled(Box)(({ theme }) => ({
alignItems: 'flex-start',

Check warning on line 5 in packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.styles.ts

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Define a constant instead of duplicating this literal 5 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 5 times.","line":5,"column":15,"nodeType":"Literal","endLine":5,"endColumn":27}
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column',
gap: theme.spacingFunction(32),
paddingLeft: theme.spacingFunction(8),
paddingRight: theme.spacingFunction(8),
paddingTop: theme.spacingFunction(8),
}));

export const InfoBanner = styled(Notice)(() => ({
alignItems: 'flex-start',
display: 'flex',
maxWidth: '630px',
width: '100%',
marginBottom: 0,
}));

export const ProductInfoSection = styled(Box)(({ theme }) => ({
alignItems: 'flex-start',
alignSelf: 'stretch',
display: 'flex',
gap: theme.spacingFunction(24),
}));

export const LogoContainer = styled(Box)(({ theme }) => ({
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
height: theme.spacingFunction(96),
justifyContent: 'center',
width: theme.spacingFunction(96),
}));

export const ProductDetailsSection = styled(Box)(({ theme }) => ({
alignItems: 'flex-start',
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column',
gap: theme.spacingFunction(16),
}));

export const ProductTitleSection = styled(Box)(({ theme }) => ({
alignItems: 'flex-start',
display: 'flex',
flexDirection: 'column',
gap: theme.spacingFunction(2),
}));

export const TagsContainer = styled(Box)(({ theme }) => ({
alignItems: 'center',
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacingFunction(8),
}));

export const StyledChip = styled(Chip)(({ theme }) => ({
'& .MuiChip-label': {
font: theme.font.bold,
fontSize: theme.tokens.font.FontSize.Xxxs,
letterSpacing: '0.12px',
lineHeight: '12px',
padding: `${theme.spacingFunction(4)} ${theme.spacingFunction(6)}`,
},
flexShrink: 0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { Box, Button, ErrorState, Paper, Typography } from '@linode/ui';
import { useTheme } from '@mui/material/styles';
import { useParams } from '@tanstack/react-router';
import * as React from 'react';

import { Markdown } from 'src/components/Markdown/Markdown';

import { getProductById } from '../products';
import { getLogoUrl } from '../shared';
import { getProductTabDetails } from './pages';
import {
InfoBanner,
LogoContainer,
ProductDetailsContainer,
ProductDetailsSection,
ProductInfoSection,
ProductTitleSection,
StyledChip,
TagsContainer,
} from './ProductDetails.styles';
import { ProductDetailsTabs } from './ProductDetailsTabs';

/**
* Main Product Details Component
*/
export const ProductDetails = () => {
const { productId } = useParams({
from: '/cloud-marketplace/catalog/$productId',
});
const theme = useTheme();

const product = React.useMemo(() => getProductById(productId), [productId]);

// Get logo URL based on theme
const logoUrl = React.useMemo(() => {
if (!product) {
return '';
}
return getLogoUrl(product, theme);
}, [product, theme]);

// Handle invalid/unknown product id
if (!product) {
return (
<ErrorState
errorText={'Unable to load product details. Please try again later.'}
/>
);
}
Comment on lines +43 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we can move this if-block before the productDetails are fetched?


// Tab content is optional. If not present for this product, we still show the page.
const details = getProductTabDetails(productId);

// Contact sales handler placeholder - will be implemented in a future ticket
const handleContactSales = () => {
// Placeholder for contact sales functionality
};

return (
<Paper
sx={(theme) => ({
alignItems: 'flex-start',
display: 'flex',
flexDirection: 'column',
mx: {
md: 0,
sm: theme.spacingFunction(16),
xs: theme.spacingFunction(12),
},
})}
>
<ProductDetailsContainer>
{/* Info Banner (conditional) */}
{product.infoBanner && (
<InfoBanner variant="info">
<Markdown textOrMarkdown={product.infoBanner} />
</InfoBanner>
)}

{/* Product Info Section */}
<ProductInfoSection>
{/* Product Logo */}
<LogoContainer>
{logoUrl && (
<img
alt={`${product.name} logo`}
src={logoUrl}
style={{
height: '100%',
objectFit: 'contain',
width: '100%',
}}
/>
)}
</LogoContainer>

{/* Product Details */}
<ProductDetailsSection>
{/* Product Name and Partner */}
<ProductTitleSection>
<Typography
sx={(theme) => ({
color: theme.tokens.alias.Content.Text.Primary.Default,
font: theme.font.extrabold,
})}
variant="h1"
>
{product.name}
</Typography>
{product.partner && (
<Typography
sx={(theme) => ({
color: theme.tokens.alias.Content.Text.Secondary.Default,
font: theme.font.bold,
})}
variant="body1"
>
{product.partner.name}
</Typography>
)}
</ProductTitleSection>

{/* Description */}
<Typography
sx={(theme) => ({
alignSelf: 'stretch',
color: theme.tokens.component.Tile.Default.Text,
font: theme.font.normal,
maxWidth: '800px',
})}
variant="body1"
>
{product.shortDescription}
</Typography>

{/* Tags */}
<TagsContainer>
{/* Tile Tag */}
{product.tileTag && (
<StyledChip
label={product.tileTag}
sx={(theme) => ({
backgroundColor:
theme.tokens.component.Badge.Positive.Subtle.Background,
color: theme.tokens.component.Badge.Positive.Subtle.Text,
})}
/>
)}

{/* Product Tags */}
{product.productTags?.map((tag: string, index: number) => (
<StyledChip
key={index}
label={tag}
sx={(theme) => ({
backgroundColor:
theme.tokens.component.Badge.Informative.Subtle
.Background,
color: theme.tokens.component.Badge.Informative.Subtle.Text,
})}
/>
))}
</TagsContainer>

{/* Contact Sales Button */}
<Box marginTop={1}>
<Button
buttonType="primary"
data-pendo-id={`Cloud Marketplace ${product.name}-Contact Sales`}
onClick={handleContactSales}
>
Contact Sales
</Button>
</Box>
</ProductDetailsSection>
</ProductInfoSection>

{/* Product Details Tabs */}
{details && (
<Box width="100%">
<ProductDetailsTabs details={details} />
</Box>
)}
</ProductDetailsContainer>
</Paper>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Box } from '@linode/ui';
import { styled } from '@mui/material/styles';

/**
* Styled components for Overview tab layout
*/
export const OverviewContainer = styled(Box)(({ theme }) => ({
alignItems: 'flex-start',
alignSelf: 'stretch',
display: 'flex',
gap: '24px',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use spacingFunction here and below as well?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same with gaps

justifyContent: 'space-between',
[theme.breakpoints.down('md')]: {
flexDirection: 'column',
gap: '48px',
},
}));

export const VideoPlaceholder = styled(Box)(({ theme }) => ({
alignItems: 'center',
alignSelf: 'stretch',
aspectRatio: '3/2',
backgroundColor: theme.bg.bgPaper,
border: `1px dashed ${theme.tokens.alias.Border.Normal}`,
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
gap: theme.spacingFunction(8),
height: '202px',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we ok with a set height here rather than a responsive container?

Also, are we serving videos locally? Pictures is one thing but this is concerning

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No videos will not be served locally. Currently there are no videos in beta.

justifyContent: 'center',
padding: '10px',
svg: {
fill: theme.tokens.alias.Content.Icon.Primary.Default,
opacity: 0.25,
},
[theme.breakpoints.down('md')]: {
order: -1,
},
}));

export const ContentSection = styled(Box)(() => ({
flex: 1,
minWidth: 0,
}));
Loading