mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add landing testimonial wall, unblock the onboarding survey (#639)
Testimonial wall: new landing section between Feature Highlights and Pricing. Two rows drifting in opposite directions, CSS-only to keep the zero-runtime rule. 22 quotes, all verbatim and traceable to a public URL or a feedback_submitted event. In-app quotes ship unattributed because the feedback dialog only ever promised "You can contact me about this feedback". Marquee traps documented in the CSS: a track gap also sits between the last original and the first clone, so the -50% translate jumped half a gap per loop; and under dir="rtl" the flex track drifted itself off-screen while "@amn-96" bidi-reordered to "amn-96@". Landing stats: DOCKER_FALLBACK read 104,000 against a real 233,057, but the stale constant was the symptom. Both fetchers swallowed failures in a bare catch, so a degraded build never announced itself. That warning then exposed the real bug: getStarCount runs from Navbar and TrustSignals on all 798 pages, firing ~800 unauthenticated GitHub calls per build and 403ing partway through, so early pages carried the live count and later pages the fallback. Both fetchers now memoize the promise. Onboarding survey: the shipped gate has no activity condition, so it fires on first admin login; 1,105 of 1,287 surveyed instances never processed a file. The opaque fixed inset-0 aria-modal with a focus trap and no Escape becomes a corner card at 12% of the screen, Escape closes, and the optional questions stay collapsed until the one required answer. Its title was an h1, which RouteAnnouncer focuses and announces on every route change, so navigating anywhere announced the survey instead of the page. Now an h2.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
---
|
||||
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
import { TESTIMONIAL_ROWS } from "@/data/testimonials";
|
||||
import { t } from "@/i18n";
|
||||
import SectionHeading from "./SectionHeading.astro";
|
||||
|
||||
interface Props {
|
||||
locale?: string;
|
||||
}
|
||||
const { locale = "en" } = Astro.props;
|
||||
|
||||
// Each row is rendered twice so translating the track by -50% lands exactly on
|
||||
// the start of the copy, which is what makes the loop seamless. The second pass
|
||||
// is aria-hidden so screen readers and crawlers see each quote once.
|
||||
const rows = TESTIMONIAL_ROWS.map((items, i) => ({
|
||||
items,
|
||||
reverse: i % 2 === 1,
|
||||
// Duration scales with item count so both rows move at the same visual speed.
|
||||
duration: `${items.length * 9}s`,
|
||||
}));
|
||||
---
|
||||
|
||||
<section class="overflow-hidden bg-primary-subtle px-6 py-20 md:py-28" id="testimonials">
|
||||
<SectionHeading
|
||||
title={t(locale, "home.testimonials.title")}
|
||||
subtitle={t(locale, "home.testimonials.subtitle")}
|
||||
/>
|
||||
|
||||
<!--
|
||||
The strip is pinned to LTR in every locale. Quotes are user-submitted and
|
||||
never translated, so they are always Latin script; under dir="rtl" the flex
|
||||
track laid out from the right and drifted itself off-screen, and handles like
|
||||
"@amn-96" bidi-reordered into "amn-96@". The heading above stays RTL.
|
||||
-->
|
||||
<div class="reveal marquee -mx-6 mt-4 flex flex-col gap-5" dir="ltr">
|
||||
{
|
||||
rows.map((row) => (
|
||||
<div class="marquee-viewport">
|
||||
<ul
|
||||
class:list={["marquee-track", row.reverse && "marquee-track-reverse"]}
|
||||
style={`--marquee-duration: ${row.duration}`}
|
||||
>
|
||||
{[false, true].map((isClone) =>
|
||||
row.items.map((item) => (
|
||||
<li class="marquee-item" aria-hidden={isClone ? "true" : undefined}>
|
||||
<figure class="flex h-full flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-[0_1px_2px_rgba(26,24,20,0.04)]">
|
||||
<div class="flex gap-0.5 text-primary" aria-hidden="true">
|
||||
{Array.from({ length: 5 }).map(() => (
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 1.5l2.47 5.16 5.68.78-4.12 3.95 1.02 5.61L10 14.35l-5.05 2.65 1.02-5.61L1.85 7.44l5.68-.78L10 1.5z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<blockquote
|
||||
class="grow text-sm leading-relaxed text-foreground"
|
||||
lang={item.lang}
|
||||
>
|
||||
{item.quote}
|
||||
</blockquote>
|
||||
|
||||
<figcaption class="flex items-baseline justify-between gap-3 text-xs">
|
||||
<span class="font-semibold text-foreground">{item.author}</span>
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
class="shrink-0 font-medium text-primary-ink underline-offset-2 hover:text-primary-ink-strong hover:underline"
|
||||
tabindex={isClone ? -1 : undefined}
|
||||
>
|
||||
{item.context}
|
||||
</a>
|
||||
) : (
|
||||
<span class="shrink-0 text-muted">{item.context}</span>
|
||||
)}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
Reference in New Issue
Block a user