Add data consent prompt, shorten privacy copy, and fix ticket text colors.
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run

Replace hero pan animations with gentle opacity breathing, add bottom-right consent modal with accept/decline, condense privacy policy with contact for full details, and ensure ticket card descriptions read green on white cards in the tickets section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
“kirukib” 2026-05-21 15:38:23 +03:00
parent efa48f6f6b
commit 2b419883eb
8 changed files with 256 additions and 120 deletions

View File

@ -395,133 +395,119 @@
filter: sepia(0.35) saturate(2.6) hue-rotate(92deg) brightness(0.92) contrast(1.2); filter: sepia(0.35) saturate(2.6) hue-rotate(92deg) brightness(0.92) contrast(1.2);
} }
/* Hero — water flowing through contour channels (vertical, not side shake) */ /* Hero — gentle fill in/out on contour lines (no pan, shake, or slide) */
.rift-hero-topo .topo-hero-pattern-img { .rift-hero-topo .topo-hero-pattern-img {
opacity: 0.48; opacity: 0.42;
mix-blend-mode: multiply; mix-blend-mode: multiply;
filter: sepia(0.35) saturate(2.6) hue-rotate(92deg) brightness(0.92) contrast(1.2); filter: sepia(0.35) saturate(2.6) hue-rotate(92deg) brightness(0.92) contrast(1.2);
transform: scale(1.08); transform: scale(1.06);
animation: topo-hero-base-flow 20s linear infinite; object-position: 50% 50%;
animation: topo-hero-fill-breathe 9s ease-in-out infinite;
} }
.rift-hero-topo .topo-hero-water-flow-img { .rift-hero-topo .topo-hero-water-flow-img {
opacity: 0.34; opacity: 0.2;
mix-blend-mode: multiply; mix-blend-mode: multiply;
filter: sepia(0.35) saturate(3) hue-rotate(92deg) brightness(1.12) contrast(1.18); filter: sepia(0.35) saturate(2.8) hue-rotate(92deg) brightness(1.05) contrast(1.15);
transform: scale(1.12); transform: scale(1.06);
animation: topo-hero-water-stream 11s linear infinite; object-position: 50% 50%;
animation: topo-hero-fill-pulse 9s ease-in-out infinite;
animation-delay: -4.5s;
} }
.topo-hero-water-shimmer { .topo-hero-water-shimmer {
background: linear-gradient( background: radial-gradient(
to top, ellipse 80% 60% at 50% 55%,
transparent 0%, rgba(45, 122, 82, 0.14) 0%,
rgba(26, 92, 56, 0.05) 12%, rgba(26, 92, 56, 0.06) 45%,
rgba(45, 122, 82, 0.18) 28%, transparent 72%
rgba(140, 220, 175, 0.38) 42%,
rgba(80, 160, 120, 0.32) 52%,
rgba(45, 122, 82, 0.22) 62%,
rgba(26, 92, 56, 0.08) 78%,
transparent 100%
); );
background-size: 120% 320%;
mix-blend-mode: soft-light; mix-blend-mode: soft-light;
opacity: 0.65; opacity: 0.5;
animation: topo-hero-shimmer-rise 8s ease-in-out infinite; animation: topo-hero-shimmer-breathe 11s ease-in-out infinite;
} }
.topo-hero-channel-glow { .topo-hero-channel-glow {
background: radial-gradient( background: radial-gradient(
ellipse 28% 18% at 50% var(--flow-y, 60%), ellipse 70% 50% at 50% 58%,
rgba(120, 200, 160, 0.45) 0%, rgba(120, 200, 160, 0.2) 0%,
rgba(45, 122, 82, 0.2) 35%, rgba(45, 122, 82, 0.08) 50%,
transparent 70% transparent 75%
); );
mix-blend-mode: soft-light; mix-blend-mode: soft-light;
opacity: 0.7; opacity: 0.55;
animation: topo-hero-channel-glow 8s ease-in-out infinite; animation: topo-hero-glow-breathe 13s ease-in-out infinite;
} }
.topo-hero-water-spotlight { .topo-hero-water-spotlight {
background: radial-gradient( background: radial-gradient(
circle at var(--topo-hover-x, 50%) var(--topo-hover-y, 50%), circle at var(--topo-hover-x, 50%) var(--topo-hover-y, 50%),
rgba(120, 200, 160, 0.28) 0%, rgba(120, 200, 160, 0.16) 0%,
rgba(45, 122, 82, 0.12) 22%, rgba(45, 122, 82, 0.06) 28%,
transparent 52% transparent 48%
); );
mix-blend-mode: soft-light; mix-blend-mode: soft-light;
opacity: 0; opacity: 0;
transition: opacity 0.45s ease; transition: opacity 0.6s ease;
} }
.rift-hero-topo--hover .topo-hero-water-flow-img { .rift-hero-topo--hover .topo-hero-water-flow-img {
opacity: 0.52; opacity: 0.3;
animation-duration: 5.5s;
} }
.rift-hero-topo--hover .topo-hero-water-shimmer { .rift-hero-topo--hover .topo-hero-water-shimmer {
opacity: 1; opacity: 0.62;
animation-duration: 4s;
} }
.rift-hero-topo--hover .topo-hero-channel-glow { .rift-hero-topo--hover .topo-hero-channel-glow {
opacity: 1; opacity: 0.68;
animation-duration: 4.5s;
} }
.rift-hero-topo--hover .topo-hero-water-spotlight { .rift-hero-topo--hover .topo-hero-water-spotlight {
opacity: 1; opacity: 0.85;
} }
.rift-hero-topo--hover .topo-hero-pattern-img { .rift-hero-topo--hover .topo-hero-pattern-img {
opacity: 0.56; opacity: 0.5;
animation-duration: 12s;
} }
@keyframes topo-hero-base-flow { @keyframes topo-hero-fill-breathe {
0% { 0%,
object-position: 50% 100%;
}
100% { 100% {
object-position: 50% 0%; opacity: 0.36;
}
}
@keyframes topo-hero-water-stream {
0% {
object-position: 46% 100%;
opacity: 0.22;
}
40% {
opacity: 0.42;
}
100% {
object-position: 54% 0%;
opacity: 0.26;
}
}
@keyframes topo-hero-shimmer-rise {
0% {
background-position: 40% 110%;
}
100% {
background-position: 60% -15%;
}
}
@keyframes topo-hero-channel-glow {
0% {
--flow-y: 95%;
opacity: 0.35;
} }
50% { 50% {
--flow-y: 35%; opacity: 0.5;
opacity: 0.85;
} }
}
@keyframes topo-hero-fill-pulse {
0%,
100% { 100% {
--flow-y: -5%; opacity: 0.14;
opacity: 0.4; }
50% {
opacity: 0.28;
}
}
@keyframes topo-hero-shimmer-breathe {
0%,
100% {
opacity: 0.38;
}
50% {
opacity: 0.58;
}
}
@keyframes topo-hero-glow-breathe {
0%,
100% {
opacity: 0.42;
}
50% {
opacity: 0.62;
} }
} }
@ -691,6 +677,17 @@
color: #3d5248; color: #3d5248;
} }
.section-green .ticket-admission,
.section-green .ticket-admission :is(h1, h2, h3, h4, p, span, li, label) {
color: #1a5c38;
text-shadow: none;
}
.section-green .ticket-admission .text-muted-foreground,
.section-green .ticket-admission p {
color: #3d5248;
}
.topo-card-link, .topo-card-link,
.topo-card-link svg { .topo-card-link svg {
color: #1a5c38; color: #1a5c38;
@ -1056,16 +1053,16 @@
.rift-hero-settled .rift-channel-inner, .rift-hero-settled .rift-channel-inner,
.rift-ambient-pulse .rift-contour-path, .rift-ambient-pulse .rift-contour-path,
.rift-pulse-animate, .rift-pulse-animate,
.rift-hero-topo .topo-hero-pattern-img, .rift-hero-topo .topo-hero-pattern-img {
.rift-hero-topo .topo-hero-water-flow-img,
.rift-hero-topo .topo-hero-water-shimmer,
.rift-hero-topo .topo-hero-channel-glow {
animation: none !important; animation: none !important;
opacity: 0.44 !important;
} }
.rift-hero-topo .topo-hero-water-flow-img,
.rift-hero-topo .topo-hero-water-shimmer, .rift-hero-topo .topo-hero-water-shimmer,
.rift-hero-topo .topo-hero-water-spotlight, .rift-hero-topo .topo-hero-channel-glow,
.rift-hero-topo .topo-hero-channel-glow { .rift-hero-topo .topo-hero-water-spotlight {
animation: none !important;
opacity: 0 !important; opacity: 0 !important;
} }
} }

