From 2ce2ecf358aa806ba16b2e1ccc2656124bf93b43 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Thu, 16 Jul 2026 11:45:22 -0300 Subject: [PATCH] fix(web): move active promotions into a top bar --- src/__tests__/ui-design-contract.test.ts | 15 +- ...ection.test.tsx => PromotionsBar.test.tsx} | 28 +- ...romotionsSection.tsx => PromotionsBar.tsx} | 107 ++--- src/routes/__root.tsx | 2 + src/routes/index.tsx | 2 - src/styles.css | 408 ++++++------------ 6 files changed, 210 insertions(+), 352 deletions(-) rename src/components/{HomePromotionsSection.test.tsx => PromotionsBar.test.tsx} (76%) rename src/components/{HomePromotionsSection.tsx => PromotionsBar.tsx} (51%) diff --git a/src/__tests__/ui-design-contract.test.ts b/src/__tests__/ui-design-contract.test.ts index 5004a468..7a84cd22 100644 --- a/src/__tests__/ui-design-contract.test.ts +++ b/src/__tests__/ui-design-contract.test.ts @@ -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("")).toBeLessThan(rootSource.indexOf("
")); + 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", () => { diff --git a/src/components/HomePromotionsSection.test.tsx b/src/components/PromotionsBar.test.tsx similarity index 76% rename from src/components/HomePromotionsSection.test.tsx rename to src/components/PromotionsBar.test.tsx index c2f3e7b7..2764717a 100644 --- a/src/components/HomePromotionsSection.test.tsx +++ b/src/components/PromotionsBar.test.tsx @@ -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) { }); } -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(); + const { container } = render(); 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(); + render(); 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(); + 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(); + }); }); diff --git a/src/components/HomePromotionsSection.tsx b/src/components/PromotionsBar.tsx similarity index 51% rename from src/components/HomePromotionsSection.tsx rename to src/components/PromotionsBar.tsx index ccf0d2a8..3cf6209b 100644 --- a/src/components/HomePromotionsSection.tsx +++ b/src/components/PromotionsBar.tsx @@ -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 {title}; - } - - return ( - <> - {match[1]} - is free on - {match[2]} - - ); -} - 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 ( -
-
-
-

- {isTencentPromotion ? ( - - ) : ( -

-

{promotionMetaCopy(promotion)}

-
-
- {/* No CLI claim snippet yet: the openclaw `promos claim` command ships - separately; advertise it here once that CLI flow exists. */} -
- - - {ctaUrl ? ( - - Try it free - ) : null} +
+
+

+ {isTencentPromotion ? ( + + ) : ( +

+
+ {ctaUrl ? ( + + Try it free + ) : null}
); } -export function HomePromotionsSection() { +export function PromotionsBar() { const [promotions, setPromotions] = useState([]); 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 ( -
-

+
+

Active promotions

-
+
{promotions.map((promotion) => ( - + ))}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index a6a80cf2..0c994fe7 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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 }) {
+
diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 72236044..3bfe66d5 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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() {

Discover skills and plugins from top creators

- diff --git a/src/styles.css b/src/styles.css index 327f6f85..e108eef0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } }