Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'page.tsx'),
'utf8',
);

test('app store detail page uses the redesigned dark shell and home footer', () => {
assert.match(source, /import \{ Footer \} from '@\/new-components\/Footer'/);
assert.match(source, /BottomLightImage/);
assert.match(source, /appStoreDetailBackgroundVars/);
assert.match(source, /data-theme="app-store"/);
assert.match(source, /className="[^"]*isolate[^"]*"/);
assert.doesNotMatch(source, /import Footer from '@\/components\/footer'/);
assert.doesNotMatch(source, /@\/components\/header\/hero/);
});

test('app store detail page renders redesigned Figma sections', () => {
assert.match(source, /AppDetailHero/);
assert.match(source, /WhyDeployOnSealos/);
assert.match(source, /WholeStackSection/);
assert.match(source, /ReadmePreview/);
assert.match(source, /RelatedTemplates/);
});
228 changes: 228 additions & 0 deletions app/[lang]/products/app-store/[slug]/components/AppDetailHero.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import Link from 'next/link';
import {
ArrowLeft,
ArrowRight,
BookOpen,
CircleCheck,
ExternalLink,
Github,
} from 'lucide-react';
import { GodRays } from '@/new-components/GodRays';
import { GradientLucideIcon } from '@/new-components/GradientLucideIcon';
import { AppIcon } from '@/components/ui/app-icon';
import { languagesType } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { DeployButton } from './DeployButton';
import AppPreviewPanel from './AppPreviewPanel';
import {
getDisplayDescription,
getReadmeUrl,
type AppDetailConfig,
} from './app-detail-utils';

interface AppDetailHeroProps {
app: AppDetailConfig;
lang: languagesType;
}

const proofPoints = [
'Easy to deploy and manage',
'Self-hosted solution',
'Open source and free',
'Community supported',
];

const heroTitleAccentClassName =
'bg-gradient-to-r from-white to-[#146DFF] bg-clip-text text-transparent';

const heroCenterLogoClassName =
'hidden lg:flex absolute left-[609px] top-9 z-20 h-[130px] w-[130px] -translate-x-1/2 items-center justify-center rounded-[10px] border-[7px] border-[#25272c] bg-[#d8d8d8] p-5 shadow-2xl shadow-black/40';

function textLinkClassName(variant: 'primary' | 'default' = 'default') {
return cn(
'inline-flex items-center gap-1.5 text-xs transition',
variant === 'primary'
? 'text-white hover:text-[#69a3ff]'
: 'text-zinc-400 hover:text-white',
);
}