View File

@ -2,6 +2,7 @@ import { Syne, DM_Sans, Playfair_Display } from "next/font/google";
import { RiftPageFlow } from "@/components/brand/RiftPageFlow"; import { RiftPageFlow } from "@/components/brand/RiftPageFlow";
import { JsonLd } from "@/components/seo/JsonLd"; import { JsonLd } from "@/components/seo/JsonLd";
import { SiteHeader } from "@/components/layout/SiteHeader"; import { SiteHeader } from "@/components/layout/SiteHeader";
import { SiteEntryPrompt } from "@/components/layout/SiteEntryPrompt";
import { SiteFooter } from "@/components/layout/SiteFooter"; import { SiteFooter } from "@/components/layout/SiteFooter";
import { rootMetadata } from "@/lib/seo"; import { rootMetadata } from "@/lib/seo";
import "./globals.css"; import "./globals.css";
@ -41,6 +42,7 @@ export default function RootLayout({
<div className="relative z-10">{children}</div> <div className="relative z-10">{children}</div>
</main> </main>
<SiteFooter /> <SiteFooter />
<SiteEntryPrompt />
</body> </body>
</html> </html>
); );

View File

@ -19,26 +19,33 @@ export default function PrivacyPage() {
eyebrow="Legal" eyebrow="Legal"
title={<h1 className="text-4xl font-bold">{privacyPolicy.title}</h1>} title={<h1 className="text-4xl font-bold">{privacyPolicy.title}</h1>}
description={ description={
<> <p className="text-sm text-[#3d5248]">Last updated: {privacyPolicy.updated}</p>
<p className="text-sm">Last updated: {privacyPolicy.updated}</p>
<p className="mt-4 leading-relaxed">{privacyPolicy.intro}</p>
</>
} }
/> />
<Section> <Section>
<TopoProseSurface className="max-w-3xl space-y-10"> <TopoProseSurface className="max-w-2xl space-y-6">
<p className="text-[#3d5248] leading-relaxed">{privacyPolicy.intro}</p>
{privacyPolicy.sections.map((section) => ( {privacyPolicy.sections.map((section) => (
<section key={section.heading}> <section key={section.heading}>
<h2 className="text-xl font-semibold text-[#1a5c38]">{section.heading}</h2> <h2 className="text-lg font-semibold text-[#1a5c38]">{section.heading}</h2>
<p className="mt-3 text-muted-foreground leading-relaxed">{section.body}</p> <p className="mt-2 text-[#3d5248] leading-relaxed">{section.body}</p>
</section> </section>
))} ))}
<p className="border-t border-[#1a5c38]/12 pt-6 text-[#3d5248] leading-relaxed">
{privacyPolicy.moreDetails}
</p>
</TopoProseSurface> </TopoProseSurface>
<div className="mt-12">
<Button variant="outline" className="rounded-full" asChild> <div className="mt-8 flex flex-wrap gap-3">
<Button className="rounded-full bg-[#1a5c38] text-white hover:bg-[#0d3d26]" asChild>
<Link href="/contact">Contact us</Link> <Link href="/contact">Contact us</Link>
</Button> </Button>
<Button variant="outline" className="rounded-full border-[#1a5c38]/30 text-[#1a5c38]" asChild>
<Link href={`mailto:${privacyPolicy.contactEmail}`}>{privacyPolicy.contactEmail}</Link>
</Button>
</div> </div>
</Section> </Section>
</> </>

View File

@ -2,7 +2,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
const MAX_PARTICLES = 64; const MAX_PARTICLES = 40;
type Particle = { type Particle = {
x: number; x: number;
@ -52,8 +52,8 @@ export function HeroRiftParticles({ active, className }: Props) {
return { return {
x: w * (0.28 + Math.random() * 0.44), x: w * (0.28 + Math.random() * 0.44),
y: h * (0.38 + Math.random() * 0.42), y: h * (0.38 + Math.random() * 0.42),
vy: -0.25 - Math.random() * (large ? 0.55 : 0.35), vy: -0.12 - Math.random() * (large ? 0.22 : 0.14),
vx: (Math.random() - 0.5) * 0.12, vx: (Math.random() - 0.5) * 0.04,
size: large ? 2.2 + Math.random() * 3.2 : 1.2 + Math.random() * 2.4, size: large ? 2.2 + Math.random() * 3.2 : 1.2 + Math.random() * 2.4,
alpha: 0.35 + Math.random() * 0.45, alpha: 0.35 + Math.random() * 0.45,
glow: large ? 14 + Math.random() * 10 : 8 + Math.random() * 6, glow: large ? 14 + Math.random() * 10 : 8 + Math.random() * 6,
@ -74,8 +74,8 @@ export function HeroRiftParticles({ active, className }: Props) {
p.y = h * (0.52 + Math.random() * 0.35); p.y = h * (0.52 + Math.random() * 0.35);
p.x = w * (0.28 + Math.random() * 0.44); p.x = w * (0.28 + Math.random() * 0.44);
} }
if (p.x < w * 0.15) p.vx += 0.02; if (p.x < w * 0.2) p.vx += 0.008;
if (p.x > w * 0.85) p.vx -= 0.02; if (p.x > w * 0.8) p.vx -= 0.008;
ctx.save(); ctx.save();
ctx.shadowBlur = p.glow; ctx.shadowBlur = p.glow;

View File

@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { X } from "lucide-react";
import { dataConsent } from "@/content/consent";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
const copy = dataConsent.siteEntry;
function hasAcceptedConsent(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem(copy.storageKey) === "accepted";
}
export function SiteEntryPrompt() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [visible, setVisible] = useState(false);
const [checked, setChecked] = useState(false);
useEffect(() => {
if (hasAcceptedConsent()) {
setOpen(false);
setVisible(false);
return;
}
setOpen(true);
setChecked(false);
const show = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(show);
}, [pathname]);
const dismiss = () => {
setVisible(false);
window.setTimeout(() => setOpen(false), 280);
};
const handleAccept = () => {
if (!checked) return;
localStorage.setItem(copy.storageKey, "accepted");
dismiss();
};
const handleDecline = () => {
dismiss();
};
if (!open) return null;
return (
<div
role="dialog"
aria-labelledby="entry-consent-title"
aria-describedby="entry-consent-desc"
className={cn(
"fixed bottom-4 right-4 z-[200] w-[min(calc(100vw-2rem),24rem)] rounded-2xl border border-[#1a5c38]/15 bg-white p-5 shadow-[0_12px_40px_rgba(13,61,38,0.18)] transition-all duration-500 ease-out",
visible ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0"
)}
>
<button
type="button"
onClick={handleDecline}
className="absolute top-3 right-3 rounded-full p-1 text-[#3d5248] transition-colors hover:bg-[#1a5c38]/8 hover:text-[#0d3d26]"
aria-label="Decline and close"
>
<X className="size-4" />
</button>
<h2
id="entry-consent-title"
className="pr-6 text-lg font-bold leading-snug text-[#0d3d26]"
>
{copy.title}
</h2>
<p id="entry-consent-desc" className="mt-2 text-sm leading-relaxed text-[#3d5248]">
{copy.description}
</p>
<div className="mt-4 flex items-start gap-3 rounded-lg border border-[#1a5c38]/12 bg-[#f0f5f2]/80 p-3">
<Checkbox
id="site-entry-consent"
checked={checked}
onCheckedChange={(v) => setChecked(v === true)}
className="mt-0.5 border-[#1a5c38]/40 data-[state=checked]:border-[#1a5c38] data-[state=checked]:bg-[#1a5c38]"
/>
<Label
htmlFor="site-entry-consent"
className="text-sm font-normal leading-snug text-[#3d5248]"
>
{copy.checkboxLabel}{" "}
<Link
href={copy.privacyHref}
className="font-medium text-[#1a5c38] underline underline-offset-2"
>
{dataConsent.privacyLinkText}
</Link>
.
</Label>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<Button
type="button"
disabled={!checked}
onClick={handleAccept}
className="rounded-full bg-[#1a5c38] text-white hover:bg-[#0d3d26] disabled:opacity-50"
>
{copy.acceptCta}
</Button>
<Button
type="button"
variant="outline"
onClick={handleDecline}
className="rounded-full border-[#1a5c38]/30 text-[#1a5c38] hover:bg-[#1a5c38]/6"
>
{copy.declineCta}
</Button>
</div>
</div>
);
}

View File

@ -40,7 +40,7 @@ export function TicketCard({ tier, index, featured, compact }: Props) {
> >
<div <div
className={cn( className={cn(
"ticket-admission relative w-full overflow-hidden bg-white text-[#0f0404]", "topo-card-surface ticket-admission relative w-full overflow-hidden bg-white text-[#0f0404]",
"shadow-[0_12px_40px_rgba(0,0,0,0.18)]", "shadow-[0_12px_40px_rgba(0,0,0,0.18)]",
featured && "ring-2 ring-[#ffb300]/50" featured && "ring-2 ring-[#ffb300]/50"
)} )}
@ -81,7 +81,7 @@ export function TicketCard({ tier, index, featured, compact }: Props) {
> >
{price} {price}
</p> </p>
<p className="mt-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <p className="mt-1 text-[10px] font-medium uppercase tracking-wide text-[#3d5248]">
{schedule} {schedule}
</p> </p>
</div> </div>
@ -91,8 +91,10 @@ export function TicketCard({ tier, index, featured, compact }: Props) {
{/* Main — name, one line, details popover, CTA */} {/* Main — name, one line, details popover, CTA */}
<div className="flex flex-1 flex-col justify-between gap-4 p-4 md:p-5"> <div className="flex flex-1 flex-col justify-between gap-4 p-4 md:p-5">
<div className="min-w-0 pr-8 md:pr-0"> <div className="min-w-0 pr-8 md:pr-0">
<h3 className="text-lg font-bold leading-tight md:text-xl">{tier.name}</h3> <h3 className="text-lg font-bold leading-tight text-[#0d3d26] md:text-xl">
<p className="mt-1.5 line-clamp-2 text-sm text-muted-foreground"> {tier.name}
</h3>
<p className="mt-1.5 line-clamp-2 text-sm text-[#3d5248]">
{ticketTagline(tier)} {ticketTagline(tier)}
</p> </p>
</div> </div>

View File

@ -6,4 +6,15 @@ export const dataConsent = {
privacyLinkText: "Privacy Policy", privacyLinkText: "Privacy Policy",
paymentLabel: paymentLabel:
"I agree that my name, email, and payment details may be collected and used by the Ethiopian Diaspora Trust Fund (EDTF) to process my ticket order and send summit-related communications, in accordance with the", "I agree that my name, email, and payment details may be collected and used by the Ethiopian Diaspora Trust Fund (EDTF) to process my ticket order and send summit-related communications, in accordance with the",
siteEntry: {
storageKey: "grv-summit-data-consent",
title: "Your privacy",
description:
"We collect information you submit on this site to operate GRV Summit. See our Privacy Policy for a short summary.",
checkboxLabel:
"I agree that my information may be collected and used by the Ethiopian Diaspora Trust Fund (EDTF) for summit operations and communications, in line with the",
acceptCta: "Accept",
declineCta: "Decline",
privacyHref: "/privacy",
},
} as const; } as const;

View File

@ -2,27 +2,18 @@ export const privacyPolicy = {
title: "Privacy Policy", title: "Privacy Policy",
updated: "May 2025", updated: "May 2025",
intro: intro:
"The Ethiopian Diaspora Trust Fund (EDTF), presenter of the Great Rift Valley Innovation Summit, explains how we collect and use personal information when you use this website, register for the summit, or submit forms.", "EDTF (presenter of GRV Summit) collects only what you submit on this site—such as name, email, and form details—to run the summit and reply to you. We do not sell your data.",
sections: [ sections: [
{ {
heading: "Information we collect", heading: "How we use it",
body: "We may collect your name, email address, phone number, company name, job title, messages you send us, booth or partnership details, startup referral information, newsletter preferences, and ticket order details when you voluntarily submit a form or complete a purchase request.", body: "To process tickets, booth and partnership requests, pitch applications, and summit updates you opt into.",
}, },
{ {
heading: "How we use your information", heading: "Your choices",
body: "We use this information to respond to inquiries, process registrations and booth requests, manage partnerships, evaluate pitch and startup referrals, send summit updates you have opted into, and improve our programs. We do not sell your personal information.", body: "You can withdraw consent or ask about your data anytime. We keep records only as long as needed for summit operations or the law requires.",
},
{
heading: "Legal basis & consent",
body: "Where required, we rely on your consent and our legitimate interest in operating the summit. Forms on this site include a consent checkbox; you may withdraw consent by contacting us, though we may need to retain certain records for legal or operational reasons.",
},
{
heading: "Retention & security",
body: "We retain information only as long as needed for the purposes above or as required by law. We apply reasonable technical and organizational measures to protect data, but no online transmission is completely secure.",
},
{
heading: "Contact",
body: "For privacy questions or to exercise your rights, email info@grvsummit.com.",
}, },
], ],
moreDetails:
"For a full privacy request, data access, or deletion, contact our team—we will respond with complete information.",
contactEmail: "info@grvsummit.com",
} as const; } as const;