chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export { SolutionsCardRow } from './solutions-card-row'
|
||||
export { SolutionsHero } from './solutions-hero'
|
||||
export { SolutionsLogosRow } from './solutions-logos-row'
|
||||
export { SolutionsStructuredData } from './solutions-structured-data'
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export { SolutionsCard } from './solutions-card'
|
||||
export { SolutionsCardRowHeader } from './solutions-card-row-header'
|
||||
export { SolutionsPillCta } from './solutions-pill-cta'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SolutionsCardRowHeader } from './solutions-card-row-header'
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { SolutionsPillCta } from '@/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta'
|
||||
import {
|
||||
SOLUTIONS_SPACING,
|
||||
SOLUTIONS_TEXT_MEASURE,
|
||||
} from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* The header block of a card row - an `<h2>` title, a body-color subtitle, and
|
||||
* a single pill CTA, stacked with named spacing constants. Extracted from
|
||||
* {@link SolutionsCardRow} so layouts that place row headers inside a shared
|
||||
* grid (the enterprise feature grid) render the exact same header chrome.
|
||||
*
|
||||
* Only the row header stack can opt into centered text; the `feature` variant
|
||||
* is the tighter, smaller treatment used by feature-tile pages.
|
||||
*/
|
||||
|
||||
interface SolutionsCardRowHeaderProps {
|
||||
row: SolutionsCardRowConfig
|
||||
/** Stable id wiring the `<h2>` into the page outline. */
|
||||
headingId: string
|
||||
/** Header stack alignment. Defaults to the original left-aligned layout. */
|
||||
align?: 'left' | 'center'
|
||||
/** Header typography treatment. Defaults to the original larger solutions header. */
|
||||
variant?: 'standard' | 'feature'
|
||||
}
|
||||
|
||||
export function SolutionsCardRowHeader({
|
||||
row,
|
||||
headingId,
|
||||
align = 'left',
|
||||
variant = 'standard',
|
||||
}: SolutionsCardRowHeaderProps) {
|
||||
const centered = align === 'center'
|
||||
const featureHeader = variant === 'feature'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
centered ? 'items-center text-center' : 'items-start text-left',
|
||||
featureHeader ? 'gap-3' : SOLUTIONS_SPACING.cardRowHeaderStack
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
id={headingId}
|
||||
className={cn(
|
||||
'text-balance text-[var(--text-primary)] leading-[1.3]',
|
||||
featureHeader
|
||||
? 'max-w-[540px] font-medium text-[22px] max-sm:text-[20px]'
|
||||
: 'max-w-[760px] text-[32px] max-sm:text-[24px]'
|
||||
)}
|
||||
>
|
||||
{row.title}
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
featureHeader ? 'w-full min-w-0 max-w-[48ch]' : SOLUTIONS_TEXT_MEASURE.rowSubtitle,
|
||||
'text-pretty',
|
||||
featureHeader
|
||||
? 'text-[15px] text-[var(--text-muted)] leading-[1.6]'
|
||||
: 'text-[20px] text-[var(--text-body)] leading-[1.5]'
|
||||
)}
|
||||
>
|
||||
{row.subtitle}
|
||||
</p>
|
||||
<div
|
||||
className={
|
||||
featureHeader
|
||||
? SOLUTIONS_SPACING.cardRowHeaderCtaGapFeature
|
||||
: SOLUTIONS_SPACING.cardRowHeaderCtaGap
|
||||
}
|
||||
>
|
||||
<SolutionsPillCta cta={row.cta} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SolutionsCard } from './solutions-card'
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { SolutionsVisualFrame } from '@/app/(landing)/components/solutions-page/components/solutions-visual-frame'
|
||||
import {
|
||||
SOLUTIONS_FEATURE_TILE_TONE,
|
||||
SOLUTIONS_SPACING,
|
||||
SOLUTIONS_TEXT_MEASURE,
|
||||
SOLUTIONS_VISUAL,
|
||||
} from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsCardConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* A single solutions card - an `<article>` with an `<h3>` title, a body-color
|
||||
* description, and a reserved visual area. The default split variant keeps copy
|
||||
* on the page canvas with a framed visual beneath it. The feature-tile variant
|
||||
* moves copy and the visual slot into one larger bordered surface for pages that
|
||||
* need a unified callout card.
|
||||
*
|
||||
* The card owns the gap between its text and visual (`cardTextToVisual`) and the
|
||||
* title→description stack (`cardTextStack`) - both from named spacing constants.
|
||||
* The split variant lands the visual in a fixed-height
|
||||
* {@link SolutionsVisualFrame}; the feature-tile variant reserves a larger
|
||||
* flexible slot inside its own frame for future UI.
|
||||
*
|
||||
* A content unit only: it accepts copy and a visual node plus a controlled visual
|
||||
* treatment, never arbitrary spacing or class overrides.
|
||||
*/
|
||||
|
||||
interface SolutionsCardProps {
|
||||
card: SolutionsCardConfig
|
||||
/** Stable id wiring the `<h3>` into the page outline. */
|
||||
headingId: string
|
||||
/** Visual treatment. Defaults to the original split text + framed visual layout. */
|
||||
variant?: 'split' | 'featureTile'
|
||||
}
|
||||
|
||||
export function SolutionsCard({ card, headingId, variant = 'split' }: SolutionsCardProps) {
|
||||
const featureTile = variant === 'featureTile'
|
||||
const featureTileTone = SOLUTIONS_FEATURE_TILE_TONE[card.featureTileTone ?? 'light']
|
||||
const featureTileDescription =
|
||||
card.featureTileDescriptionTone === 'soft' && card.featureTileTone === 'dark'
|
||||
? SOLUTIONS_FEATURE_TILE_TONE.dark.descriptionSoft
|
||||
: featureTileTone.description
|
||||
const textBlock = (
|
||||
<div className={cn('flex flex-col', SOLUTIONS_SPACING.cardTextStack)}>
|
||||
<h3
|
||||
id={headingId}
|
||||
className={cn(
|
||||
'leading-[1.3]',
|
||||
featureTile
|
||||
? cn('font-medium text-[16px]', featureTileTone.title)
|
||||
: 'text-[18px] text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{card.title}
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
SOLUTIONS_TEXT_MEASURE.cardDescription,
|
||||
'text-pretty leading-[1.5]',
|
||||
featureTile
|
||||
? cn('text-[14px]', featureTileDescription)
|
||||
: 'text-[15px] text-[var(--text-body)]'
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (featureTile) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'flex h-full flex-col overflow-hidden rounded-lg',
|
||||
featureTileTone.surface,
|
||||
SOLUTIONS_VISUAL.featureTileMinHeight,
|
||||
SOLUTIONS_SPACING.cardFeatureTilePadding
|
||||
)}
|
||||
>
|
||||
{textBlock}
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-mr-8 -mb-8 max-lg:-mr-6 max-lg:-mb-6 mt-8 min-h-[240px] w-[calc(100%+2rem)] flex-1 max-lg:min-h-[210px] max-lg:w-[calc(100%+1.5rem)]'
|
||||
>
|
||||
<div className='h-full w-full'>{card.visual}</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={cn('flex h-full flex-col', SOLUTIONS_SPACING.cardTextToVisual)}>
|
||||
<div className='flex flex-1 flex-col'>{textBlock}</div>
|
||||
|
||||
<SolutionsVisualFrame size='card'>{card.visual}</SolutionsVisualFrame>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SolutionsPillCta } from './solutions-pill-cta'
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import type { SolutionsPillCta as SolutionsPillCtaConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* The card-row pill CTA - a single primary `ChipLink` with a trailing arrow,
|
||||
* matching the reference image's "Learn about automations →". The chip owns its
|
||||
* own chrome and spacing; this component only wires the label, the href, and the
|
||||
* arrow icon, exposing no layout knobs.
|
||||
*
|
||||
* Client leaf: `ChipLink` is a Client Component and its `rightIcon` is a
|
||||
* component reference (`ArrowRight`), which cannot cross the server→client
|
||||
* boundary as a prop - so the icon must be wired from client code, exactly as the
|
||||
* navbar's `NavMenuChip` does. The props it receives ({@link SolutionsPillCtaConfig})
|
||||
* are plain serializable data, so the surrounding layout stays Server Components.
|
||||
*
|
||||
* Link safety: an external href (absolute `http(s)://`) renders with
|
||||
* `rel='noopener noreferrer'` and `target='_blank'`; an internal href routes
|
||||
* through the Next `<Link>` that `ChipLink` is built on - so every link is
|
||||
* crawlable and safe with no per-page ceremony.
|
||||
*/
|
||||
|
||||
interface SolutionsPillCtaProps {
|
||||
cta: SolutionsPillCtaConfig
|
||||
}
|
||||
|
||||
/** Returns true for absolute external URLs (http/https), which need rel/target hardening. */
|
||||
function isExternalHref(href: string): boolean {
|
||||
return /^https?:\/\//.test(href)
|
||||
}
|
||||
|
||||
export function SolutionsPillCta({ cta }: SolutionsPillCtaProps) {
|
||||
const external = isExternalHref(cta.href)
|
||||
|
||||
return (
|
||||
<ChipLink
|
||||
variant='primary'
|
||||
href={cta.href}
|
||||
rightIcon={ArrowRight}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{cta.label}
|
||||
</ChipLink>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SolutionsCardRow } from './solutions-card-row'
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import {
|
||||
SolutionsCard,
|
||||
SolutionsCardRowHeader,
|
||||
} from '@/app/(landing)/components/solutions-page/components/solutions-card-row/components'
|
||||
import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* A card row - the core repeating unit of a solutions page. A header block (an
|
||||
* `<h2>` title, a body-color subtitle, and a single pill CTA) sits above a grid
|
||||
* of cards. The grid column count is derived from `cards.length` - 3 cards render
|
||||
* `grid-cols-3`, 4 render `grid-cols-4` - so the page never specifies layout.
|
||||
*
|
||||
* Rendered as a labelled `<section>` for a clean, crawlable landmark; each card
|
||||
* is an `<article>` with an `<h3>`, keeping the strict H2 → H3 hierarchy. Every
|
||||
* gap (header sub-stack, header-to-grid, and inter-card) is owned by named
|
||||
* spacing constants. Only the row header stack can opt into centered text; card
|
||||
* text remains left aligned.
|
||||
*/
|
||||
|
||||
interface SolutionsCardRowProps {
|
||||
row: SolutionsCardRowConfig
|
||||
/** Header stack alignment. Defaults to the original left-aligned layout. */
|
||||
align?: 'left' | 'center'
|
||||
/** Card treatment. Defaults to the original split copy + visual layout. */
|
||||
cardVariant?: 'split' | 'featureTile'
|
||||
/** Header typography treatment. Defaults to the original larger solutions header. */
|
||||
headerVariant?: 'standard' | 'feature'
|
||||
}
|
||||
|
||||
/** Maps a supported card count to its grid column class; anything else falls back to three-up. */
|
||||
const GRID_COLS: Record<number, string> = {
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
}
|
||||
|
||||
export function SolutionsCardRow({
|
||||
row,
|
||||
align = 'left',
|
||||
cardVariant = 'split',
|
||||
headerVariant = 'standard',
|
||||
}: SolutionsCardRowProps) {
|
||||
const headingId = `solutions-row-${row.id}-heading`
|
||||
const gridCols = GRID_COLS[row.cards.length] ?? GRID_COLS[3]
|
||||
|
||||
return (
|
||||
<section
|
||||
id={`solutions-row-${row.id}`}
|
||||
aria-labelledby={headingId}
|
||||
className={cn('flex flex-col', SOLUTIONS_SPACING.cardRowHeaderToGrid)}
|
||||
>
|
||||
<SolutionsCardRowHeader
|
||||
row={row}
|
||||
headingId={headingId}
|
||||
align={align}
|
||||
variant={headerVariant}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
gridCols,
|
||||
'max-sm:grid-cols-1 max-lg:grid-cols-2',
|
||||
SOLUTIONS_SPACING.cardGridGap
|
||||
)}
|
||||
>
|
||||
{row.cards.map((card, index) => (
|
||||
<SolutionsCard
|
||||
key={`${row.id}-${card.title}`}
|
||||
card={card}
|
||||
headingId={`solutions-row-${row.id}-card-${index}-heading`}
|
||||
variant={cardVariant}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SolutionsHero } from './solutions-hero'
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import { LandingHeroHeader } from '@/app/(landing)/components/hero/components/hero-header'
|
||||
import { HeroCta } from '@/app/(landing)/components/hero-cta'
|
||||
import {
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_CTA_GAP,
|
||||
LANDING_HERO_TOP_PADDING,
|
||||
} from '@/app/(landing)/components/landing-layout'
|
||||
import { SolutionsVisualFrame } from '@/app/(landing)/components/solutions-page/components/solutions-visual-frame'
|
||||
import {
|
||||
SOLUTIONS_SPACING,
|
||||
SOLUTIONS_TEXT_MEASURE,
|
||||
} from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsHeroConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* Solutions hero - the only `<h1>` on a solutions page. Header copy (headline +
|
||||
* supporting description) sits above the same CTA as the landing hero
|
||||
* ({@link HeroCta}, the single source of truth), then a full-width solutions visual
|
||||
* underneath. Pages can optionally center this header stack, use the home hero
|
||||
* top layout, and show a mono chip above the headline, matching the home landing
|
||||
* feature tags.
|
||||
*
|
||||
* The header column and the visual are stacked in one flex column; the header's
|
||||
* own sub-stack (tag → headline → description → CTA) and the gap down to the visual are
|
||||
* both owned by named spacing constants, so a consumer page passes only copy and
|
||||
* a visual node - never any spacing. The visual renders into a reserved-aspect
|
||||
* {@link SolutionsVisualFrame} (CLS = 0).
|
||||
*
|
||||
* Carries the page's sr-only ~50-word product summary for AI citation (GEO). The
|
||||
* standard variant inherits its gutter from `SolutionsPage`; the home variant
|
||||
* owns the exact shared homepage cap and gutter because enterprise renders it
|
||||
* outside the solutions content wrapper.
|
||||
*/
|
||||
|
||||
interface SolutionsHeroProps {
|
||||
hero: SolutionsHeroConfig
|
||||
/** Header stack alignment. Defaults to the original left-aligned layout. */
|
||||
align?: 'left' | 'center'
|
||||
/** Visual treatment for the top hero. Defaults to the original solutions layout. */
|
||||
variant?: 'solutions' | 'home'
|
||||
}
|
||||
|
||||
export function SolutionsHero({ hero, align = 'left', variant = 'solutions' }: SolutionsHeroProps) {
|
||||
const centered = align === 'center'
|
||||
const homeVariant = variant === 'home'
|
||||
|
||||
const eyebrow = hero.eyebrow ? <ChipTag variant='mono'>{hero.eyebrow}</ChipTag> : null
|
||||
|
||||
if (homeVariant) {
|
||||
return (
|
||||
<section
|
||||
id='solutions-hero'
|
||||
aria-labelledby='solutions-hero-heading'
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-[22px] text-left',
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_TOP_PADDING
|
||||
)}
|
||||
>
|
||||
<p className='sr-only'>{hero.summary}</p>
|
||||
|
||||
<LandingHeroHeader
|
||||
eyebrow={eyebrow}
|
||||
heading={hero.heading}
|
||||
headingId='solutions-hero-heading'
|
||||
description={hero.description}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='relative mt-[34px] aspect-[1300/720] w-full overflow-hidden rounded-lg bg-[var(--surface-3)] max-sm:aspect-[4/3]'
|
||||
>
|
||||
{hero.visual}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id='solutions-hero'
|
||||
aria-labelledby='solutions-hero-heading'
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
SOLUTIONS_SPACING.heroTopPadding,
|
||||
SOLUTIONS_SPACING.heroToVisual
|
||||
)}
|
||||
>
|
||||
<p className='sr-only'>{hero.summary}</p>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
centered ? 'items-center text-center' : 'items-start text-left',
|
||||
SOLUTIONS_SPACING.heroStack
|
||||
)}
|
||||
>
|
||||
{eyebrow}
|
||||
|
||||
<h1
|
||||
id='solutions-hero-heading'
|
||||
className='max-w-[900px] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
|
||||
>
|
||||
{hero.heading}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
SOLUTIONS_TEXT_MEASURE.heroDescription,
|
||||
'text-pretty text-[20px] text-[var(--text-body)] leading-[1.5]'
|
||||
)}
|
||||
>
|
||||
{hero.description}
|
||||
</p>
|
||||
|
||||
<div className={cn('max-sm:w-full', LANDING_HERO_CTA_GAP)}>
|
||||
<HeroCta />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SolutionsVisualFrame size='hero'>{hero.visual}</SolutionsVisualFrame>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SolutionsLogosRow } from './solutions-logos-row'
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Logos } from '@/app/(landing)/components/logos'
|
||||
|
||||
/**
|
||||
* Solutions logos row - the same customer wordmarks as the landing hero, in a
|
||||
* single horizontally-centered row at the shared `gap-x-24` rhythm (owned by the
|
||||
* shared {@link Logos} component, `row` layout). Takes no props and exposes no
|
||||
* spacing: its horizontal gutter comes from `SolutionsPage` and its inter-section
|
||||
* spacing from the page's `<main>` gap.
|
||||
*
|
||||
* Wrapped as a labelled `<section>` so it is a discrete, crawlable landmark; the
|
||||
* heading is sr-only because the logos are a proof band rather than a content
|
||||
* section, but the H2 keeps the page's heading hierarchy intact. The section is
|
||||
* `relative` so the `sr-only` (`position: absolute`) heading is contained by it
|
||||
* rather than falling back to the document root - matching {@link Features}'s
|
||||
* `relative` wrapper for its own `sr-only` heading, and avoiding a phantom root
|
||||
* scrollbar (the heading's un-offset static position would otherwise inflate
|
||||
* `document.documentElement`'s scroll height by its own position in the page).
|
||||
*/
|
||||
export function SolutionsLogosRow() {
|
||||
return (
|
||||
<section id='solutions-logos' aria-labelledby='solutions-logos-heading' className='relative'>
|
||||
<h2 id='solutions-logos-heading' className='sr-only'>
|
||||
Companies building AI agents with Sim
|
||||
</h2>
|
||||
<div className='flex justify-center'>
|
||||
<Logos layout='row' />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SolutionsStructuredData } from './solutions-structured-data'
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import type { SolutionsPageConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* JSON-LD for a solutions page - a `WebPage` (about a `WebApplication`) plus a
|
||||
* `BreadcrumbList`, rendered server-side before any visible content so crawlers
|
||||
* and AI answer engines read the structured data first.
|
||||
*
|
||||
* Everything is derived from the same {@link SolutionsPageConfig} that drives the
|
||||
* visible sections, so the structured data can never drift from the page: the
|
||||
* `WebApplication.featureList` is the deduped set of card titles actually
|
||||
* rendered, the page name/description come from the hero, and the breadcrumb
|
||||
* from the module + path. The page author maintains zero schema by hand.
|
||||
*
|
||||
* Server Component; no client cost. Internal to the solutions layout - emitted by
|
||||
* `SolutionsPage`, never rendered by a consumer directly.
|
||||
*/
|
||||
|
||||
interface SolutionsStructuredDataProps {
|
||||
config: SolutionsPageConfig
|
||||
}
|
||||
|
||||
export function SolutionsStructuredData({ config }: SolutionsStructuredDataProps) {
|
||||
const { module, path, hero, rows } = config
|
||||
const url = `${SITE_URL}${path}`
|
||||
const featureList = Array.from(
|
||||
new Set(rows.flatMap((row) => row.cards.map((card) => card.title)))
|
||||
)
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
'@id': `${url}#webpage`,
|
||||
url,
|
||||
name: hero.heading,
|
||||
description: hero.summary,
|
||||
isPartOf: { '@id': `${SITE_URL}#website` },
|
||||
about: { '@id': `${url}#application` },
|
||||
breadcrumb: { '@id': `${url}#breadcrumb` },
|
||||
inLanguage: 'en-US',
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': `${url}#breadcrumb`,
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
|
||||
{ '@type': 'ListItem', position: 2, name: module, item: url },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'WebApplication',
|
||||
'@id': `${url}#application`,
|
||||
name: `Sim ${module}`,
|
||||
description: hero.summary,
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
url,
|
||||
featureList,
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return <JsonLd data={jsonLd} />
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SolutionsVisualFrame } from './solutions-visual-frame'
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { SOLUTIONS_VISUAL } from '@/app/(landing)/components/solutions-page/constants'
|
||||
|
||||
/**
|
||||
* The one escape hatch in the solutions layout - a fixed-dimension frame that
|
||||
* holds a page-supplied visual `ReactNode`. The frame owns its chrome (the
|
||||
* hero-visual family: `--surface-2` fill, `--border-1` hairline, `rounded-lg`,
|
||||
* `overflow-hidden`) and, crucially, its dimensions: a `hero` frame reserves a
|
||||
* 16:9 aspect ratio, a `card` frame a fixed height. Because the size is reserved
|
||||
* before paint and the node fills `h-full w-full` inside, a dropped-in node can
|
||||
* neither shift surrounding layout (CLS = 0) nor change the frame's own padding.
|
||||
*
|
||||
* The frame is decorative chrome around product visuals, so it is `aria-hidden`;
|
||||
* the page's meaning lives in the adjacent headings and copy.
|
||||
*/
|
||||
|
||||
interface SolutionsVisualFrameProps {
|
||||
/**
|
||||
* Reserved-dimension preset.
|
||||
* - `hero` - full-width 16:9 frame for the solutions hero visual.
|
||||
* - `card` - fixed-height frame for a card's visual panel.
|
||||
*/
|
||||
size: 'hero' | 'card'
|
||||
/** The page-supplied visual island or static panel. Fills the frame; owns no chrome. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SolutionsVisualFrame({ size, children }: SolutionsVisualFrameProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'w-full overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)]',
|
||||
size === 'hero' ? SOLUTIONS_VISUAL.heroAspect : SOLUTIONS_VISUAL.cardHeight
|
||||
)}
|
||||
>
|
||||
<div className='h-full w-full'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_TOP_PADDING,
|
||||
LANDING_SECTION_RHYTHM,
|
||||
} from '@/app/(landing)/components/landing-layout'
|
||||
|
||||
/**
|
||||
* Solutions-layout spacing - the single source of truth for every gutter, gap,
|
||||
* inset, and reserved dimension used across the solutions-page components.
|
||||
*
|
||||
* The padding fortress lives here. No solutions component accepts a `className`
|
||||
* or `style`, and none hard-codes a spacing value inline. Every measurement a
|
||||
* reviewer might want to audit - the horizontal gutter, the inter-section rhythm,
|
||||
* the card-grid gaps, the card stack, and the fixed visual-slot dimensions - is
|
||||
* named in this one file. To change spacing you edit a constant here; consumer
|
||||
* pages only choose from controlled component variants.
|
||||
*
|
||||
* All values are Tailwind class fragments (not raw numbers) so they compose
|
||||
* directly into `className` strings inside the components and stay legible.
|
||||
*/
|
||||
export const SOLUTIONS_SPACING = {
|
||||
/**
|
||||
* The one horizontal gutter, owned solely by `SolutionsPage`. Matches the
|
||||
* navbar and landing hero exactly (`px-20 max-lg:px-8 max-sm:px-5`) so
|
||||
* solutions content starts on the wordmark's vertical line at every
|
||||
* breakpoint. Sections and cards never set their own gutter.
|
||||
*/
|
||||
gutter: LANDING_GUTTER,
|
||||
/**
|
||||
* Inter-section vertical rhythm - the gap of the `<main>` flex column that
|
||||
* `SolutionsPage` owns. Sections carry no vertical margin/padding of their own,
|
||||
* so this is the only knob for the space between the hero, logos, and every
|
||||
* card row. Tightens on smaller screens in lockstep with the landing `<main>`
|
||||
* (`gap-[120px] max-lg:gap-[88px] max-sm:gap-16`).
|
||||
*/
|
||||
sectionRhythm: LANDING_SECTION_RHYTHM,
|
||||
/** Hero text top padding, matching the landing hero at every breakpoint. */
|
||||
heroTopPadding: LANDING_HERO_TOP_PADDING,
|
||||
/** Vertical stack gap inside the hero header column (headline → description → CTA). */
|
||||
heroStack: 'gap-[22px]',
|
||||
/** Gap between the hero header column and the full-width hero visual beneath it. */
|
||||
heroToVisual: 'gap-12',
|
||||
/** Gap between a card row's header block and the card grid beneath it. */
|
||||
cardRowHeaderToGrid: 'gap-12',
|
||||
/** Vertical stack gap inside a card row's header (title → subtitle → CTA). */
|
||||
cardRowHeaderStack: 'gap-5',
|
||||
/**
|
||||
* Extra top separation for the header CTA over the stack gap. Title and
|
||||
* subtitle are one copy group and stay tight; the CTA is a separate action
|
||||
* group, so its subtitle→CTA gap lands at 2× the title→subtitle gap
|
||||
* (standard: 20px + 20px = 40px; feature: 12px + 12px = 24px).
|
||||
*/
|
||||
cardRowHeaderCtaGap: 'mt-5',
|
||||
cardRowHeaderCtaGapFeature: 'mt-3',
|
||||
/** Gap between cards within a card-row grid (both axes). */
|
||||
cardGridGap: 'gap-8',
|
||||
/** Minimum gap between a card's text block and its visual panel. */
|
||||
cardTextToVisual: 'gap-5',
|
||||
/** Vertical stack gap inside a card's text block (title → description). */
|
||||
cardTextStack: 'gap-2',
|
||||
/** Inner padding for feature tiles where copy and the visual slot share one frame. */
|
||||
cardFeatureTilePadding: 'p-8 max-lg:p-6',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Readable text measures for the recurring solutions-page copy surfaces.
|
||||
* Paragraph width is expressed in characters so line length tracks type size
|
||||
* instead of the viewport. `min-w-0` keeps flex items wrapping cleanly on narrow
|
||||
* screens, and `w-full` gives centered copy a stable measure.
|
||||
*/
|
||||
export const SOLUTIONS_TEXT_MEASURE = {
|
||||
/** Hero support copy: broad enough for the primary value prop, still under long-form prose width. */
|
||||
heroDescription: 'w-full min-w-0 max-w-[58ch]',
|
||||
/** Section subtitles: slightly tighter than hero copy so centered headers feel intentional. */
|
||||
rowSubtitle: 'w-full min-w-0 max-w-[52ch]',
|
||||
/** Card descriptions: short scan lines inside three-up card grids. */
|
||||
cardDescription: 'min-w-0 max-w-[38ch]',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Reserved fixed dimensions for the component-owned visual frames. A dropped-in
|
||||
* `ReactNode` renders into a frame of exactly these dimensions, so it can never
|
||||
* shift surrounding layout (CLS = 0) nor change its own frame padding. The node
|
||||
* fills `h-full w-full` inside; it owns nothing about the frame.
|
||||
*/
|
||||
export const SOLUTIONS_VISUAL = {
|
||||
/** Full-width hero visual aspect ratio - reserves height before paint. */
|
||||
heroAspect: 'aspect-[16/9]',
|
||||
/** Fixed height of a card's visual panel - uniform across every card. */
|
||||
cardHeight: 'h-[240px]',
|
||||
/**
|
||||
* Minimum height for framed feature tiles with copy and future UI in one
|
||||
* surface. One value at every breakpoint: the tallest tile vignettes (audit
|
||||
* ledger, staging panel) need ~300px of visual slot below the copy block, so
|
||||
* shrinking the tile on small screens crops their tops against the slot's
|
||||
* `overflow-hidden`.
|
||||
*/
|
||||
featureTileMinHeight: 'min-h-[440px]',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Feature-tile surface + copy colors. Pages pick a tone per card via
|
||||
* {@link SolutionsCardConfig.featureTileTone}; the component maps it here so
|
||||
* each tile can diverge without shared hard-coded classes.
|
||||
*/
|
||||
export const SOLUTIONS_FEATURE_TILE_TONE = {
|
||||
light: {
|
||||
surface: 'bg-[var(--surface-3)]',
|
||||
title: 'text-[var(--text-primary)]',
|
||||
description: 'text-[var(--text-muted)]',
|
||||
},
|
||||
dark: {
|
||||
surface: 'bg-[var(--text-secondary)]',
|
||||
title: 'text-[var(--text-inverse)]',
|
||||
description: 'text-[var(--surface-3)]',
|
||||
/** Softer body copy on dark tiles — `#E6E6E6` via `--surface-6` (`#E5E5E5`). */
|
||||
descriptionSoft: 'text-[var(--surface-6)]',
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,8 @@
|
||||
export { SolutionsPage } from './solutions-page'
|
||||
export type {
|
||||
SolutionsCardConfig,
|
||||
SolutionsCardRowConfig,
|
||||
SolutionsHeroConfig,
|
||||
SolutionsPageConfig,
|
||||
SolutionsPillCta,
|
||||
} from './types'
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import {
|
||||
SolutionsCardRow,
|
||||
SolutionsHero,
|
||||
SolutionsLogosRow,
|
||||
SolutionsStructuredData,
|
||||
} from '@/app/(landing)/components/solutions-page/components'
|
||||
import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsPageConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* The reusable solutions-page content stack - the single component every
|
||||
* solution route (IT, Engineering, Finance, Compliance, HR) consumes with
|
||||
* near-zero ceremony. A route renders the shared shell and drops in one
|
||||
* `<SolutionsPage config={…} />`.
|
||||
*
|
||||
* Structurally this mirrors `PlatformPage` today; the two are deliberately
|
||||
* separate so the solutions layout and its components can diverge from the
|
||||
* platform layout without coupling.
|
||||
*
|
||||
* This component owns the entire `<main>`: the shared `max-w-[1460px]` content
|
||||
* column (centered with `mx-auto`, matching the navbar and landing sections), the
|
||||
* one horizontal gutter (`SOLUTIONS_SPACING.gutter`), and the inter-section
|
||||
* vertical rhythm (`SOLUTIONS_SPACING.sectionRhythm`, the `<main>` flex gap). The
|
||||
* hero, the logos row, and every card row carry no gutter and no inter-section
|
||||
* margin of their own, so spacing is uniform and unreachable from a consumer
|
||||
* page - the config is pure content (strings + `ReactNode` visual slots), with no
|
||||
* layout knob anywhere in its tree.
|
||||
*
|
||||
* The order is fixed: structured data first (before visible content, derived
|
||||
* from the same config so it never drifts) → solutions hero (the page's only
|
||||
* `<h1>`) → centered logos row → the configured card rows in array order. The
|
||||
* heading outline is strict H1 → H2 (per card row) → H3 (per card), never
|
||||
* skipped. Server Component only - no client island lives here; the page
|
||||
* supplies its own islands through the `visual` slots in the config.
|
||||
*/
|
||||
|
||||
interface SolutionsPageProps {
|
||||
/** The complete page content - identity, hero, and ordered card rows. */
|
||||
config: SolutionsPageConfig
|
||||
}
|
||||
|
||||
export function SolutionsPage({ config }: SolutionsPageProps) {
|
||||
return (
|
||||
<>
|
||||
<SolutionsStructuredData config={config} />
|
||||
<main
|
||||
id='main-content'
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-[1460px] flex-col',
|
||||
SOLUTIONS_SPACING.sectionRhythm,
|
||||
SOLUTIONS_SPACING.gutter
|
||||
)}
|
||||
>
|
||||
<SolutionsHero hero={config.hero} />
|
||||
<SolutionsLogosRow />
|
||||
{config.rows.map((row) => (
|
||||
<SolutionsCardRow key={row.id} row={row} />
|
||||
))}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Solutions-page configuration - the entire content contract a route passes to
|
||||
* {@link SolutionsPage}. Every field is content-only: strings for copy, `ReactNode`
|
||||
* exclusively for the designated visual/animation slots, and typed config arrays
|
||||
* for card rows. There is deliberately no `className`, `style`, width, height,
|
||||
* padding, margin, or any other layout knob anywhere in this tree - spacing
|
||||
* lives entirely inside the components (see `SOLUTIONS_SPACING`). A page describes
|
||||
* WHAT to show; the layout decides WHERE and with how much space.
|
||||
*/
|
||||
|
||||
/** A single pill call-to-action - label plus destination, used by card rows. */
|
||||
export interface SolutionsPillCta {
|
||||
/** Visible link text. A trailing arrow is added by the component. */
|
||||
label: string
|
||||
/** Destination href. Internal hrefs render as Next `<Link>`; external as a safe anchor. */
|
||||
href: string
|
||||
}
|
||||
|
||||
/** The solutions hero - optional tag, header copy, the shared CTA, and a full-width visual. */
|
||||
export interface SolutionsHeroConfig {
|
||||
/** Optional mono chip shown above the page's single `<h1>`. */
|
||||
eyebrow?: string
|
||||
/**
|
||||
* The page's single `<h1>`. Per the constitution it should name the module and
|
||||
* "Sim"/"AI workspace" (e.g. "Workflows - the visual builder in Sim, the AI workspace").
|
||||
*/
|
||||
heading: string
|
||||
/** Supporting description beneath the heading, in the body color. */
|
||||
description: string
|
||||
/**
|
||||
* ~50-word sr-only atomic summary for AI citation (GEO). Names "Sim" explicitly
|
||||
* and states what the module is, who it's for, and what it does.
|
||||
*/
|
||||
summary: string
|
||||
/**
|
||||
* The full-width hero visual - a page-supplied client island or static panel.
|
||||
* Renders into a component-owned frame with reserved aspect ratio (CLS = 0) and
|
||||
* is marked `aria-hidden`; it owns nothing about the frame's chrome or spacing.
|
||||
*/
|
||||
visual: ReactNode
|
||||
}
|
||||
|
||||
/** A single card - text plus a reserved visual panel. Rendered as an `<article>`. */
|
||||
export interface SolutionsCardConfig {
|
||||
/** The card's `<h3>` title. */
|
||||
title: string
|
||||
/**
|
||||
* Supporting description beneath the title, in the body color. Self-contained
|
||||
* and names "Sim" - never "the platform" or a bare pronoun - so each card is an
|
||||
* independently quotable answer block.
|
||||
*/
|
||||
description: string
|
||||
/**
|
||||
* The card's visual/animation - a page-supplied node. Renders into a
|
||||
* component-owned, fixed-height frame (CLS = 0), marked `aria-hidden`. The card
|
||||
* owns the spacing around both the text and this frame.
|
||||
*/
|
||||
visual: ReactNode
|
||||
/**
|
||||
* Feature-tile surface tone - only read when the row renders
|
||||
* `cardVariant='featureTile'`. Defaults to `'light'` so tiles can mix light
|
||||
* and dark backgrounds within the same row.
|
||||
*/
|
||||
featureTileTone?: SolutionsFeatureTileTone
|
||||
/**
|
||||
* Optional description color on feature tiles. `'soft'` is for lighter body
|
||||
* copy on dark surfaces without changing the row's default tone map.
|
||||
*/
|
||||
featureTileDescriptionTone?: 'soft'
|
||||
}
|
||||
|
||||
/** Controlled surface tones for {@link SolutionsCardConfig.featureTileTone}. */
|
||||
export type SolutionsFeatureTileTone = 'light' | 'dark'
|
||||
|
||||
/**
|
||||
* A card row - the core repeating unit. A header (title + subtitle + CTA) above a
|
||||
* grid of 3 or 4 cards. The grid column count is derived from `cards.length`, so
|
||||
* the page never specifies layout.
|
||||
*/
|
||||
export interface SolutionsCardRowConfig {
|
||||
/**
|
||||
* Stable section id for the `<section>` landmark and `aria-labelledby` wiring.
|
||||
* Must be unique within the page (e.g. `'build'`).
|
||||
*/
|
||||
id: string
|
||||
/** The row's `<h2>` title, in the headline color - reads as an answer to a user question. */
|
||||
title: string
|
||||
/** Supporting subtitle beneath the title, in the body color, naming "Sim". */
|
||||
subtitle: string
|
||||
/** The row's single pill CTA. */
|
||||
cta: SolutionsPillCta
|
||||
/** The cards in this row - 3 or 4. The grid derives its columns from this length. */
|
||||
cards: SolutionsCardConfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete solutions-page content: page identity (for structured data), one
|
||||
* hero, plus ordered card rows. A route passes exactly this object to
|
||||
* {@link SolutionsPage} and nothing else.
|
||||
*/
|
||||
export interface SolutionsPageConfig {
|
||||
/** Module name, e.g. "Workflows" - used in the breadcrumb and schema.org name. */
|
||||
module: string
|
||||
/** Canonical path, e.g. "/workflows" - used to build the JSON-LD `url`/breadcrumb. */
|
||||
path: string
|
||||
/** The hero (the page's only `<h1>`). */
|
||||
hero: SolutionsHeroConfig
|
||||
/** Card rows rendered in order beneath the logos row. */
|
||||
rows: SolutionsCardRowConfig[]
|
||||
}
|
||||
Reference in New Issue
Block a user