export default function AppDetailHero({ app, lang }: AppDetailHeroProps) {
const readmeUrl = getReadmeUrl(app.github);

return (
<section className="relative overflow-hidden pt-36 pb-20 md:pt-44 md:pb-28 lg:min-h-[820px] lg:pt-[132px]">
<div
className="pointer-events-none absolute inset-0 z-0 bg-background"
aria-hidden="true"
/>
<div className="pointer-events-none absolute inset-0 z-[3]">
<GodRays
sources={[
{
x: -0.05,
y: -0.05,
angle: 60,
spread: 20,
count: 12,
color: '220, 220, 220',
opacityMin: 0.24,
opacityMax: 0.25,
minWidth: 120,
maxWidth: 180,
},
{
x: -0.05,
y: -0.05,
angle: 60,
spread: 8,
count: 6,
color: '255, 255, 255',
opacityMin: 0.89,
opacityMax: 0.9,
minWidth: 12,
maxWidth: 24,
},
{
x: 0.25,
y: -0.06,
angle: 50,
spread: 20,
count: 6,
color: '180, 180, 180',
opacityMin: 0.14,
opacityMax: 0.15,
minWidth: 60,
maxWidth: 120,
},
]}
speed={0}
maxWidth={48}
minLength={1200}
maxLength={2000}
blur={8}
/>
</div>

<div className="relative z-10 mx-auto grid max-w-7xl items-start gap-12 px-6 lg:grid-cols-[minmax(0,560px)_minmax(0,1fr)] lg:px-8">
<div className={heroCenterLogoClassName}>
<AppIcon
src={app.icon}
alt={`${app.name} icon`}
width={92}
height={92}
className="h-[92px] w-[92px] rounded-md object-contain"
fallbackClassName="h-16 w-16 text-zinc-700"
/>
</div>

<div className="relative z-20 min-w-0 lg:pt-2">
<Link
href={`/${lang}/products/app-store`}
className="mb-12 inline-flex items-center gap-2 text-xs text-zinc-500 transition hover:text-zinc-200 lg:mb-[92px]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to templates

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Localize detail hero labels by active language

The detail hero uses hardcoded English UI strings (for example the back link and CTA text) even though this route is language-scoped (/[lang]/...). As a result, localized pages like zh-cn now show English labels on the primary detail surface, which regresses the previous language-aware behavior and breaks locale consistency for production users.

Useful? React with 👍 / 👎.

</Link>

<div className="mb-7 flex items-center gap-5 lg:hidden">
<div className="flex h-[84px] w-[84px] items-center justify-center rounded-xl border border-white/15 bg-zinc-100 p-3 shadow-2xl shadow-black/40">
<AppIcon
src={app.icon}
alt={`${app.name} icon`}
width={64}
height={64}
className="h-16 w-16 rounded-lg object-contain"
fallbackClassName="h-12 w-12 text-zinc-700"
/>
</div>
</div>

<h1 className="max-w-[720px] text-[34px] leading-[1.12] font-semibold text-white sm:text-[36px] lg:max-w-[500px] lg:text-[36px] lg:leading-[1.18]">
Deploy{' '}
<span className={heroTitleAccentClassName}>
{app.name} on Sealos
</span>
</h1>
<p className="mt-6 max-w-[600px] text-sm leading-6 text-zinc-400">
{getDisplayDescription(app)}
</p>

<div className="mt-6 flex flex-wrap items-center gap-2">
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-white/[0.055] px-2 text-xs text-zinc-400">
<span className="h-1.5 w-1.5 rounded-full bg-[#6ea2ff]" />
{app.category}
</span>
</div>

<div className="mt-5 flex flex-wrap gap-x-5 gap-y-3 text-xs text-zinc-400">
{(app.benefits.length ? app.benefits : proofPoints)
.slice(0, 4)
.map((item) => (
<span key={item} className="inline-flex items-center gap-2">
<GradientLucideIcon
Icon={CircleCheck}
className="h-3.5 w-3.5 shrink-0"
/>
{item}
</span>
))}
</div>

<div className="mt-10">
<DeployButton
templateName={app.slug}
appName={app.name}
category={app.category}
className="h-10 gap-2 px-6 text-sm"
>
Deploy Now
<ArrowRight className="h-4 w-4" />
</DeployButton>
</div>

<div className="mt-9 flex flex-wrap items-center gap-5">
{app.website && (
<a
href={app.website}
target="_blank"
rel="noopener noreferrer"
className={textLinkClassName('primary')}
>
<ExternalLink className="h-3.5 w-3.5" />
Official Website
</a>
)}
{app.github && (
<a
href={app.github}
target="_blank"
rel="noopener noreferrer"
className={textLinkClassName()}
>
<Github className="h-3.5 w-3.5" />
GitHub
</a>
)}
<a
href={readmeUrl}
target={app.github ? '_blank' : undefined}
rel={app.github ? 'noopener noreferrer' : undefined}
className={textLinkClassName()}
>
<BookOpen className="h-3.5 w-3.5" />
README
</a>
</div>
</div>

<div className="relative z-20 hidden min-w-0 lg:block lg:pt-[150px]">
<div className="absolute -inset-10 bg-[radial-gradient(circle_at_45%_40%,rgba(20,109,255,0.18),transparent_54%)]" />
<div className="relative translate-x-14">
<AppPreviewPanel app={app} variant="hero" />
</div>
</div>
</div>
</section>
);
}
133 changes: 133 additions & 0 deletions app/[lang]/products/app-store/[slug]/components/AppPreviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import Image from 'next/image';
import { Globe } from 'lucide-react';
import { AppIcon } from '@/components/ui/app-icon';
import type { AppDetailConfig } from './app-detail-utils';

interface AppPreviewPanelProps {
app: AppDetailConfig;
compact?: boolean;
displayUrl?: string;
showChrome?: boolean;
variant?: 'default' | 'hero' | 'readme';
}

