diff --git a/packages/manager/.changeset/pr-13271-upcoming-features-1765483386245.md b/packages/manager/.changeset/pr-13271-upcoming-features-1765483386245.md new file mode 100644 index 00000000000..a388b52c209 --- /dev/null +++ b/packages/manager/.changeset/pr-13271-upcoming-features-1765483386245.md @@ -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)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index b727035cec1..6a7252f2df3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -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'; diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySection.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySection.tsx index 69eb76b3442..785c5c321a1 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySection.tsx +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySection.tsx @@ -20,7 +20,7 @@ export interface CategorySectionProps { } export interface ProductCardItem extends ProductCardData { - id: number; + id: string; } const PRODUCTS_PER_BATCH = 6; @@ -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}` }); }; diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.test.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.test.tsx index 483cf75630f..ef2e472a540 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.test.tsx +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.test.tsx @@ -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 = { diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx index bf779f39579..8fd3cc59587 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/CategorySectionView.tsx @@ -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 }) => ( @@ -31,7 +31,7 @@ const ProductsGrid = ({ onProductClick, }: { cardData: ProductCardItem[]; - onProductClick: (productId: number) => void; + onProductClick: (productId: string) => void; }) => ( {cardData.map((item) => { diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.test.ts b/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.test.ts index e207f26c1b8..beb62002a30 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.test.ts +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/utils.test.ts @@ -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: { @@ -20,7 +20,7 @@ describe('filterProducts', () => { categories: ['Media & Entertainment, Gaming', 'Compute'], }, { - id: 2, + id: 'apimetrics', name: 'APImetrics', shortDescription: 'API monitoring and analytics', partner: { @@ -33,7 +33,7 @@ describe('filterProducts', () => { categories: ['Development Tools'], }, { - id: 3, + id: 'spinkube', name: 'SpinKube', shortDescription: 'Kubernetes operator for Spin apps', partner: { diff --git a/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.styles.ts b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.styles.ts new file mode 100644 index 00000000000..b44461a7371 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.styles.ts @@ -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', + 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, +})); diff --git a/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.tsx b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.tsx new file mode 100644 index 00000000000..368a0aa894a --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.tsx @@ -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 ( + + ); + } + + // 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 ( + ({ + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + mx: { + md: 0, + sm: theme.spacingFunction(16), + xs: theme.spacingFunction(12), + }, + })} + > + + {/* Info Banner (conditional) */} + {product.infoBanner && ( + + + + )} + + {/* Product Info Section */} + + {/* Product Logo */} + + {logoUrl && ( + {`${product.name} + )} + + + {/* Product Details */} + + {/* Product Name and Partner */} + + ({ + color: theme.tokens.alias.Content.Text.Primary.Default, + font: theme.font.extrabold, + })} + variant="h1" + > + {product.name} + + {product.partner && ( + ({ + color: theme.tokens.alias.Content.Text.Secondary.Default, + font: theme.font.bold, + })} + variant="body1" + > + {product.partner.name} + + )} + + + {/* Description */} + ({ + alignSelf: 'stretch', + color: theme.tokens.component.Tile.Default.Text, + font: theme.font.normal, + maxWidth: '800px', + })} + variant="body1" + > + {product.shortDescription} + + + {/* Tags */} + + {/* Tile Tag */} + {product.tileTag && ( + ({ + 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) => ( + ({ + backgroundColor: + theme.tokens.component.Badge.Informative.Subtle + .Background, + color: theme.tokens.component.Badge.Informative.Subtle.Text, + })} + /> + ))} + + + {/* Contact Sales Button */} + + + + + + + {/* Product Details Tabs */} + {details && ( + + + + )} + + + ); +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.styles.ts b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.styles.ts new file mode 100644 index 00000000000..5f520408ee1 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.styles.ts @@ -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', + 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', + 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, +})); diff --git a/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.tsx b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.tsx new file mode 100644 index 00000000000..96cf28da8a2 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.tsx @@ -0,0 +1,150 @@ +import { PlayCircleIcon, Typography } from '@linode/ui'; +import * as React from 'react'; + +import { Markdown } from 'src/components/Markdown/Markdown'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { Tab } from 'src/components/Tabs/Tab'; +import { TabList } from 'src/components/Tabs/TabList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; + +import { + ContentSection, + OverviewContainer, + VideoPlaceholder, +} from './ProductDetailsTabs.styles'; +import { StyledTabContent } from './TabContent.styles'; + +import type { ProductTabDetails } from './pages'; + +interface Props { + details: ProductTabDetails; +} + +/** + * Tab configuration for available detail sections + */ +interface TabConfig { + content: React.ReactNode; + label: string; + pendoId: string; +} + +/** + * Component to render sanitized Markdown content + */ +const MarkdownContentRenderer = ({ content }: { content: string }) => { + return ( + + + + ); +}; + +/** + * ProductDetailsTabs component displays product information in tabs + * Only renders tabs for sections that have content + */ +export const ProductDetailsTabs = ({ details }: Props) => { + const [index, setIndex] = React.useState(0); + const tabs: TabConfig[] = []; + + const overview = details.overview?.trim(); + const pricing = details.pricing?.trim(); + const documentation = details.documentation?.trim(); + const support = details.support?.trim(); + + // Overview Tab + if (overview) { + tabs.push({ + content: ( + + + + + + + ({ + color: theme.tokens.alias.Content.Text.Secondary.Default, + fontFamily: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xs, + })} + variant="body1" + > + Video Coming Soon + + + + ), + label: 'Overview', + pendoId: 'Cloud Marketplace Details-Overview', + }); + } + + // Pricing Tab + if (pricing) { + tabs.push({ + content: ( + + + + ), + label: 'Pricing', + pendoId: 'Cloud Marketplace Details-Pricing', + }); + } + + // Documentation Tab + if (documentation) { + tabs.push({ + content: ( + + + + ), + label: 'Documentation', + pendoId: 'Cloud Marketplace Details-Documentation', + }); + } + + // Support Tab + if (support) { + tabs.push({ + content: ( + + + + ), + label: 'Support', + pendoId: 'Cloud Marketplace Details-Support', + }); + } + + if (tabs.length === 0) { + return null; + } + + const handleTabChange = (newIndex: number) => { + setIndex(newIndex); + }; + + return ( + + + {tabs.map((tab, idx) => ( + + {tab.label} + + ))} + + + {tabs.map((tab, idx) => ( + + {tab.content} + + ))} + + + ); +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/TabContent.styles.ts b/packages/manager/src/features/Marketplace/ProductDetails/TabContent.styles.ts new file mode 100644 index 00000000000..3c866d40596 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/TabContent.styles.ts @@ -0,0 +1,151 @@ +import { styled } from '@mui/material/styles'; + +/** + * Styled container for rendering HTML content from the API + * This provides consistent styling for all HTML elements across tabs + * Styles follow Figma design specifications + */ +export const StyledTabContent = styled('div')(({ theme }) => ({ + '& a': { + color: theme.palette.primary.main, + textDecoration: 'none', + '&:hover': { + textDecoration: 'underline', + }, + }, + '& blockquote': { + borderLeft: `4px solid ${theme.borderColors.borderTable}`, + fontStyle: 'italic', + margin: `${theme.spacingFunction(16)} 0`, + paddingLeft: theme.spacingFunction(16), + }, + '& code': { + backgroundColor: theme.bg.offWhite, + borderRadius: '3px', + fontFamily: '"Courier New", Courier, monospace', + fontSize: theme.tokens.font.FontSize.Xs, + padding: `${theme.spacingFunction(2)} ${theme.spacingFunction(6)}`, + }, + '& h1': { + color: theme.tokens.alias.Content.Text.Primary.Default, + fontFamily: theme.font.bold, + fontSize: theme.tokens.font.FontSize.L, + fontStyle: 'normal', + lineHeight: theme.tokens.font.LineHeight.M, + margin: 0, + paddingBottom: theme.tokens.spacing.S16, + }, + '& h2': { + color: theme.tokens.alias.Content.Text.Primary.Default, + fontFamily: theme.font.bold, + fontSize: theme.tokens.font.FontSize.M, + fontStyle: 'normal', + lineHeight: theme.tokens.font.LineHeight.S, + margin: 0, + paddingBottom: theme.tokens.spacing.S16, + }, + '& h3': { + color: theme.tokens.alias.Content.Text.Primary.Default, + fontFamily: theme.font.bold, + fontSize: theme.tokens.font.FontSize.S, + fontStyle: 'normal', + lineHeight: theme.tokens.font.LineHeight.Xs, + margin: 0, + paddingBottom: theme.tokens.spacing.S16, + }, + '& h4, & h5, & h6': { + color: theme.tokens.alias.Content.Text.Primary.Default, + fontFamily: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xs, + fontStyle: 'normal', + lineHeight: theme.tokens.font.LineHeight.Xs, + margin: 0, + paddingBottom: theme.tokens.spacing.S16, + }, + '& img': { + display: 'block', + height: 'auto', + margin: `${theme.spacingFunction(16)} 0`, + maxWidth: '100%', + }, + '& li': { + color: theme.tokens.alias.Content.Text.Primary.Default, + fontFamily: theme.font.normal, + fontSize: theme.tokens.font.FontSize.Xs, + fontStyle: 'normal', + lineHeight: theme.tokens.font.LineHeight.Xs, + }, + '& ol': { + listStyleType: 'decimal', + margin: 0, + paddingBottom: theme.tokens.spacing.S16, + paddingLeft: theme.spacingFunction(32), + }, + '& p': { + color: theme.tokens.alias.Content.Text.Primary.Default, + fontFamily: theme.font.normal, + fontSize: theme.tokens.font.FontSize.Xs, + fontStyle: 'normal', + lineHeight: theme.tokens.font.LineHeight.Xs, + margin: 0, + paddingBottom: theme.tokens.spacing.S16, + }, + '& pre': { + backgroundColor: theme.bg.offWhite, + borderRadius: '4px', + fontFamily: '"Courier New", Courier, monospace', + fontSize: theme.tokens.font.FontSize.Xs, + margin: `${theme.spacingFunction(16)} 0`, + overflowX: 'auto', + padding: theme.spacingFunction(16), + }, + '& strong': { + fontFamily: theme.font.bold, + }, + '& table': { + borderCollapse: 'collapse', + marginBottom: theme.tokens.spacing.S16, + marginTop: 0, + width: '100%', + }, + '& td': { + alignItems: 'center', + alignSelf: 'stretch', + borderBottom: `1px solid ${theme.tokens.component.Table.Row.Border}`, + color: theme.tokens.alias.Content.Text.Primary.Default, + display: 'table-cell', + fontFamily: theme.font.normal, + fontSize: theme.tokens.font.FontSize.Xs, + fontStyle: 'normal', + gap: theme.spacingFunction(8), + height: theme.spacingFunction(40), + lineHeight: theme.tokens.font.LineHeight.Xs, + padding: `${theme.spacingFunction(10)} ${theme.spacingFunction(12)}`, + verticalAlign: 'middle', + }, + '& th': { + alignItems: 'center', + background: theme.tokens.component.Table.HeaderNested.Background, + borderBottom: `1px solid ${theme.tokens.component.Table.Row.Border}`, + display: 'table-cell', + flex: '1 0 0', + fontFamily: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xs, + gap: theme.spacingFunction(8), + lineHeight: theme.tokens.font.LineHeight.Xs, + padding: `${theme.spacingFunction(12)} ${theme.spacingFunction(16)} ${theme.spacingFunction(12)} ${theme.spacingFunction(12)}`, + textAlign: 'left', + verticalAlign: 'middle', + }, + '& ul': { + listStyleType: 'disc', + margin: 0, + paddingBottom: theme.tokens.spacing.S16, + paddingLeft: theme.spacingFunction(32), + }, + color: theme.tokens.alias.Content.Text.Primary.Default, + fontFamily: theme.font.normal, + fontSize: theme.tokens.font.FontSize.Xs, + lineHeight: theme.tokens.font.LineHeight.Xs, + wordBreak: 'break-word', +})); diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/akamai-cloud-computing.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/akamai-cloud-computing.ts new file mode 100644 index 00000000000..30c5e4f3218 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/akamai-cloud-computing.ts @@ -0,0 +1,53 @@ +/** + * Product tab details for slug akamai-cloud-computing. + * + * Content is provided as Markdown strings which are rendered at runtime. + */ + +import type { ProductTabDetails } from '.'; + +const overviewMarkdown = ` +## SpinKube Overview + +SpinKube is an open source project that streamlines the experience of deploying and operating Wasm workloads on Kubernetes, using Spin Operator in tandem with runwasi and runtime class manager. + +With SpinKube, you can leverage the advantages of using WebAssembly (Wasm) for your workloads: + +- Artifacts are significantly smaller in size compared to container images. +- Artifacts can be quickly fetched over the network and started much faster (Note: We are aware of several optimizations that still need to be implemented to enhance the startup time for workloads). +- Substantially fewer resources are required during idle times. + +### Features include: + +| Feature | Basic Plan | Middle Plan | Ultra Plan | +| :--- | :--- | :--- | :--- | +| Feature 1 | Plan 1 | Plan 3 | Plan 5 | +| Feature 2 | Plan 2 | Plan 4 | Plan 6 | +`.trim(); + +const pricingMarkdown = ` +## Pricing + +Pricing details will be discussed directly with the third-party provider Sales team after your request is received, and the third-party provider contacts you. Costs of the product you will be purchasing from the third-party provider will be charged by the third-party provider. For the referral motion, Akamai is not a party in the purchase contract. + +The full price of the product cost should be clarified between you and the third-party provider within the agreed upon terms and conditions of the purchase contract. +`.trim(); + +const documentationMarkdown = ` +## Getting started with SpinKube + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacus arcu, rhoncus id rhoncus nec, dictum nec arcu. Duis et ullamcorper libero. Cras eget dui fermentum, commodo mauris at, ultrices dolor. Nunc eleifend, nibh ac malesuada scelerisque, mi velit mollis erat, quis condimentum dolor metus vel dolor. Praesent id metus ac sem sollicitudin cursus. Nunc eleifend dui placerat magna scelerisque auctor. Donec venenatis vulputate bibendum. Donec sagittis, dui vel fringilla sagittis, nisl arcu bibendum dolor, ac viverra mauris nisi sit amet justo. Aenean efficitur varius bibendum. +`.trim(); + +const supportMarkdown = ` +## Support + +For product support, reach out to the vendor directly. You can find contact information in the product documentation and on the vendor's website. +`.trim(); + +export const akamaiCloudComputing: ProductTabDetails = { + overview: overviewMarkdown, + pricing: pricingMarkdown, + documentation: documentationMarkdown, + support: supportMarkdown, +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts new file mode 100644 index 00000000000..e730fbe7637 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts @@ -0,0 +1,30 @@ +import { akamaiCloudComputing } from './akamai-cloud-computing'; + +/** + * Tab content structure for product details page. + * Content is provided as Markdown strings which are rendered at runtime. + */ +export interface ProductTabDetails { + documentation?: string; + overview?: string; + pricing?: string; + support?: string; +} + +/** + * Map of all product detail modules. + * Each product's details are imported statically and available synchronously. + */ +const detailsMap: Record = { + 'akamai-cloud-computing': akamaiCloudComputing, + // Add more products here as you add their details files +}; + +/** + * Looks up product tab details for a given product ID (slug). + */ +export const getProductTabDetails = ( + productId: string +): ProductTabDetails | undefined => { + return detailsMap[productId]; +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/productDetailsLazyRoute.tsx b/packages/manager/src/features/Marketplace/ProductDetails/productDetailsLazyRoute.tsx new file mode 100644 index 00000000000..f9965307689 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/productDetailsLazyRoute.tsx @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { ProductDetails } from './ProductDetails'; + +export const productDetailsLazyRoute = createLazyRoute( + '/cloud-marketplace/catalog/$productId' +)({ + component: ProductDetails, +}); diff --git a/packages/manager/src/features/Marketplace/products.ts b/packages/manager/src/features/Marketplace/products.ts index 032fc13d6bc..6458cf23ff4 100644 --- a/packages/manager/src/features/Marketplace/products.ts +++ b/packages/manager/src/features/Marketplace/products.ts @@ -2,7 +2,7 @@ import type { Product } from './shared'; export const PRODUCTS: Product[] = [ { - id: 100001, + id: 'akamai-cloud-computing', name: 'Akamai Cloud Computing', shortDescription: 'Akamai provides cloud computing, security, and content delivery services...', @@ -22,7 +22,7 @@ export const PRODUCTS: Product[] = [ categories: ['CDN Affiliated', 'Networking'], }, { - id: 100002, + id: 'product-2', name: 'Product 2', shortDescription: 'Akamai provides cloud computing, security, and content delivery services...', @@ -42,7 +42,7 @@ export const PRODUCTS: Product[] = [ categories: ['CDN Affiliated'], }, { - id: 100003, + id: 'product-3', name: 'Product 3', shortDescription: 'Akamai provides cloud computing, security, and content delivery services...', @@ -61,7 +61,7 @@ export const PRODUCTS: Product[] = [ categories: ['CDN Affiliated', 'Networking'], }, { - id: 100004, + id: 'product-4', name: 'Product 4', shortDescription: 'Akamai provides cloud computing, security, and content delivery services...', @@ -80,7 +80,7 @@ export const PRODUCTS: Product[] = [ categories: ['CDN Affiliated'], }, { - id: 100005, + id: 'product-5', name: 'Product 5', shortDescription: 'Akamai provides cloud computing, security, and content delivery services...', @@ -99,7 +99,7 @@ export const PRODUCTS: Product[] = [ categories: ['CDN Affiliated'], }, { - id: 100006, + id: 'product-6', name: 'Product 6', shortDescription: 'Akamai provides cloud computing, security, and content delivery services...', @@ -118,7 +118,7 @@ export const PRODUCTS: Product[] = [ categories: ['CDN Affiliated'], }, { - id: 100007, + id: 'product-7', name: 'Product 7', shortDescription: 'Akamai provides cloud computing, security, and content delivery services...', @@ -137,3 +137,7 @@ export const PRODUCTS: Product[] = [ categories: ['CDN Affiliated'], }, ]; + +export const getProductById = (productId: string): Product | undefined => { + return PRODUCTS.find((product) => product.id === productId); +}; diff --git a/packages/manager/src/features/Marketplace/shared.ts b/packages/manager/src/features/Marketplace/shared.ts index ce1c1052ebf..c1454d26d37 100644 --- a/packages/manager/src/features/Marketplace/shared.ts +++ b/packages/manager/src/features/Marketplace/shared.ts @@ -26,7 +26,7 @@ export type Type = export interface Product { categories: Category[]; - id: number; + id: string; infoBanner?: string; name: string; partner: { diff --git a/packages/manager/src/routes/marketplace/index.ts b/packages/manager/src/routes/marketplace/index.ts index 817559788e3..38ee6e6d6f2 100644 --- a/packages/manager/src/routes/marketplace/index.ts +++ b/packages/manager/src/routes/marketplace/index.ts @@ -33,7 +33,17 @@ export const marketplaceCatlogRoute = createRoute({ ).then((m) => m.marketplaceLazyRoute) ); +export const marketplaceProductDetailsRoute = createRoute({ + getParentRoute: () => marketplaceRoute, + path: '/catalog/$productId', +}).lazy(() => + import( + 'src/features/Marketplace/ProductDetails/productDetailsLazyRoute' + ).then((m) => m.productDetailsLazyRoute) +); + export const marketplaceRouteTree = marketplaceRoute.addChildren([ marketplaceLandingRoute, marketplaceCatlogRoute, + marketplaceProductDetailsRoute, ]); diff --git a/packages/ui/.changeset/pr-13271-added-1767856294367.md b/packages/ui/.changeset/pr-13271-added-1767856294367.md new file mode 100644 index 00000000000..70c2732a9e7 --- /dev/null +++ b/packages/ui/.changeset/pr-13271-added-1767856294367.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Added +--- + +Added play icon ([#13271](https://github.com/linode/manager/pull/13271)) diff --git a/packages/ui/src/assets/icons/index.ts b/packages/ui/src/assets/icons/index.ts index f8435916882..00a20d286c2 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -17,6 +17,7 @@ export { default as InfoIcon } from './info.svg'; export { default as LightBulbIcon } from './lightbulb.svg'; export { default as LoadFailureIcon } from './load-failure.svg'; export { default as PendingIcon } from './pending.svg'; +export { default as PlayCircleIcon } from './play-circle.svg'; export { default as PlusSignIcon } from './plusSign.svg'; export { default as RadioIcon } from './radio.svg'; export { default as RadioIconRadioed } from './radioRadioed.svg'; diff --git a/packages/ui/src/assets/icons/play-circle.svg b/packages/ui/src/assets/icons/play-circle.svg new file mode 100644 index 00000000000..def18ce8bd0 --- /dev/null +++ b/packages/ui/src/assets/icons/play-circle.svg @@ -0,0 +1,3 @@ + + +