mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
feat(site): theming-morph showcase hero on /products/webshops
The 'render once, serve cheap pixels' showcase, live. A 7s reel of the same platform themed as five real demo brands (artisan -> merch -> crypto -> yoga -> coaching), inside a decorative browser frame, at the top of the webshops page. - ShowcaseVideo: poster-first, preload='none' (no download until played), plays only while in the viewport (IntersectionObserver), pauses offscreen, and prefers-reduced-motion -> poster only (video never loads). muted + playsInline for mobile autoplay-on-view. - BrowserFrame: static decorative chrome so the reel reads as a real shop. - 16:9 assets ~330-355KB (webm/mp4) + 50KB poster, all within the motion budget.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,8 @@
|
|||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
import { DemosGallery } from "@/components/products/demos-gallery";
|
import { DemosGallery } from "@/components/products/demos-gallery";
|
||||||
|
import { ShowcaseVideo } from "@/components/showcase/showcase-video";
|
||||||
|
import { BrowserFrame } from "@/components/showcase/browser-frame";
|
||||||
import { Card } from "@repo/ui/components/card";
|
import { Card } from "@repo/ui/components/card";
|
||||||
import { Button } from "@repo/ui/components/button";
|
import { Button } from "@repo/ui/components/button";
|
||||||
import { JsonLd, breadcrumbLd, faqLd } from "@/components/seo/json-ld";
|
import { JsonLd, breadcrumbLd, faqLd } from "@/components/seo/json-ld";
|
||||||
@@ -110,6 +112,22 @@ export default function WebshopsPage() {
|
|||||||
maintain it on revenue share, so we only win when you make sales.
|
maintain it on revenue share, so we only win when you make sales.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Theming morph: the same platform themed as five real demo brands. */}
|
||||||
|
<figure className="mb-16">
|
||||||
|
<BrowserFrame url="your-brand.com">
|
||||||
|
<ShowcaseVideo
|
||||||
|
webm="/showcase/theming-morph.webm"
|
||||||
|
mp4="/showcase/theming-morph.mp4"
|
||||||
|
poster="/showcase/theming-morph-poster.jpg"
|
||||||
|
label="The same commerce platform themed as five distinct brands, dissolving one into the next: an artisan shop, a merch store, a crypto shop, a yoga studio, and a coaching site."
|
||||||
|
className="block aspect-video w-full"
|
||||||
|
/>
|
||||||
|
</BrowserFrame>
|
||||||
|
<figcaption className="mt-3 text-center text-sm text-muted-foreground">
|
||||||
|
One platform, five real storefronts. Every shop here runs the same engine.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
{/* Theming spotlight, the headline story */}
|
{/* Theming spotlight, the headline story */}
|
||||||
<section aria-labelledby="theming-heading" className="section-container border-l-4 border-emerald-500 mb-16">
|
<section aria-labelledby="theming-heading" className="section-container border-l-4 border-emerald-500 mb-16">
|
||||||
<h2 id="theming-heading" className="text-2xl md:text-3xl font-bold mb-4">
|
<h2 id="theming-heading" className="text-2xl md:text-3xl font-bold mb-4">
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lightweight, purely-decorative browser chrome (title bar + traffic lights +
|
||||||
|
* a URL pill) to wrap a storefront screenshot or the theming-morph clip, so it
|
||||||
|
* reads as "a real shop in a real browser." Static CSS, no JS.
|
||||||
|
*/
|
||||||
|
export function BrowserFrame({
|
||||||
|
url = 'your-brand.com',
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
url?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden rounded-xl border border-white/10 bg-slate-900/60 shadow-2xl ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 border-b border-white/10 bg-slate-800/70 px-4 py-2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="flex gap-1.5">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-red-400/70" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-amber-400/70" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-emerald-400/70" />
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 flex-1 truncate rounded-md bg-slate-950/50 px-3 py-1 text-center text-xs text-slate-400">
|
||||||
|
{url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type ShowcaseVideoProps = {
|
||||||
|
webm: string;
|
||||||
|
mp4: string;
|
||||||
|
poster: string;
|
||||||
|
/** Accessible description of what the clip shows. */
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perf-safe showcase video. The studio's motion budget, enforced in one place:
|
||||||
|
* - The poster paints instantly; the video is `preload="none"`, so it
|
||||||
|
* downloads NOTHING until it is actually played.
|
||||||
|
* - It only plays while in the viewport (IntersectionObserver) and pauses
|
||||||
|
* when scrolled away or the tab is hidden. No offscreen decoders.
|
||||||
|
* - `prefers-reduced-motion`: the video is never played, so with
|
||||||
|
* `preload="none"` it is never even downloaded. The poster is the whole
|
||||||
|
* experience for those visitors.
|
||||||
|
* - `muted` + `playsInline` so mobile autoplay-on-view is permitted (one
|
||||||
|
* video at a time; this component is for a single hero/section clip, not a
|
||||||
|
* grid — a grid would gate to one visible at a time).
|
||||||
|
*/
|
||||||
|
export function ShowcaseVideo({ webm, mp4, poster, label, className }: ShowcaseVideoProps) {
|
||||||
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
|
const [allowMotion, setAllowMotion] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAllowMotion(!window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const v = ref.current;
|
||||||
|
if (!v || !allowMotion || typeof IntersectionObserver === 'undefined') return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// .play() on a preload="none" element triggers the deferred load,
|
||||||
|
// then plays once buffered. Ignore the rejection browsers throw when
|
||||||
|
// a play() is interrupted by a quick pause() on fast scroll.
|
||||||
|
v.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
v.pause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.25 },
|
||||||
|
);
|
||||||
|
io.observe(v);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [allowMotion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
poster={poster}
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<source src={webm} type="video/webm" />
|
||||||
|
<source src={mp4} type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user