fix(web): move active promotions into a top bar

This commit is contained in:
vyctorbrzezowski
2026-07-16 13:25:15 -03:00
committed by Vyctor H. Brzezowski
parent 3348b0baa4
commit 2ce2ecf358
6 changed files with 210 additions and 352 deletions
+8 -7
View File
@@ -111,9 +111,7 @@ describe("restored UI design contract", () => {
]) {
expect(cssRule(css, selector)).toContain("border-radius: var(--oc-radius-control)");
}
expect(cssRule(css, ".home-v2-promotion-title-icon")).toContain(
"border-radius: var(--oc-radius-inset)",
);
expect(cssRule(css, ".promotion-bar-icon")).toContain("border-radius: var(--oc-radius-inset)");
expect(cssRule(css, ".home-v2-apps-workflow-tile")).toContain(
"border-radius: var(--oc-radius-surface)",
);
@@ -474,12 +472,15 @@ describe("restored UI design contract", () => {
);
});
it("keeps promotion cards within narrow mobile viewports", () => {
it("keeps the promotion bar thin and horizontally bounded", () => {
const rootSource = rootRoute();
const css = styles();
expect(cssRule(css, ".home-v2-promotions")).toContain("padding: 0 48px 56px");
expect(cssRule(css, ".home-v2-promotions-track")).toContain("display: grid");
expect(css).toContain("padding: 0 20px 48px");
expect(rootSource.indexOf("<PromotionsBar />")).toBeLessThan(rootSource.indexOf("<Header />"));
expect(cssRule(css, ".promotion-bar-track")).toContain("min-height: 36px");
expect(cssRule(css, ".promotion-bar-track")).toContain("overflow-x: auto");
expect(cssRule(css, ".promotion-bar-item")).toContain("min-width: min(100%, 520px)");
expect(css).toContain("min-width: 100%");
});
it("keeps typeahead creator avatars round for users and square for orgs", () => {
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HomePromotionsSection } from "./HomePromotionsSection";
import { PromotionsBar } from "./PromotionsBar";
const { fetchMock, publicApiUrlMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
@@ -36,7 +36,7 @@ function promotionsResponse(promotions: Array<typeof promotion>) {
});
}
describe("HomePromotionsSection", () => {
describe("PromotionsBar", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(100_000);
@@ -58,7 +58,7 @@ describe("HomePromotionsSection", () => {
promotionsResponse([{ ...promotion, startsAt: 120_000, endsAt: 200_000 }]),
);
const { container } = render(<HomePromotionsSection />);
const { container } = render(<PromotionsBar />);
await flushPromises();
expect(screen.queryByText(promotion.title)).toBeNull();
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://clawhub.test/api/v1/promotions");
@@ -71,6 +71,7 @@ describe("HomePromotionsSection", () => {
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://clawhub.test/api/v1/promotions");
expect(screen.getByText(promotion.title)).toBeTruthy();
expect(screen.getByText(promotion.blurb)).toBeTruthy();
expect(screen.queryByText("1 day left")).toBeNull();
expect(container.querySelector('img[src="/tencent-hy-favicon.png"]')).toBeNull();
});
@@ -79,7 +80,7 @@ describe("HomePromotionsSection", () => {
.mockResolvedValueOnce(promotionsResponse([{ ...promotion, endsAt: 100_500 }]))
.mockResolvedValueOnce(promotionsResponse([]));
render(<HomePromotionsSection />);
render(<PromotionsBar />);
await flushPromises();
expect(screen.getByText(promotion.title)).toBeTruthy();
expect(screen.getByText(promotion.blurb)).toBeTruthy();
@@ -91,4 +92,23 @@ describe("HomePromotionsSection", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(screen.queryByText(promotion.title)).toBeNull();
});
it("uses the campaign date instead of a changing countdown for Tencent Hy3", async () => {
fetchMock.mockResolvedValueOnce(
promotionsResponse([
{
...promotion,
title: "Tencent Hy3 is free on OpenRouter",
endsAt: Date.UTC(2026, 6, 21),
},
]),
);
const { container } = render(<PromotionsBar />);
await flushPromises();
expect(screen.getByText("Tencent's latest model, free until July 21")).toBeTruthy();
expect(screen.queryByText(/days left/)).toBeNull();
expect(container.querySelector('img[src="/tencent-hy-favicon.png"]')).toBeTruthy();
});
});
@@ -7,7 +7,6 @@ type PublicPromotion = {
slug: string;
title: string;
blurb: string;
sponsor?: string;
endsAt: number;
signupUrl?: string;
docsUrl?: string;
@@ -15,7 +14,6 @@ type PublicPromotion = {
};
const PROMOTIONS_POLL_INTERVAL_MS = 60_000;
const DAY_MS = 24 * 60 * 60 * 1000;
function nextPromotionsRefreshDelay(promotions: PublicPromotion[], now: number) {
return promotions.reduce(
@@ -33,96 +31,59 @@ function formatPromotionDate(endsAt: number) {
}).format(new Date(endsAt));
}
function formatDaysRemaining(endsAt: number) {
const days = Math.max(0, Math.ceil((endsAt - Date.now()) / DAY_MS));
return `${days} ${days === 1 ? "day" : "days"} left`;
}
function isTencentHyPromotion(title: string) {
return /tencent hy3/i.test(title);
}
function promotionDisplayTitle(title: string) {
const match = /^(.*?)\s+is free on\s+(.*?)$/i.exec(title);
if (!match) {
return <span className="home-v2-promotion-title-brand">{title}</span>;
}
return (
<>
<span className="home-v2-promotion-title-brand">{match[1]}</span>
<span className="home-v2-promotion-title-muted"> is free on </span>
<span className="home-v2-promotion-title-muted">{match[2]}</span>
</>
);
}
function promotionCtaUrl(promotion: PublicPromotion) {
return promotion.launchPageUrl ?? promotion.signupUrl ?? promotion.docsUrl ?? null;
}
function promotionMetaCopy(promotion: PublicPromotion) {
if (isTencentHyPromotion(promotion.title)) {
return `Tencent's latest model, free until ${formatPromotionDate(promotion.endsAt)}.`;
return `Tencent's latest model, free until ${formatPromotionDate(promotion.endsAt)}`;
}
return promotion.blurb;
}
function PromotionCard({ promotion }: { promotion: PublicPromotion }) {
function PromotionBarItem({ promotion }: { promotion: PublicPromotion }) {
const ctaUrl = promotionCtaUrl(promotion);
const daysRemainingLabel = formatDaysRemaining(promotion.endsAt);
const isTencentPromotion = isTencentHyPromotion(promotion.title);
return (
<article className="home-v2-promotion-card oc-card">
<div className="home-v2-promotion-content">
<div className="home-v2-promotion-stack">
<h3 className="home-v2-promotion-title">
{isTencentPromotion ? (
<img
src="/tencent-hy-favicon.png"
alt=""
aria-hidden="true"
className="home-v2-promotion-title-icon"
/>
) : (
<Gift
size={20}
aria-hidden="true"
className="home-v2-promotion-title-icon home-v2-promotion-title-icon-fallback"
/>
)}
<span className="home-v2-promotion-title-copy">
{promotionDisplayTitle(promotion.title)}
</span>
</h3>
<p className="home-v2-promotion-meta">{promotionMetaCopy(promotion)}</p>
</div>
</div>
{/* No CLI claim snippet yet: the openclaw `promos claim` command ships
separately; advertise it here once that CLI flow exists. */}
<div className="home-v2-promotion-actions">
<span className="home-v2-promotion-days">
<Gift size={14} aria-hidden="true" />
<span>{daysRemainingLabel}</span>
</span>
{ctaUrl ? (
<a
className="home-v2-promotion-link oc-action oc-action-primary"
href={ctaUrl}
target="_blank"
rel="noopener noreferrer"
>
Try it free <ArrowUpRight size={15} aria-hidden="true" />
</a>
) : null}
<article className="promotion-bar-item">
<div className="promotion-bar-copy">
<h3 className="promotion-bar-title">
{isTencentPromotion ? (
<img
src="/tencent-hy-favicon.png"
alt=""
aria-hidden="true"
className="promotion-bar-icon"
/>
) : (
<Gift
size={14}
aria-hidden="true"
className="promotion-bar-icon promotion-bar-icon-fallback"
/>
)}
<span className="promotion-bar-title-copy">{promotion.title}</span>
</h3>
<span className="promotion-bar-separator" aria-hidden="true" />
<span className="promotion-bar-meta">{promotionMetaCopy(promotion)}</span>
</div>
{ctaUrl ? (
<a className="promotion-bar-link" href={ctaUrl} target="_blank" rel="noopener noreferrer">
Try it free <ArrowUpRight size={15} aria-hidden="true" />
</a>
) : null}
</article>
);
}
export function HomePromotionsSection() {
export function PromotionsBar() {
const [promotions, setPromotions] = useState<PublicPromotion[]>([]);
useEffect(() => {
@@ -152,7 +113,7 @@ export function HomePromotionsSection() {
setPromotions(active);
scheduleRefresh(active);
} catch {
// Promotions are decorative on the homepage; render nothing on failure.
// Promotions are optional header content; render nothing on failure.
if (!cancelled) scheduleRefresh([]);
}
}
@@ -167,13 +128,13 @@ export function HomePromotionsSection() {
if (promotions.length === 0) return null;
return (
<section className="home-v2-promotions oc-section" aria-labelledby="home-promotions-title">
<h2 id="home-promotions-title" className="sr-only">
<section className="promotion-bar" aria-labelledby="active-promotions-title">
<h2 id="active-promotions-title" className="sr-only">
Active promotions
</h2>
<div className="home-v2-promotions-track">
<div className="promotion-bar-track">
{promotions.map((promotion) => (
<PromotionCard key={promotion.slug} promotion={promotion} />
<PromotionBarItem key={promotion.slug} promotion={promotion} />
))}
</div>
</section>
+2
View File
@@ -17,6 +17,7 @@ import { ErrorBoundary } from "../components/ErrorBoundary";
import { Footer } from "../components/Footer";
import { GenericNotFoundPage } from "../components/GenericNotFoundPage";
import Header from "../components/Header";
import { PromotionsBar } from "../components/PromotionsBar";
import {
BANNED_ACCOUNT_PATH,
isBannedAccountAuthError,
@@ -194,6 +195,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<body>
<AppProviders>
<div className="app-shell">
<PromotionsBar />
<Header />
<ClientOnly>
<DeploymentDriftBanner />
-2
View File
@@ -4,7 +4,6 @@ import { HomeAppsSection } from "../components/HomeAppsSection";
import { HomeBringSkillsSection } from "../components/HomeBringSkillsSection";
import { HomeListingSection } from "../components/HomeListingSection";
import { HomePopularPublishersSection } from "../components/HomePopularPublishersSection";
import { HomePromotionsSection } from "../components/HomePromotionsSection";
import { HomeV2FoldBottomFade } from "../components/HomeV2FoldBottomFade";
import { fetchInitialHomeListing, type HomeListingInitialData } from "../lib/homeListingData";
@@ -377,7 +376,6 @@ function SkillsHome() {
<p className="home-v2-sub oc-hero-lede">Discover skills and plugins from top creators</p>
</section>
<HomePromotionsSection />
<HomeListingSection initialListing={initialListing} />
<HomePopularPublishersSection />
<HomeAppsSection />
+142 -266
View File
@@ -25543,309 +25543,185 @@ a.search-empty-action {
0 1px 2px rgba(15, 23, 42, 0.12);
}
/* ═══ HOME PROMOTIONS ═══ */
.home-v2-promotions {
width: 100%;
max-width: 1296px;
margin: 0 auto;
padding: 0 48px 56px;
}
.home-v2-promotions-track {
display: grid;
gap: 12px;
}
.home-v2-promotion-card {
/* ═══ PROMOTION BAR ═══ */
.promotion-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
min-height: 124px;
padding: 28px 30px;
z-index: 11;
isolation: isolate;
width: 100%;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--hv2-text) 7%, transparent);
border-radius: var(--hv2-radius-md);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
background: color-mix(in srgb, var(--hv2-text) 4%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--accent) 26%, var(--line));
background:
radial-gradient(
ellipse 28% 180% at 12% -90%,
color-mix(in srgb, var(--accent) 26%, transparent) 0%,
transparent 72%
),
radial-gradient(
ellipse 34% 220% at 84% 180%,
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
transparent 70%
),
linear-gradient(
90deg,
var(--surface) 0%,
color-mix(in srgb, var(--accent) 9%, var(--surface)) 48%,
color-mix(in srgb, var(--accent) 5%, var(--surface)) 100%
);
}
.home-v2-promotion-card::before,
.home-v2-promotion-card::after {
.promotion-bar::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
.home-v2-promotion-card::before {
background:
radial-gradient(
ellipse 56% 72% at 26% -8%,
rgba(255, 255, 255, 0.14) 0%,
rgba(255, 255, 255, 0.06) 38%,
rgba(255, 255, 255, 0) 72%
),
radial-gradient(
ellipse 82% 120% at 84% 140%,
rgba(204, 48, 24, 0.2) 0%,
rgba(244, 68, 46, 0.1) 44%,
rgba(244, 68, 46, 0) 76%
),
radial-gradient(
ellipse 36% 70% at 98% 126%,
rgba(255, 80, 48, 0.14) 0%,
rgba(244, 68, 46, 0) 70%
);
mix-blend-mode: multiply;
z-index: 0;
.promotion-bar::before {
inset: 0;
z-index: -1;
background: repeating-linear-gradient(
105deg,
transparent 0 24px,
color-mix(in srgb, var(--accent) 12%, transparent) 24px 25px
);
opacity: 0.5;
mask-image: linear-gradient(90deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
}
.home-v2-promotion-card::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80' viewBox='0 0 80 80'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='80' height='80' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
opacity: 0.025;
mix-blend-mode: overlay;
z-index: 0;
}
.home-v2-promotion-content {
display: flex;
align-items: center;
min-width: 0;
.promotion-bar-track {
position: relative;
z-index: 1;
}
.home-v2-promotion-stack {
display: grid;
gap: 10px;
min-width: 0;
}
.home-v2-promotion-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--hv2-text);
font-size: 17px;
font-weight: 600;
letter-spacing: 0;
white-space: nowrap;
}
.home-v2-promotion-title-brand {
color: var(--hv2-text);
}
.home-v2-promotion-title-muted {
color: var(--hv2-text-secondary);
font-weight: 500;
}
.home-v2-promotion-title-icon {
width: 20px;
height: 20px;
border-radius: var(--oc-radius-inset);
flex: 0 0 auto;
}
.home-v2-promotion-title-icon-fallback {
color: var(--hv2-text-secondary);
opacity: 0.72;
}
.home-v2-promotion-title-copy {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.home-v2-promotion-meta {
margin: 0;
padding-left: 28px;
color: var(--hv2-text-secondary);
font-size: 14px;
font-weight: 500;
line-height: 1.5;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-v2-promotion-actions {
display: flex;
align-items: center;
gap: 18px;
flex: 0 0 auto;
position: relative;
z-index: 1;
width: 100%;
max-width: var(--page-max);
min-height: 36px;
margin: 0 auto;
padding: 0 var(--space-5);
overflow-x: auto;
scrollbar-width: none;
scroll-snap-type: x mandatory;
}
.home-v2-promotion-days {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 9ch;
color: var(--hv2-text-secondary);
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
.promotion-bar-track::-webkit-scrollbar {
display: none;
}
.home-v2-promotion-days svg {
color: currentColor;
opacity: 0.72;
}
.home-v2-promotion-link {
display: inline-flex;
.promotion-bar-item {
display: flex;
flex: 1 0 auto;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 34px;
padding: 0 14px;
border: 1px solid rgba(15, 23, 42, 0.14);
border-radius: var(--hv2-radius-sm);
color: var(--hv2-text);
background: transparent;
font-size: 13px;
font-weight: 500;
text-decoration: none;
gap: 18px;
min-width: min(100%, 520px);
padding: 6px 0;
scroll-snap-align: start;
}
.promotion-bar-item + .promotion-bar-item {
border-left: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
}
.promotion-bar-title {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
margin: 0;
color: var(--ink);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.promotion-bar-icon {
width: 14px;
height: 14px;
flex: 0 0 auto;
border-radius: var(--oc-radius-inset);
}
.promotion-bar-icon-fallback {
color: var(--accent);
}
.promotion-bar-title-copy {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition:
background-color 0.15s ease,
color 0.15s ease,
transform 0.15s ease;
}
.home-v2-promotion-link:hover {
background: rgba(15, 23, 42, 0.04);
color: var(--hv2-text);
text-decoration: none;
transform: translateY(-1px);
.promotion-bar-copy {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 14px;
min-width: 0;
font-size: 11px;
white-space: nowrap;
}
[data-theme-resolved="light"] .home-v2-promotion-link {
border-color: var(--border-ui);
color: #171717;
background: var(--surface);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
.promotion-bar-separator {
width: 1px;
height: 14px;
flex: 0 0 auto;
background: color-mix(in srgb, var(--ink) 18%, transparent);
}
[data-theme-resolved="light"] .home-v2-promotion-link:hover {
border-color: var(--border-ui-hover);
background: var(--surface-muted);
color: #171717;
.promotion-bar-meta {
min-width: 0;
overflow: hidden;
color: var(--ink-soft);
text-overflow: ellipsis;
}
[data-theme-resolved="dark"] .home-v2-promotion-card {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
inset 1px 0 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(10px) saturate(1.08);
}
[data-theme-resolved="dark"] .home-v2-promotion-card::before {
background:
radial-gradient(
ellipse 56% 72% at 26% -8%,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.03) 38%,
rgba(255, 255, 255, 0) 72%
),
radial-gradient(
ellipse 90% 140% at 76% 136%,
rgba(220, 38, 64, 0.36) 0%,
rgba(220, 38, 64, 0.2) 42%,
rgba(220, 38, 64, 0) 76%
),
radial-gradient(
ellipse 44% 76% at 98% 126%,
rgba(255, 77, 46, 0.18) 0%,
rgba(255, 77, 46, 0) 70%
);
mix-blend-mode: normal;
}
[data-theme-resolved="dark"] .home-v2-promotion-card::after {
opacity: 0.04;
}
[data-theme-resolved="dark"] .home-v2-promotion-title-brand {
color: rgba(255, 255, 255, 0.94);
}
[data-theme-resolved="dark"] .home-v2-promotion-title-muted {
color: rgba(255, 255, 255, 0.5);
}
[data-theme-resolved="dark"] .home-v2-promotion-meta {
color: rgba(255, 255, 255, 0.52);
}
[data-theme-resolved="dark"] .home-v2-promotion-days {
color: rgba(255, 255, 255, 0.56);
}
[data-theme-resolved="dark"] .home-v2-promotion-link {
border: 0;
color: #111;
background: rgba(255, 255, 255, 0.94);
}
[data-theme-resolved="dark"] .home-v2-promotion-link:hover {
background: white;
color: #000;
.promotion-bar-link {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 24px;
padding: 0 8px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
border-radius: var(--oc-radius-control);
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
@media (max-width: 720px) {
.home-v2-promotions {
padding: 0 20px 48px;
.promotion-bar-link:hover {
border-color: color-mix(in srgb, var(--accent) 42%, transparent);
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent-hover);
text-decoration: none;
}
.promotion-bar-link svg {
width: 12px;
height: 12px;
}
@media (max-width: 760px) {
.promotion-bar-track {
padding: 0;
}
.home-v2-promotion-card {
align-items: stretch;
flex-direction: column;
gap: 22px;
min-height: 0;
padding: 24px;
}
.home-v2-promotion-content {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.home-v2-promotion-title,
.home-v2-promotion-meta {
white-space: normal;
}
.home-v2-promotion-title {
font-size: 18px;
}
.home-v2-promotion-meta {
font-size: 15px;
}
.home-v2-promotion-actions {
.promotion-bar-item {
justify-content: space-between;
gap: 12px;
min-width: 100%;
padding: 7px 12px;
border-left: 0;
}
.home-v2-promotion-link {
min-height: 34px;
padding: 0 14px;
font-size: 13px;
.promotion-bar-copy {
gap: 0;
}
.promotion-bar-separator,
.promotion-bar-meta {
display: none;
}
}