export default function AppPreviewPanel({
app,
compact = false,
displayUrl,
showChrome = true,
variant = 'default',
}: AppPreviewPanelProps) {
const primaryScreenshot = app.screenshots?.[0];
const readmePanelClassName =
'relative aspect-[1253/671] min-h-[520px] overflow-hidden bg-[#d8d8d8] p-8 text-zinc-950 sm:p-10 lg:p-12';
const heroPanelClassName =
'relative aspect-[586/390] min-h-[300px] overflow-hidden bg-[#d8d8d8] p-6 text-zinc-950 sm:min-h-[340px] lg:p-8';
const defaultPanelClassName =
'relative min-h-[260px] overflow-hidden bg-[#d8d8d8] p-8 text-zinc-950 sm:min-h-[330px]';
const contentMinHeightClassName =
variant === 'readme'
? 'min-h-[456px] sm:min-h-[500px] lg:min-h-[575px]'
: variant === 'hero'
? 'min-h-[252px] sm:min-h-[292px]'
: 'min-h-[220px] sm:min-h-[280px]';
const heroTitleClassName =
'max-w-[360px] text-[46px] leading-[0.98] font-semibold tracking-normal sm:text-[58px] lg:text-[66px]';
const defaultTitleClassName =
'text-[52px] leading-none font-semibold tracking-normal sm:text-[76px] lg:text-[96px]';
const titleClassName =
variant === 'hero' ? heroTitleClassName : defaultTitleClassName;
const heroScreenshotImageClassName =
'object-cover -translate-y-[5%] scale-[1.28]';

return (
<div className="relative overflow-hidden rounded-xl border border-white/10 bg-[#101218] shadow-2xl shadow-black/40">
{showChrome && (
<div className="flex h-8 items-center justify-between border-b border-white/10 bg-white/[0.035] px-3">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-[#ff5f57]" />
<span className="h-2 w-2 rounded-full bg-[#ffbd2e]" />
<span className="h-2 w-2 rounded-full bg-[#28c840]" />
</div>
<div className="inline-flex items-center gap-1.5 rounded-md bg-white/[0.04] px-8 py-1 text-[10px] text-zinc-300">
<Globe className="h-2.5 w-2.5 text-zinc-400" />
sealos.io
</div>
<span className="w-[34px]" aria-hidden="true" />
</div>
)}

{variant === 'hero' && primaryScreenshot ? (
<div className="relative aspect-[586/390] min-h-[300px] overflow-hidden bg-[#d8d8d8] sm:min-h-[340px]">
<Image
src={primaryScreenshot}
alt={`${app.name} screenshot`}
fill
className={heroScreenshotImageClassName}
sizes="(max-width: 1024px) 100vw, 586px"
priority
/>
</div>
) : (
<div
className={
variant === 'readme'
? readmePanelClassName
: variant === 'hero'
? heroPanelClassName
: defaultPanelClassName
}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_78%_28%,rgba(20,109,255,0.16),transparent_34%)]" />
<div
className={`relative flex h-full flex-col justify-between ${contentMinHeightClassName}`}
>
<div className="flex items-start justify-between gap-6">
<div>
<div className={titleClassName}>{app.name}</div>
<p className="mt-5 max-w-[410px] text-base leading-6 text-zinc-800 lg:text-lg lg:leading-7">
Build production-ready {app.category.toLowerCase()} templates
with Sealos.
</p>
</div>
<div className="hidden shrink-0 rounded-2xl border border-black/10 bg-white/75 p-3 shadow-xl sm:block">
<AppIcon
src={app.icon}
alt={`${app.name} icon`}
width={72}
height={72}
className="h-16 w-16 rounded-xl object-contain"
fallbackClassName="h-12 w-12 text-zinc-700"
/>
</div>
</div>

{!compact && (
<div className="grid gap-3 sm:grid-cols-3">
{(app.features.length ? app.features : app.benefits)
.slice(0, 3)
.map((item) => (
<div
key={item}
className="rounded-lg border border-black/10 bg-white/55 p-3 text-xs leading-5 text-zinc-700"
>
{item}
</div>
))}
</div>
)}

<div className="flex items-end justify-between gap-4">
<span className="truncate text-base text-zinc-900">
{displayUrl || app.website || app.github || `/${app.slug}`}
</span>
<span className="text-4xl leading-none font-semibold">
deploy <span className="text-[#073edc]">now</span>
</span>
</div>
</div>
</div>
)}
</div>
);
}
Loading