Add venture detail pages, CropSphere showcase, and update Dockerfile
26
Dockerfile
@@ -1,5 +1,8 @@
|
||||
# Node 24 to match local development: package-lock.json written by a local `npm install`
|
||||
# (npm 11) must also pass `npm ci` here, or the build fails with "lock file out of sync".
|
||||
|
||||
# 1. Build Stage
|
||||
FROM node:22-alpine AS build
|
||||
FROM node:24-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,7 +10,7 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
@@ -16,17 +19,24 @@ COPY . .
|
||||
RUN NITRO_PRESET=node-server npm run build
|
||||
|
||||
# 2. Production Stage
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built output from build stage
|
||||
COPY --from=build /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# The Nitro output is self-contained (dependencies are bundled), so nothing else is copied.
|
||||
COPY --from=build --chown=node:node /app/.output ./.output
|
||||
|
||||
# Run as the image's unprivileged user rather than root.
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Lets Dokploy/Docker see when the server is actually answering requests.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/ >/dev/null || exit 1
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
||||
@@ -5,10 +5,10 @@ All photos are from [Unsplash](https://unsplash.com) under the
|
||||
required; credited here anyway). Resized to 1200px WebP.
|
||||
|
||||
| File | Photographer | Unsplash photo |
|
||||
| ---------------------- | ------------------ | --------------------------------------- |
|
||||
| `studio-foundry.webp` | Morteza Mohammadi | https://unsplash.com/photos/ZLzkwU49BdY |
|
||||
| `holding-tower.webp` | Verne Ho | https://unsplash.com/photos/0LAJfSNa-xQ |
|
||||
| `compound-spiral.webp` | Jean-Luc Benazet | https://unsplash.com/photos/dLpP34ADQPI |
|
||||
| ----------------------------- | ------------------ | --------------------------------------- |
|
||||
| `pillar-studio-team.webp` | Annie Spratt | https://unsplash.com/photos/MChSQHxGZrQ |
|
||||
| `pillar-holding-hq.webp` | Juzzepo | https://unsplash.com/photos/vcee2hRsjl0 |
|
||||
| `pillar-compound-growth.webp` | Carlos Muza | https://unsplash.com/photos/hpjSkU2UYSU |
|
||||
| `ai-vision.webp` | Yael Yañez | https://unsplash.com/photos/jw245ZR2lz4 |
|
||||
| `ai-agentic.webp` | Gabriele Malaspina | https://unsplash.com/photos/CjWsslYVnPI |
|
||||
| `ai-enterprise.webp` | Winston Chen | https://unsplash.com/photos/iZe21DzHnUg |
|
||||
|
||||
|
Before Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 81 KiB |
BIN
src/assets/cards/pillar-compound-growth.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
src/assets/cards/pillar-holding-hq.webp
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
src/assets/cards/pillar-studio-team.webp
Normal file
|
After Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 155 KiB |
@@ -12,21 +12,21 @@ required; credited here anyway). Resized WebP.
|
||||
| `stage-launch.webp` | SpaceX | https://unsplash.com/photos/TV2gg2kZD1o |
|
||||
| `stage-scale.webp` | Mara F | https://unsplash.com/photos/tZdFQvqJuQQ |
|
||||
| `stage-spinoff.webp` | Curioso Photography | https://unsplash.com/photos/OOiygaquXZs |
|
||||
| `stack-engineering.webp` | Manuel | https://unsplash.com/photos/CANL3bzp6wU |
|
||||
| `stack-engineering.webp` | Peaky Frames | https://unsplash.com/photos/GAoGLvYLt3c |
|
||||
| `stack-design.webp` | Amélie Mourichon | https://unsplash.com/photos/sv8oOQaUb-o |
|
||||
| `stack-cloud.webp` | Rebecca Campbell | https://unsplash.com/photos/5nN9NX6dJxc |
|
||||
| `stack-cloud.webp` | Eric Stoynov | https://unsplash.com/photos/UfR_yqIcd4g |
|
||||
| `stack-legal.webp` | Sasun Bughdaryan | https://unsplash.com/photos/qVOJqYI37qE |
|
||||
| `stack-finance.webp` | Kelly Sikkema | https://unsplash.com/photos/M98NRBuzbpc |
|
||||
| `stack-marketing.webp` | Kayle Kaupanger | https://unsplash.com/photos/GVi0ijl8ExM |
|
||||
| `stack-sales.webp` | Rock Staar | https://unsplash.com/photos/NzIV4vOBA7s |
|
||||
| `stack-growth.webp` | Sable Flow | https://unsplash.com/photos/T74mVg__F_k |
|
||||
| `stack-data.webp` | Luke Chesser | https://unsplash.com/photos/JKUTrJ4vK00 |
|
||||
| `stack-data.webp` | Resource Database | https://unsplash.com/photos/TIUyoNGM0no |
|
||||
| `stack-governance.webp` | Danielle Cerullo | https://unsplash.com/photos/bIZJRVBLfOM |
|
||||
| `vision-city.webp` | Pradeep Gopal (Bengaluru) | https://unsplash.com/photos/4J3CHmrFiIg |
|
||||
| `investors-skyline.webp` | Himmel S | https://unsplash.com/photos/Io1lz5bZ0as |
|
||||
| `principle-evidence.webp` | Mika Baumeister | https://unsplash.com/photos/Wpnoqo2plFA |
|
||||
| `principle-engine.webp` | Nikita Turkovich | https://unsplash.com/photos/ioxS2dYRiJw |
|
||||
| `principle-standalone.webp` | Nathan Jennings | https://unsplash.com/photos/VsPsf4F5Pi0 |
|
||||
| `model-build.webp` | Art Institute of Chicago | https://unsplash.com/photos/sAlWjm2huck |
|
||||
| `model-build.webp` | Lucas Kepner | https://unsplash.com/photos/Yn8D5B8C-eY |
|
||||
| `model-spinoff.webp` | Markus Spiske | https://unsplash.com/photos/vrbZVyX2k4I |
|
||||
| `model-compound.webp` | Marek Studzinski | https://unsplash.com/photos/tnT54YSnlL0 |
|
||||
|
||||
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 118 KiB |
766
src/components/site/cropsphere.tsx
Normal file
@@ -0,0 +1,766 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
Coins,
|
||||
Database,
|
||||
Globe,
|
||||
Recycle,
|
||||
ShoppingBasket,
|
||||
Sprout,
|
||||
Store,
|
||||
User,
|
||||
Wheat,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Accent,
|
||||
EASE_STAGE,
|
||||
FillWords,
|
||||
SectionHeader,
|
||||
reveal,
|
||||
usePinnedLayout,
|
||||
} from "@/components/site/sections";
|
||||
|
||||
// Cropsphere's story, adapted from the founding team's own description. Plans and potential
|
||||
// revenue are worded as such; nothing here claims traction.
|
||||
|
||||
const CHAIN = ["Farm", "Food", "Consumer", "Regeneration"];
|
||||
|
||||
const LAYERS: { layer: string; product: string; role: string; icon: LucideIcon }[] = [
|
||||
{
|
||||
layer: "Agriculture",
|
||||
product: "HarvestHQ",
|
||||
role: "An AI + IoT agricultural operating system.",
|
||||
icon: Sprout,
|
||||
},
|
||||
{
|
||||
layer: "Commerce",
|
||||
product: "Cropsphere Produce",
|
||||
role: "A traceable farm-to-consumer marketplace.",
|
||||
icon: ShoppingBasket,
|
||||
},
|
||||
{
|
||||
layer: "Regeneration",
|
||||
product: "O-Points",
|
||||
role: "Rewards and measures regenerative actions.",
|
||||
icon: Recycle,
|
||||
},
|
||||
{
|
||||
layer: "Consumer",
|
||||
product: "Living",
|
||||
role: "The physical consumer and retail experience layer.",
|
||||
icon: Store,
|
||||
},
|
||||
];
|
||||
|
||||
/** Section 1: what Cropsphere is, and the four layers it connects. */
|
||||
function Overview() {
|
||||
return (
|
||||
<section className="sheet-enter theme-light section-y bg-background text-foreground">
|
||||
<div className="container-x">
|
||||
<SectionHeader
|
||||
eyebrow="What is Cropsphere"
|
||||
icon={Sprout}
|
||||
title={
|
||||
<>
|
||||
Not an app. Not a marketplace.{" "}
|
||||
<Accent>An operating ecosystem for regenerative agriculture.</Accent>
|
||||
</>
|
||||
}
|
||||
compact
|
||||
>
|
||||
<p>
|
||||
Cropsphere is an integrated regenerative-agriculture operating ecosystem. It connects
|
||||
four things that operate separately today:
|
||||
</p>
|
||||
</SectionHeader>
|
||||
|
||||
{/* Farm → Food → Consumer → Regeneration */}
|
||||
{/* Each step lights up in turn as the row crosses the screen (.chain-step). */}
|
||||
<ol className="mt-10 flex flex-wrap items-center gap-3 font-display text-xl font-bold md:text-3xl">
|
||||
{CHAIN.map((c, i) => (
|
||||
<li
|
||||
key={c}
|
||||
className="chain-step flex items-center gap-3"
|
||||
style={{ "--i": i } as CSSProperties}
|
||||
>
|
||||
<span className="rounded-full border border-primary/40 bg-primary/10 px-5 py-2.5 text-primary">
|
||||
{c}
|
||||
</span>
|
||||
{i < CHAIN.length - 1 ? (
|
||||
<ArrowRight className="h-5 w-5 text-foreground/40" aria-hidden />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<blockquote
|
||||
{...reveal("up", 120)}
|
||||
className="mt-14 max-w-4xl border-l-2 border-primary pl-6 font-display text-2xl font-semibold leading-snug tracking-tight md:pl-8 md:text-4xl"
|
||||
>
|
||||
Use technology to make farming more intelligent, make food more traceable, and make
|
||||
regenerative actions economically valuable.
|
||||
</blockquote>
|
||||
|
||||
<div className="mt-20">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<h3 className="font-display text-3xl font-bold md:text-4xl">
|
||||
Four layers, one system.
|
||||
</h3>
|
||||
<span className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Regenerative Agri Operating Ecosystem (RAOE)
|
||||
</span>
|
||||
</div>
|
||||
{/* The layers stack on top of each other as you scroll (.layer-stack). */}
|
||||
<ul className="layer-stack mt-8 flex flex-col gap-5 pb-[10vh]">
|
||||
{LAYERS.map((l, i) => {
|
||||
const Icon = l.icon;
|
||||
return (
|
||||
<li
|
||||
key={l.product}
|
||||
style={{ "--i": i } as CSSProperties}
|
||||
className="theme-dark group relative flex min-h-[13rem] flex-col justify-between gap-6 overflow-hidden rounded-3xl border border-white/10 bg-background p-7 text-foreground shadow-[0_-24px_60px_-30px_oklch(0.05_0.01_60/0.8)] md:flex-row md:items-center md:p-10"
|
||||
>
|
||||
<div className="glow absolute -right-16 -top-16 h-56 w-56 opacity-40 transition-opacity duration-500 group-hover:opacity-90" />
|
||||
<div className="relative flex items-center gap-5">
|
||||
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-primary text-primary-foreground">
|
||||
<Icon className="h-6 w-6" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
0{i + 1} · {l.layer}
|
||||
</div>
|
||||
<div className="mt-1 font-display text-3xl font-bold tracking-tight md:text-5xl">
|
||||
{l.product}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="relative text-lg leading-relaxed text-muted-foreground md:max-w-sm md:text-right">
|
||||
{l.role}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small left-to-right (wrapping) chain of steps. */
|
||||
function Chain({ items, tone = "dark" }: { items: string[]; tone?: "dark" | "gold" }) {
|
||||
return (
|
||||
<ol className="mt-5 flex flex-wrap items-center gap-2">
|
||||
{items.map((t, i) => (
|
||||
<li key={t} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-3.5 py-1.5 text-sm ${
|
||||
tone === "gold"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border border-white/10 bg-white/[0.05] text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
{i < items.length - 1 ? (
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS: { n: string; title: string; product: string; body: ReactNode }[] = [
|
||||
{
|
||||
n: "01",
|
||||
title: "Connect farms",
|
||||
product: "Network",
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Farmers and landowners join the Cropsphere network. HarvestHQ gathers what each farm needs
|
||||
to be understood, combining AI, IoT, satellite and farm-level data to support agricultural
|
||||
decisions.
|
||||
</p>
|
||||
<Chain
|
||||
items={[
|
||||
"Land and crop information",
|
||||
"Soil and environmental data",
|
||||
"Weather",
|
||||
"Farm activity",
|
||||
"Crop images",
|
||||
"Market demand",
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
n: "02",
|
||||
title: "HarvestHQ helps run the farm",
|
||||
product: "HarvestHQ",
|
||||
body: (
|
||||
<>
|
||||
<p>The intelligence layer, across the whole season:</p>
|
||||
<Chain
|
||||
tone="gold"
|
||||
items={[
|
||||
"Crop planning",
|
||||
"Irrigation",
|
||||
"Disease monitoring",
|
||||
"Input optimisation",
|
||||
"Yield prediction",
|
||||
"Market planning",
|
||||
]}
|
||||
/>
|
||||
<div className="mt-6 rounded-2xl border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
For example
|
||||
</div>
|
||||
<p className="mt-2">
|
||||
A farmer has two acres. Instead of only asking “What should I grow?”, HarvestHQ can
|
||||
analyse the farm's information and help work out{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
what to grow, how to grow it, how much input to use, the expected yield and the likely
|
||||
market demand.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-6">
|
||||
The long-term vision is to move from advice alone to an operating system that connects
|
||||
recommendations with execution and automation.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
n: "03",
|
||||
title: "Cropsphere Produce takes it to market",
|
||||
product: "Cropsphere Produce",
|
||||
body: (
|
||||
<>
|
||||
<p>Once production is connected, the next question is where the produce goes.</p>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 p-5">
|
||||
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Today
|
||||
</div>
|
||||
<Chain items={["Farmer", "Many intermediaries", "Retailer", "Consumer"]} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-primary/40 bg-primary/10 p-5">
|
||||
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
With Cropsphere
|
||||
</div>
|
||||
<Chain tone="gold" items={["Farm", "Cropsphere", "Consumer"]} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-6">
|
||||
With digital traceability, a consumer could see which farm, what was grown and how, right
|
||||
through to purchase. The marketplace can support subscriptions, quick commerce, standard
|
||||
e-commerce, farm experiences and institutional or B2B supply.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
n: "04",
|
||||
title: "O-Points rewards regeneration",
|
||||
product: "O-Points",
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Whenever people take part in regenerative activity, they can earn O-Points, turning
|
||||
otherwise invisible environmental actions into a measurable, digital layer of
|
||||
participation.
|
||||
</p>
|
||||
<ul className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
"Buy produce",
|
||||
"Plant a sapling or seedball",
|
||||
"Register and verify an existing tree",
|
||||
"Join regenerative activities",
|
||||
].map((a) => (
|
||||
<li
|
||||
key={a}
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-4"
|
||||
>
|
||||
<span className="text-foreground">{a}</span>
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-3 py-1 text-xs font-semibold text-primary">
|
||||
+ O-Points
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-6">
|
||||
Behind it: reward logic, verification, a ledger and wallet, and over time a mechanism for
|
||||
aggregating carbon value.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
n: "05",
|
||||
title: "Living brings it to the consumer",
|
||||
product: "Living",
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Living is Cropsphere's physical, offline layer: hubs, product sampling, farm experiences,
|
||||
wellness and consumer engagement. Its first touchpoint is a vending-machine network in
|
||||
tier-1 and tier-2 cities.
|
||||
</p>
|
||||
<Chain
|
||||
items={[
|
||||
"A consumer buys a smoothie or produce",
|
||||
"The sale earns revenue",
|
||||
"They discover Cropsphere",
|
||||
"They join O-Points",
|
||||
]}
|
||||
/>
|
||||
<p className="mt-6 font-display text-xl font-semibold text-foreground">
|
||||
Not merely a vending machine: revenue, brand, customer acquisition and an entry point to
|
||||
the ecosystem, in one.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Section 2: the five steps, each pinned beside its number as you scroll. */
|
||||
function HowItWorks() {
|
||||
return (
|
||||
<section className="section-y relative bg-ink">
|
||||
<div className="container-x">
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
How it <Accent>actually works.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>Four products, one flywheel. Here is the path from a farm to a consumer and back.</p>
|
||||
</SectionHeader>
|
||||
|
||||
{/* A stem grows down the steps as you read; each step's node lights as it passes. */}
|
||||
<ol className="relative mt-16 pl-10 md:pl-16">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-[11px] top-0 w-px bg-white/10 md:left-[19px]"
|
||||
>
|
||||
<span className="stem-grow block h-full w-full bg-gradient-to-b from-primary via-primary to-primary/30" />
|
||||
</span>
|
||||
{STEPS.map((s) => (
|
||||
<li
|
||||
key={s.n}
|
||||
className="relative grid gap-6 border-t border-white/10 py-12 md:py-16 lg:grid-cols-12 lg:gap-12"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="stem-node absolute -left-10 top-14 grid h-6 w-6 place-items-center rounded-full border-2 border-primary bg-primary md:-left-16 md:top-[4.5rem] md:h-10 md:w-10"
|
||||
>
|
||||
<Sprout className="h-3 w-3 text-ink md:h-4 md:w-4" />
|
||||
</span>
|
||||
<div className="lg:col-span-4">
|
||||
<div className="lg:sticky lg:top-32" {...reveal("stagger")}>
|
||||
<span className="font-display text-6xl font-extrabold tabular-nums text-transparent [-webkit-text-stroke:1px_color-mix(in_oklab,var(--primary)_75%,transparent)] md:text-7xl">
|
||||
{s.n}
|
||||
</span>
|
||||
<h3 className="mt-4 font-display text-3xl font-bold leading-tight md:text-4xl">
|
||||
{s.title}
|
||||
</h3>
|
||||
<span className="mt-4 inline-block rounded-full bg-primary/15 px-3 py-1 font-display text-xs font-semibold uppercase tracking-[0.14em] text-primary">
|
||||
{s.product}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
{...reveal("up", 100)}
|
||||
className="space-y-0 text-lg leading-relaxed text-muted-foreground lg:col-span-8"
|
||||
>
|
||||
{s.body}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const FLYWHEEL: { label: string; icon: LucideIcon; desc: string }[] = [
|
||||
{ label: "Farm", icon: Sprout, desc: "HarvestHQ improves agricultural intelligence." },
|
||||
{ label: "Food", icon: Wheat, desc: "Cropsphere Produce brings verified production to market." },
|
||||
{ label: "Consumer", icon: User, desc: "Consumers purchase and take part." },
|
||||
{
|
||||
label: "O-Points",
|
||||
icon: Recycle,
|
||||
desc: "Their regenerative actions are recorded and rewarded.",
|
||||
},
|
||||
{
|
||||
label: "Impact",
|
||||
icon: Globe,
|
||||
desc: "Regenerative activity creates measurable environmental data.",
|
||||
},
|
||||
{
|
||||
label: "Value",
|
||||
icon: Coins,
|
||||
desc: "That data and activity can support future carbon and ESG monetisation.",
|
||||
},
|
||||
{ label: "Data", icon: Database, desc: "More farms, transactions and users create more data." },
|
||||
{
|
||||
label: "Better intelligence",
|
||||
icon: BrainCircuit,
|
||||
desc: "More data sharpens how the platform understands farms, demand and regeneration. More farms join, and the cycle repeats.",
|
||||
},
|
||||
];
|
||||
|
||||
const SPIN_MS = 2800;
|
||||
|
||||
/** Place n items evenly on a circle (radius in % of the box), starting at 12 o'clock. */
|
||||
function onRing(i: number, n: number, radius: number): CSSProperties {
|
||||
const a = ((-90 + (i * 360) / n) * Math.PI) / 180;
|
||||
return {
|
||||
left: `${(50 + radius * Math.cos(a)).toFixed(2)}%`,
|
||||
top: `${(50 + radius * Math.sin(a)).toFixed(2)}%`,
|
||||
};
|
||||
}
|
||||
|
||||
// Scroll length per flywheel stage while the section is pinned.
|
||||
const FLYWHEEL_STEP_SVH = 32;
|
||||
|
||||
/**
|
||||
* Section 3: the flywheel. Large screens: the section pins and scrolling turns the wheel one
|
||||
* stage at a time (pointer and keyboard can also pick a stage); the centre explains it. Short
|
||||
* screens and reduced motion: the wheel advances on its own. Small screens: a looping list.
|
||||
*/
|
||||
function Flywheel() {
|
||||
const [active, setActive] = useState(0);
|
||||
const [held, setHeld] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const ringRef = useRef<SVGGElement>(null);
|
||||
const n = FLYWHEEL.length;
|
||||
const pinned = usePinnedLayout("(min-width: 1024px)");
|
||||
|
||||
// Pinned: scroll position picks the stage and spins the outer ring.
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!pinned || !section) return;
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const distance = section.offsetHeight - window.innerHeight;
|
||||
const p =
|
||||
distance > 0
|
||||
? Math.min(1, Math.max(0, -section.getBoundingClientRect().top / distance))
|
||||
: 0;
|
||||
setActive(Math.min(n - 1, Math.floor(p * n)));
|
||||
if (ringRef.current) ringRef.current.style.transform = `rotate(${(p * 120).toFixed(2)}deg)`;
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [pinned, n]);
|
||||
|
||||
// Not pinned: advance on a timer while in view.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || pinned || held || window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
||||
return;
|
||||
let timer = 0;
|
||||
const io = new IntersectionObserver(([e]) => {
|
||||
window.clearInterval(timer);
|
||||
if (e.isIntersecting)
|
||||
timer = window.setInterval(() => setActive((a) => (a + 1) % n), SPIN_MS);
|
||||
});
|
||||
io.observe(el);
|
||||
return () => {
|
||||
io.disconnect();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [pinned, held, n]);
|
||||
|
||||
const current = FLYWHEEL[active];
|
||||
const CurrentIcon = current.icon;
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={`sheet-enter theme-light relative bg-background text-foreground ${pinned ? "" : "section-y"}`}
|
||||
style={pinned ? { height: `${100 + n * FLYWHEEL_STEP_SVH}svh` } : undefined}
|
||||
>
|
||||
<div className={pinned ? "sticky top-0 flex h-[100svh] items-center pt-16" : ""}>
|
||||
<div className="container-x grid w-full items-center gap-14 lg:grid-cols-12">
|
||||
<div className="lg:col-span-5" {...reveal("stagger")}>
|
||||
<span className="eyebrow">The flywheel</span>
|
||||
<h2 className="mt-5 font-display text-4xl font-bold leading-[1.04] md:text-5xl">
|
||||
Every turn makes the <Accent>next one stronger.</Accent>
|
||||
</h2>
|
||||
<p className="mt-6 max-w-[48ch] text-lg leading-relaxed text-muted-foreground">
|
||||
Cropsphere is a flywheel, not four separate products. Better farming creates better
|
||||
food; engaged consumers create data and revenue; regenerative activity creates
|
||||
measurable environmental value, and all of it brings more farms in.
|
||||
</p>
|
||||
{pinned ? (
|
||||
<div className="mt-10 flex items-center gap-5">
|
||||
<span className="font-display text-sm font-semibold tabular-nums">
|
||||
<span className="text-primary">{String(active + 1).padStart(2, "0")}</span>
|
||||
<span className="text-muted-foreground"> / {String(n).padStart(2, "0")}</span>
|
||||
</span>
|
||||
<span className="h-px flex-1 overflow-hidden bg-foreground/10">
|
||||
<span
|
||||
className="block h-full origin-left bg-primary transition-transform duration-500"
|
||||
style={{ transform: `scaleX(${(active + 1) / n})` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Scroll to turn
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Wheel (large screens) */}
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative mx-auto hidden aspect-square w-full max-w-[34rem] lg:col-span-7 lg:block"
|
||||
onPointerEnter={() => setHeld(true)}
|
||||
onPointerLeave={() => setHeld(false)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 400 400"
|
||||
className="absolute inset-0 h-full w-full overflow-visible"
|
||||
aria-hidden
|
||||
>
|
||||
{/* Outer dashed ring: spins as the flywheel is scrolled. */}
|
||||
<g ref={ringRef} style={{ transformOrigin: "200px 200px" }}>
|
||||
<circle
|
||||
cx="200"
|
||||
cy="200"
|
||||
r="194"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="2 12"
|
||||
className="stroke-primary/50"
|
||||
/>
|
||||
</g>
|
||||
<circle
|
||||
cx="200"
|
||||
cy="200"
|
||||
r="168"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
className="stroke-foreground/10"
|
||||
/>
|
||||
<circle
|
||||
cx="200"
|
||||
cy="200"
|
||||
r="168"
|
||||
fill="none"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={2 * Math.PI * 168}
|
||||
strokeDashoffset={2 * Math.PI * 168 * (1 - (active + 1) / n)}
|
||||
transform="rotate(-90 200 200)"
|
||||
className="stroke-primary transition-[stroke-dashoffset] duration-700"
|
||||
/>
|
||||
</svg>
|
||||
{FLYWHEEL.map((f, i) => {
|
||||
const Icon = f.icon;
|
||||
const on = i === active;
|
||||
return (
|
||||
<button
|
||||
key={f.label}
|
||||
type="button"
|
||||
style={onRing(i, n, 42)}
|
||||
onPointerEnter={() => setActive(i)}
|
||||
onFocus={() => {
|
||||
setHeld(true);
|
||||
setActive(i);
|
||||
}}
|
||||
onBlur={() => setHeld(false)}
|
||||
aria-label={`${f.label}: ${f.desc}`}
|
||||
aria-pressed={on}
|
||||
className="group absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={`grid h-14 w-14 place-items-center rounded-2xl border transition-all duration-500 ${EASE_STAGE} ${
|
||||
on
|
||||
? "scale-110 border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/30"
|
||||
: "border-border bg-card text-primary group-hover:border-primary/60"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className={`whitespace-nowrap font-display text-xs font-semibold uppercase tracking-wider transition-colors ${
|
||||
on ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="absolute inset-[24%] grid place-items-center text-center">
|
||||
<div key={active} className="duration-700 animate-in fade-in zoom-in-95">
|
||||
<CurrentIcon className="mx-auto h-8 w-8 text-primary" aria-hidden />
|
||||
<div className="mt-3 font-display text-2xl font-bold">{current.label}</div>
|
||||
<p className="mx-auto mt-2 max-w-[22ch] text-sm leading-snug text-muted-foreground">
|
||||
{current.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loop list (small screens) */}
|
||||
<ol className="space-y-2 lg:hidden" {...reveal("stagger")}>
|
||||
{FLYWHEEL.map((f, i) => {
|
||||
const Icon = f.icon;
|
||||
return (
|
||||
<li key={f.label}>
|
||||
<div className="flex items-start gap-4 rounded-2xl border border-border bg-card p-4">
|
||||
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground">
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-display text-lg font-bold">{f.label}</span>
|
||||
<span className="block text-sm leading-snug text-muted-foreground">
|
||||
{f.desc}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{i < FLYWHEEL.length - 1 ? (
|
||||
<ArrowDown className="mx-auto my-1 h-4 w-4 text-primary/60" aria-hidden />
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className="pt-2 text-center font-display text-sm font-semibold text-primary">
|
||||
↺ More farms join, and the cycle repeats
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const REVENUE: { product: string; items: string[] }[] = [
|
||||
{
|
||||
product: "HarvestHQ",
|
||||
items: ["AI and platform subscriptions", "Farm management and technology services"],
|
||||
},
|
||||
{
|
||||
product: "Cropsphere Produce",
|
||||
items: ["Produce margins", "Subscriptions", "Marketplace commissions", "Value-added products"],
|
||||
},
|
||||
{
|
||||
product: "Living",
|
||||
items: ["Vending-machine sales", "Retail", "Experiences", "Wellness and consumer offerings"],
|
||||
},
|
||||
{
|
||||
product: "O-Points and regeneration",
|
||||
items: [
|
||||
"Corporate sustainability programmes",
|
||||
"Carbon-value realisation",
|
||||
"ESG and impact data services",
|
||||
"Partnerships",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Section 4: where revenue can come from, layer by layer. */
|
||||
function Revenue() {
|
||||
return (
|
||||
<section className="section-y relative overflow-hidden bg-ink">
|
||||
<div className="glow glow-drift absolute -right-48 top-0 h-[36rem] w-[36rem] opacity-40" />
|
||||
<div className="container-x relative">
|
||||
<SectionHeader
|
||||
eyebrow="Business model"
|
||||
icon={Coins}
|
||||
title={
|
||||
<>
|
||||
Revenue from <Accent>every layer.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
A multi-layer revenue architecture rather than a business that depends on selling
|
||||
software alone. Potential revenue streams:
|
||||
</p>
|
||||
</SectionHeader>
|
||||
{/* Columns rise at different speeds, like bars growing (.rise-staggered). */}
|
||||
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{REVENUE.map((r, i) => (
|
||||
<div
|
||||
key={r.product}
|
||||
style={{ "--i": i } as CSSProperties}
|
||||
className="rise-staggered rounded-3xl border border-border bg-card/70 p-7"
|
||||
>
|
||||
<div className="font-display text-xl font-bold text-primary">{r.product}</div>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{r.items.map((it) => (
|
||||
<li key={it} className="flex gap-3 text-foreground/90">
|
||||
<span className="mt-2.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
{it}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section 5: the whole story in one paragraph, brightening word by word as it scrolls. */
|
||||
function InOneParagraph() {
|
||||
return (
|
||||
<section className="sheet-enter theme-light section-y bg-background text-foreground">
|
||||
<div className="container-x">
|
||||
<span className="eyebrow">In one paragraph</span>
|
||||
<p className="mt-8 max-w-5xl font-display text-3xl font-bold leading-[1.12] tracking-tight md:text-5xl">
|
||||
<FillWords
|
||||
text="Cropsphere is building the operating infrastructure for regenerative agriculture."
|
||||
className="text-primary"
|
||||
/>
|
||||
<FillWords text="We connect farms through AI, move their produce through a traceable marketplace, reward consumers and farmers for regenerative actions through O-Points, and bring the ecosystem to consumers through Living." />
|
||||
</p>
|
||||
<p
|
||||
{...reveal("up", 150)}
|
||||
className="mt-10 max-w-[65ch] text-lg leading-relaxed text-muted-foreground"
|
||||
>
|
||||
The result is a closed loop: better farming creates better food, better consumer
|
||||
engagement creates more data and revenue, and regenerative activity creates measurable
|
||||
environmental value.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cropsphere's full story, shown on its venture page in place of the generic vertical lists. */
|
||||
export function CropsphereStory() {
|
||||
return (
|
||||
<>
|
||||
<Overview />
|
||||
<HowItWorks />
|
||||
<Flywheel />
|
||||
<Revenue />
|
||||
<InOneParagraph />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,23 @@ export function useSmoothScroll() {
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks page sections that are well off screen with data-offscreen, which pauses their looping
|
||||
* CSS animations (see styles.css) so hidden decoration never competes with the scroll.
|
||||
*/
|
||||
export function usePauseOffscreen() {
|
||||
useEffect(() => {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) e.target.toggleAttribute("data-offscreen", !e.isIntersecting);
|
||||
},
|
||||
{ rootMargin: "200px 0px" },
|
||||
);
|
||||
document.querySelectorAll("main > section, main > div").forEach((el) => io.observe(el));
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
}
|
||||
|
||||
const LINKISH = "a, button, [role='button'], summary, [data-cursor]";
|
||||
const TEXTISH = "input, textarea, select, [contenteditable='true']";
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ArrowRight, ArrowUpRight, Check, Compass, Layers, Minus, Sparkles } fro
|
||||
import { CONTACT_EMAIL } from "@/lib/contact";
|
||||
import { SplitTitle, type TitlePart } from "@/components/site/motion";
|
||||
import { IndustryIndex, MoltenMark } from "@/components/site/creative";
|
||||
import { CropsphereStory } from "@/components/site/cropsphere";
|
||||
import { DoorMileStory, LoyalyStory } from "@/components/site/venture-stories";
|
||||
// Section photography (Unsplash License): see src/assets/sections/CREDITS.md.
|
||||
import principleEvidence from "@/assets/sections/principle-evidence.webp";
|
||||
import principleEngine from "@/assets/sections/principle-engine.webp";
|
||||
@@ -701,9 +703,23 @@ export function FrameworkPrinciples() {
|
||||
);
|
||||
}
|
||||
|
||||
const VENTURES = VERTICALS.filter((v) => v.example).map((v) => {
|
||||
/** Ventures in the studio, from each vertical's example; each gets a page at /ventures/<slug>. */
|
||||
export const VENTURES = VERTICALS.filter((v) => v.example).map((v) => {
|
||||
const [name, ...rest] = (v.example ?? "").split(". ");
|
||||
return { name, pitch: rest.join(". "), vertical: v.name, img: v.img, icon: v.icon };
|
||||
return {
|
||||
name,
|
||||
slug: name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, ""),
|
||||
pitch: rest.join(". "),
|
||||
vertical: v.name,
|
||||
tag: v.tag,
|
||||
problem: v.problem,
|
||||
solution: v.solution,
|
||||
img: v.img,
|
||||
icon: v.icon,
|
||||
};
|
||||
});
|
||||
|
||||
/** Portfolio: ventures already taking shape in the studio. */
|
||||
@@ -723,14 +739,14 @@ export function Ventures() {
|
||||
<p>A first look at companies being engineered on the KapFoundry engine.</p>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="mt-16 grid gap-6 md:grid-cols-2">
|
||||
<div className="mt-16 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{VENTURES.map((v, i) => {
|
||||
const Icon = v.icon;
|
||||
return (
|
||||
<article
|
||||
key={v.name}
|
||||
{...reveal("clip", i * 120)}
|
||||
className="theme-dark group relative flex min-h-[30rem] flex-col justify-end overflow-hidden rounded-[2rem] bg-background p-8 text-foreground md:min-h-[36rem] md:p-10"
|
||||
className="theme-dark group relative flex min-h-[30rem] flex-col justify-end overflow-hidden rounded-[2rem] bg-background p-8 text-foreground md:min-h-[36rem] md:p-10 md:[&:last-child:nth-child(odd)]:col-span-2 lg:[&:last-child:nth-child(odd)]:col-span-1"
|
||||
>
|
||||
<img
|
||||
src={v.img}
|
||||
@@ -745,13 +761,30 @@ export function Ventures() {
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{v.vertical}
|
||||
</span>
|
||||
<h3 className="mt-6 font-display text-4xl font-extrabold tracking-tight lg:text-6xl">
|
||||
<h3 className="mt-6 font-display text-[2rem] font-extrabold tracking-tight min-[360px]:text-4xl xl:text-5xl">
|
||||
{v.name}
|
||||
</h3>
|
||||
<p className="mt-4 max-w-[40ch] text-lg leading-relaxed text-muted-foreground">
|
||||
{v.pitch}
|
||||
</p>
|
||||
{/* Visible label for the card-wide link below. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-8 inline-flex items-center gap-3 rounded-full border border-white/20 bg-white/10 py-2 pl-6 pr-2 font-display text-sm font-semibold text-foreground backdrop-blur-md transition-colors duration-500 group-hover:border-primary group-hover:bg-primary group-hover:text-primary-foreground"
|
||||
>
|
||||
Explore venture
|
||||
<span className="grid h-9 w-9 place-items-center rounded-full bg-primary text-primary-foreground transition-[background-color,color,rotate] duration-500 group-hover:rotate-45 group-hover:bg-primary-foreground group-hover:text-primary">
|
||||
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* One link over the whole card: click anywhere, one tab stop. */}
|
||||
<a
|
||||
href={`/ventures/${v.slug}`}
|
||||
data-cursor="Explore"
|
||||
aria-label={`Explore ${v.name}`}
|
||||
className="absolute inset-0 z-10 rounded-[2rem]"
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -963,3 +996,301 @@ export function PortfolioIndex() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Venture detail page (/ventures/$slug)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Ventures with a full story of their own; the rest get the generic vertical sections. */
|
||||
const VENTURE_STORIES: Record<string, () => ReactNode> = {
|
||||
cropsphere: CropsphereStory,
|
||||
"doormile-wings": DoorMileStory,
|
||||
"loyaly-ai": LoyalyStory,
|
||||
};
|
||||
|
||||
/** Looks a venture up by its URL slug. */
|
||||
export function findVenture(slug: string) {
|
||||
return VENTURES.find((v) => v.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything on this page comes from what the site already says about the venture and its
|
||||
* vertical; nothing about stage, traction or team is implied.
|
||||
*/
|
||||
export function VentureDetail({ slug }: { slug: string }) {
|
||||
const v = findVenture(slug);
|
||||
if (!v) return null;
|
||||
const i = VENTURES.indexOf(v);
|
||||
const next = VENTURES[(i + 1) % VENTURES.length];
|
||||
const Icon = v.icon;
|
||||
const Story = VENTURE_STORIES[v.slug];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero: the venture's own photo, full bleed */}
|
||||
<section
|
||||
id="top"
|
||||
className="hero-timeline relative flex min-h-[100svh] flex-col overflow-hidden bg-ink"
|
||||
>
|
||||
<div className="hero-media absolute inset-0" aria-hidden>
|
||||
{/* Pulls back into a rounded card as the hero scrolls away (.venture-photo). */}
|
||||
<div className="venture-photo absolute inset-0 overflow-hidden">
|
||||
<img
|
||||
src={v.img}
|
||||
alt=""
|
||||
fetchPriority="high"
|
||||
className="hero-zoom absolute inset-0 h-full w-full object-cover opacity-70"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-ink via-ink/75 to-ink/20" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-ink to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-copy container-x relative flex flex-1 flex-col justify-between gap-12 pb-12 pt-28 md:pt-36">
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
{...rise(100)}
|
||||
className="rise-in flex flex-wrap items-center gap-3 font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground"
|
||||
>
|
||||
<a href="/portfolio" className="transition-colors hover:text-primary">
|
||||
Portfolio
|
||||
</a>
|
||||
<span className="h-px w-8 bg-primary/50" aria-hidden />
|
||||
<span className="text-primary">{v.vertical}</span>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<span
|
||||
{...rise(250)}
|
||||
className="rise-in inline-flex items-center gap-2 rounded-full bg-primary px-3.5 py-1.5 font-display text-[11px] font-semibold uppercase tracking-wider text-primary-foreground"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
In the studio
|
||||
</span>
|
||||
<h1 className="venture-name mt-6 max-w-[14ch] font-display text-[clamp(2.75rem,11vw,4rem)] font-extrabold leading-[0.92] tracking-tighter md:text-[clamp(4rem,9vw,8.5rem)]">
|
||||
<SplitTitle parts={[{ accent: v.name }]} />
|
||||
</h1>
|
||||
<p
|
||||
{...rise(700)}
|
||||
className="rise-in mt-8 max-w-[46ch] text-xl leading-relaxed text-white/80 md:text-2xl"
|
||||
>
|
||||
{v.pitch}
|
||||
</p>
|
||||
<div {...rise(850)} className="rise-in mt-10 flex flex-wrap gap-4">
|
||||
<a
|
||||
href="/contact"
|
||||
data-magnetic
|
||||
className="press group inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 font-display text-sm font-semibold text-primary-foreground hover:brightness-110"
|
||||
>
|
||||
Partner with us
|
||||
<ArrowRight
|
||||
className="h-4 w-4 transition group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="/portfolio"
|
||||
className="press inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-7 py-3.5 font-display text-sm font-semibold text-foreground backdrop-blur-md hover:border-white/30 hover:bg-white/10"
|
||||
>
|
||||
Back to portfolio
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{Story ? (
|
||||
<Story />
|
||||
) : (
|
||||
<>
|
||||
{/* The problem: the market this venture is built for */}
|
||||
<section className="sheet-enter theme-light section-y bg-background text-foreground">
|
||||
<div className="container-x grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-32" {...reveal("stagger")}>
|
||||
<span className="eyebrow">The problem</span>
|
||||
<h2 className="mt-5 font-display text-4xl font-bold leading-[1.04] md:text-5xl">
|
||||
{v.vertical}: <Accent>{v.tag.toLowerCase()}.</Accent>
|
||||
</h2>
|
||||
<p className="mt-6 max-w-[48ch] text-lg leading-relaxed text-muted-foreground">
|
||||
{v.name} is being built for a market held back by the same problems, again and
|
||||
again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="lg:col-span-7">
|
||||
{v.problem.map((p, k) => (
|
||||
<li
|
||||
key={p}
|
||||
{...reveal("up", k * 40)}
|
||||
className="relative flex items-baseline gap-6 py-6 md:py-7"
|
||||
>
|
||||
<span aria-hidden className="absolute inset-x-0 top-0 h-px bg-border" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="line-draw absolute inset-x-0 top-0 h-px bg-primary/60"
|
||||
/>
|
||||
<span className="w-10 shrink-0 font-display text-sm font-semibold tabular-nums text-primary">
|
||||
0{k + 1}
|
||||
</span>
|
||||
<span className="font-display text-2xl font-bold tracking-tight md:text-3xl">
|
||||
{p}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What we build */}
|
||||
<section className="section-y relative overflow-hidden bg-ink">
|
||||
<div className="glow glow-drift absolute -right-48 top-0 h-[36rem] w-[36rem] opacity-40" />
|
||||
<div className="container-x relative">
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
What we build, <Accent>AI-first.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Where KapFoundry focuses in {v.vertical.toLowerCase()}, and the ground {v.name} is
|
||||
built on.
|
||||
</p>
|
||||
</SectionHeader>
|
||||
<ul className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{v.solution.map((s) => (
|
||||
<li
|
||||
key={s}
|
||||
className="tilt-in group flex items-start gap-4 rounded-3xl border border-border bg-card/70 p-6 transition-colors duration-500 hover:border-primary/50 md:p-7"
|
||||
>
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary/15 text-primary transition-colors duration-500 group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<span className="pt-1.5 font-display text-lg font-semibold leading-snug">
|
||||
{s}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Built on the engine: the same framework and stack as every venture */}
|
||||
<section className="sheet-enter theme-light section-y bg-background text-foreground">
|
||||
<div className="container-x">
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
Built on the <Accent>KapFoundry engine.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Like every venture in the studio, {v.name} moves through the same six stages and
|
||||
launches with a mature company's stack.
|
||||
</p>
|
||||
</SectionHeader>
|
||||
|
||||
<ol
|
||||
className="mt-14 grid grid-cols-1 gap-3 min-[400px]:grid-cols-2 md:grid-cols-3 lg:grid-cols-6"
|
||||
{...reveal("stagger")}
|
||||
>
|
||||
{FRAMEWORK.map((f) => {
|
||||
const StageIcon = f.icon;
|
||||
return (
|
||||
<li key={f.n} className="rounded-3xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display text-sm font-semibold tabular-nums text-primary">
|
||||
{f.n}
|
||||
</span>
|
||||
<StageIcon className="h-4 w-4 text-primary" aria-hidden />
|
||||
</div>
|
||||
<div className="mt-6 font-display text-xl font-bold">{f.title}</div>
|
||||
<p className="mt-2 text-sm leading-snug text-muted-foreground">{f.body}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className="mt-10 flex flex-wrap gap-2" {...reveal("up", 150)}>
|
||||
{STACK.map((s) => (
|
||||
<span
|
||||
key={s.label}
|
||||
className="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to action */}
|
||||
<section className="section-y relative overflow-hidden bg-ink">
|
||||
<div className="glow glow-drift absolute -left-40 top-0 h-[34rem] w-[34rem] opacity-40" />
|
||||
<div className="container-x relative grid items-end gap-10 md:grid-cols-12">
|
||||
<h2
|
||||
{...reveal("mask")}
|
||||
className="font-display text-4xl font-bold leading-[1.02] md:col-span-8 md:text-6xl"
|
||||
>
|
||||
Interested in {v.name}? <Accent>Let's talk.</Accent>
|
||||
</h2>
|
||||
<div className="md:col-span-4 md:justify-self-end" {...reveal("up", 150)}>
|
||||
<p className="text-lg leading-relaxed text-muted-foreground">
|
||||
Partners, investors and operators: we'd like to hear from you.
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
data-magnetic
|
||||
className="press group mt-6 inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 font-display text-sm font-semibold text-primary-foreground hover:brightness-110"
|
||||
>
|
||||
Speak with our team
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" aria-hidden />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Next venture */}
|
||||
{next !== v ? (
|
||||
<section
|
||||
aria-label="Next venture"
|
||||
className="relative overflow-hidden border-t border-border bg-ink"
|
||||
>
|
||||
<a
|
||||
href={`/ventures/${next.slug}`}
|
||||
data-cursor="Next"
|
||||
className="group relative block py-24 md:py-32"
|
||||
>
|
||||
<div className="container-x">
|
||||
<div className="flex items-center justify-between font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<span data-scramble>Next venture</span>
|
||||
<span>{next.vertical}</span>
|
||||
</div>
|
||||
<div className="next-grow mt-8 flex flex-col items-start gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<span className="next-fill font-display text-[clamp(2.5rem,13vw,10rem)] font-extrabold leading-[0.9] tracking-tighter md:text-[min(10vw,10rem)]">
|
||||
{next.name}
|
||||
</span>
|
||||
<span
|
||||
className={`grid h-16 w-16 shrink-0 place-items-center rounded-full border border-primary/40 text-primary transition-all duration-700 group-hover:bg-primary group-hover:text-primary-foreground md:h-28 md:w-28 ${EASE_STAGE}`}
|
||||
>
|
||||
<ArrowUpRight
|
||||
className={`h-7 w-7 transition-transform duration-700 group-hover:rotate-45 md:h-10 md:w-10 ${EASE_STAGE}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-10 h-px bg-white/10">
|
||||
<div className="line-draw h-full bg-primary/70" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-md text-lg text-muted-foreground">{next.pitch}</p>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -50,7 +51,13 @@ import {
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Cursor, SplitTitle, useScramble, useSmoothScroll } from "@/components/site/motion";
|
||||
import {
|
||||
Cursor,
|
||||
SplitTitle,
|
||||
usePauseOffscreen,
|
||||
useScramble,
|
||||
useSmoothScroll,
|
||||
} from "@/components/site/motion";
|
||||
import { LensStatement, MoltenMark, VelocityTicker } from "@/components/site/creative";
|
||||
import { CONTACT_EMAIL, INTERESTS, sendContactEnquiry, type ContactInput } from "@/lib/contact";
|
||||
// Brand assets cut from kapfoundry-logo-1.png (emblem, wordmark, full lockup).
|
||||
@@ -66,9 +73,9 @@ import vFoodtech from "@/assets/vertical-foodtech.jpg";
|
||||
import vCarbon from "@/assets/vertical-carbon.jpg";
|
||||
import vHealthtech from "@/assets/vertical-healthtech.jpg";
|
||||
// Card photography (Unsplash License): see src/assets/cards/CREDITS.md.
|
||||
import imgFoundry from "@/assets/cards/studio-foundry.webp";
|
||||
import imgTower from "@/assets/cards/holding-tower.webp";
|
||||
import imgSpiral from "@/assets/cards/compound-spiral.webp";
|
||||
import imgStudioTeam from "@/assets/cards/pillar-studio-team.webp";
|
||||
import imgHoldingHq from "@/assets/cards/pillar-holding-hq.webp";
|
||||
import imgCompoundGrowth from "@/assets/cards/pillar-compound-growth.webp";
|
||||
import imgVision from "@/assets/cards/ai-vision.webp";
|
||||
import imgAgentic from "@/assets/cards/ai-agentic.webp";
|
||||
import imgEnterprise from "@/assets/cards/ai-enterprise.webp";
|
||||
@@ -109,14 +116,7 @@ const NAV = [
|
||||
];
|
||||
|
||||
export type SitePageName =
|
||||
| "home"
|
||||
| "studio"
|
||||
| "framework"
|
||||
| "portfolio"
|
||||
| "ai"
|
||||
| "investors"
|
||||
| "about"
|
||||
| "contact";
|
||||
"home" | "studio" | "framework" | "portfolio" | "ai" | "investors" | "about" | "contact";
|
||||
|
||||
const VERTICALS = [
|
||||
{
|
||||
@@ -210,6 +210,7 @@ const VERTICALS = [
|
||||
"Supply-chain digitization",
|
||||
"Yield prediction and farm analytics",
|
||||
],
|
||||
example: "Cropsphere. The operating infrastructure for regenerative agriculture.",
|
||||
},
|
||||
{
|
||||
n: "05",
|
||||
@@ -462,22 +463,19 @@ function useScrollReveal() {
|
||||
useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const timers: number[] = [];
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// Elements that arrive together (a grid row, a fast scroll) cascade in reading order.
|
||||
const arriving = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.boundingClientRect.top - b.boundingClientRect.top ||
|
||||
a.boundingClientRect.left - b.boundingClientRect.left,
|
||||
);
|
||||
arriving.forEach((entry, i) => {
|
||||
const el = entry.target as HTMLElement;
|
||||
const pending = new Set<HTMLElement>();
|
||||
|
||||
// Reveal a group of elements, cascading them in reading order.
|
||||
const revealAll = (els: HTMLElement[]) => {
|
||||
els
|
||||
.map((el) => ({ el, r: el.getBoundingClientRect() }))
|
||||
.sort((a, b) => a.r.top - b.r.top || a.r.left - b.r.left)
|
||||
.forEach(({ el }, i) => {
|
||||
if (!pending.delete(el)) return;
|
||||
io.unobserve(el);
|
||||
const batchDelay = Math.min(i, 5) * REVEAL_BATCH_STEP;
|
||||
el.style.setProperty("--batch-delay", `${batchDelay}ms`);
|
||||
el.classList.add("is-visible");
|
||||
io.unobserve(el);
|
||||
// Once settled, drop the reveal classes so the element's own hover transitions return.
|
||||
const own = parseFloat(el.style.getPropertyValue("--reveal-delay")) || 0;
|
||||
const stagger = el.dataset.reveal === "stagger" ? el.children.length * 80 : 0;
|
||||
@@ -488,9 +486,29 @@ function useScrollReveal() {
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) =>
|
||||
revealAll(entries.filter((e) => e.isIntersecting).map((e) => e.target as HTMLElement)),
|
||||
{ rootMargin: "0px 0px -10% 0px" },
|
||||
);
|
||||
|
||||
// A fast fling, an anchor jump or the End key can carry an element past the screen between
|
||||
// two frames, so the observer never sees it on screen and it would stay hidden. Catch those:
|
||||
// anything whose top is already above the reveal line is revealed straight away.
|
||||
let frame = 0;
|
||||
const sweep = () => {
|
||||
frame = 0;
|
||||
const line = window.innerHeight * 0.9;
|
||||
const passed = [...pending].filter((el) => el.getBoundingClientRect().top < line);
|
||||
if (passed.length) revealAll(passed);
|
||||
if (!pending.size) window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = requestAnimationFrame(sweep);
|
||||
};
|
||||
|
||||
for (const el of document.querySelectorAll<HTMLElement>("[data-reveal]")) {
|
||||
// Leave anything already on screen alone so it never flickers after hydration.
|
||||
if (el.getBoundingClientRect().top < window.innerHeight) continue;
|
||||
@@ -500,10 +518,14 @@ function useScrollReveal() {
|
||||
);
|
||||
}
|
||||
el.classList.add("reveal-pending");
|
||||
pending.add(el);
|
||||
io.observe(el);
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
io.disconnect();
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(frame);
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
@@ -692,19 +714,18 @@ function Nav({ page }: { page: SitePageName }) {
|
||||
const isCurrent = (href: string) => href === `/${page}`;
|
||||
return (
|
||||
<>
|
||||
{/* Loyaly-style header: transparent over the hero with a dark nav capsule; once scrolled
|
||||
it becomes a floating cream island with rounded lower corners. */}
|
||||
<header
|
||||
className={`vt-header fixed inset-x-0 top-0 z-50 transition-[transform,background-color,border-color] duration-500 focus-within:translate-y-0 motion-reduce:transition-none ${EASE_STAGE} ${
|
||||
className={`vt-header fixed inset-x-0 top-0 z-50 transition-transform duration-500 focus-within:translate-y-0 motion-reduce:transition-none ${EASE_STAGE} ${
|
||||
hidden ? "-translate-y-full" : "translate-y-0"
|
||||
} ${
|
||||
solid
|
||||
? "border-b border-border bg-background/85 backdrop-blur-md"
|
||||
: "border-b border-transparent bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className={`header-island relative overflow-hidden ${solid ? "is-solid" : ""}`}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={`scroll-progress absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-primary/40 via-primary to-primary/40 transition-opacity duration-300 ${
|
||||
open ? "opacity-0" : ""
|
||||
className={`scroll-progress absolute inset-x-0 bottom-0 h-0.5 bg-gradient-to-r from-primary/40 via-primary to-primary/40 transition-opacity duration-300 ${
|
||||
solid ? "" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<div className="container-x flex h-18 items-center justify-between gap-4">
|
||||
@@ -725,9 +746,7 @@ function Nav({ page }: { page: SitePageName }) {
|
||||
</a>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={`hidden items-center gap-1 rounded-full border border-white/10 p-1.5 transition-colors duration-300 lg:flex ${
|
||||
solid ? "bg-white/[0.03]" : "bg-white/5 backdrop-blur-md"
|
||||
}`}
|
||||
className="nav-capsule hidden items-center gap-1 rounded-full p-1.5 lg:flex"
|
||||
>
|
||||
{NAV.map((n) => {
|
||||
const current = isCurrent(n.href);
|
||||
@@ -737,8 +756,10 @@ function Nav({ page }: { page: SitePageName }) {
|
||||
href={n.href}
|
||||
data-scramble
|
||||
aria-current={current ? "true" : undefined}
|
||||
className={`rounded-full px-3 py-2 font-display text-xs font-semibold uppercase tracking-wider transition-colors duration-300 hover:bg-white/10 hover:text-foreground xl:px-4 ${
|
||||
current ? "bg-primary/15 text-primary" : "text-muted-foreground"
|
||||
className={`rounded-full px-3 py-2 font-display text-xs font-semibold uppercase tracking-wider transition-colors duration-300 xl:px-4 ${
|
||||
current
|
||||
? "bg-white/10 text-primary"
|
||||
: "text-white/70 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
@@ -749,7 +770,9 @@ function Nav({ page }: { page: SitePageName }) {
|
||||
<a
|
||||
href="/investors"
|
||||
data-magnetic
|
||||
className="press hidden items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-display text-xs font-semibold uppercase tracking-wider text-primary-foreground hover:brightness-110 lg:inline-flex"
|
||||
className={`press header-cta hidden items-center gap-2 rounded-full px-5 py-2.5 font-display text-xs font-semibold uppercase tracking-wider lg:inline-flex ${
|
||||
solid ? "is-outline" : ""
|
||||
}`}
|
||||
>
|
||||
Invest with us
|
||||
</a>
|
||||
@@ -762,8 +785,8 @@ function Nav({ page }: { page: SitePageName }) {
|
||||
aria-expanded={open}
|
||||
aria-controls="site-menu"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
className={`press group flex h-11 items-center gap-3 rounded-full border pl-4 pr-3 text-foreground transition-colors duration-300 lg:hidden ${
|
||||
open ? "border-primary/50 bg-primary/10" : "border-border bg-ink/40 backdrop-blur-md"
|
||||
className={`press nav-capsule group flex h-11 items-center gap-3 rounded-full pl-4 pr-3 text-white transition-colors duration-300 lg:hidden ${
|
||||
open ? "ring-1 ring-primary/60" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="hidden font-display text-xs font-semibold uppercase tracking-[0.16em] sm:block">
|
||||
@@ -783,6 +806,7 @@ function Nav({ page }: { page: SitePageName }) {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Full-screen menu for phones and tablets: a circle that opens from the menu button. */}
|
||||
@@ -968,19 +992,19 @@ const PILLARS = [
|
||||
{
|
||||
t: "Venture Studio",
|
||||
icon: Building2,
|
||||
img: imgFoundry,
|
||||
img: imgStudioTeam,
|
||||
d: "Companies are systematically created, validated and scaled on shared infrastructure, not simply funded after formation.",
|
||||
},
|
||||
{
|
||||
t: "Holding Company",
|
||||
icon: Layers,
|
||||
img: imgTower,
|
||||
img: imgHoldingHq,
|
||||
d: "Every venture strengthens the ecosystem while remaining capable of becoming an independent, high-growth business.",
|
||||
},
|
||||
{
|
||||
t: "Built to Compound",
|
||||
icon: TrendingUp,
|
||||
img: imgSpiral,
|
||||
img: imgCompoundGrowth,
|
||||
d: "Build faster, reduce risk, and maximize long-term value with one shared engine behind many companies.",
|
||||
},
|
||||
];
|
||||
@@ -1703,8 +1727,14 @@ const PORTFOLIO_STEP_SVH = 70;
|
||||
const DECK_FLY_START = 0.5;
|
||||
const DECK_PEEK_PX = 22;
|
||||
|
||||
/** One vertical as a deck card: photo side and details side. */
|
||||
function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
|
||||
// Portfolio cards show only the top problems and solutions of each vertical, to stay scannable.
|
||||
const CARD_POINTS = 3;
|
||||
|
||||
/**
|
||||
* One vertical as a deck card: photo side and details side. Memoised: the deck re-renders on
|
||||
* every card change, and rebuilding all seven cards each time stalled the scroll.
|
||||
*/
|
||||
const VerticalCard = memo(function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
|
||||
const Icon = v.icon;
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[minmax(130px,28%)_1fr] overflow-hidden rounded-[2rem] border border-white/10 bg-card shadow-[0_40px_80px_-40px_oklch(0.05_0.01_60/0.9)] md:grid-cols-2 md:grid-rows-1">
|
||||
@@ -1735,13 +1765,13 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-1 md:gap-6 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2.5 text-sm font-medium text-muted-foreground">The problem</div>
|
||||
<ul className="space-y-1.5">
|
||||
{v.problem.map((p) => (
|
||||
<ul className="space-y-2">
|
||||
{v.problem.slice(0, CARD_POINTS).map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex gap-2 text-[13px] leading-snug text-muted-foreground md:text-sm"
|
||||
className="flex gap-2 text-[13px] leading-snug text-muted-foreground min-[360px]:text-sm md:text-base"
|
||||
>
|
||||
<span className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-muted-foreground/60" />
|
||||
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-muted-foreground/60" />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
@@ -1749,11 +1779,11 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2.5 text-sm font-medium text-primary">Our solution</div>
|
||||
<ul className="space-y-1.5">
|
||||
{v.solution.map((p) => (
|
||||
<ul className="space-y-2">
|
||||
{v.solution.slice(0, CARD_POINTS).map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex gap-2 text-[13px] leading-snug text-foreground md:text-sm"
|
||||
className="flex gap-2 text-[13px] leading-snug text-foreground min-[360px]:text-sm md:text-base"
|
||||
>
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" aria-hidden />
|
||||
{p}
|
||||
@@ -1774,7 +1804,7 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Card deck. Pinned: the verticals sit stacked like a deck; scrolling flies the top card away
|
||||
@@ -2813,6 +2843,7 @@ function SitePage({ page, children }: { page: SitePageName; children: ReactNode
|
||||
useScrollReveal();
|
||||
useSmoothScroll();
|
||||
useScramble();
|
||||
usePauseOffscreen();
|
||||
|
||||
// Arriving from another page with a hash (e.g. /#framework): the router restores scroll to
|
||||
// the top after hydration, so jump to the requested section once it has settled.
|
||||
@@ -2847,6 +2878,7 @@ function SitePage({ page, children }: { page: SitePageName; children: ReactNode
|
||||
export {
|
||||
NAV,
|
||||
DuoPhoto,
|
||||
usePinnedLayout,
|
||||
VERTICALS,
|
||||
FRAMEWORK,
|
||||
STACK,
|
||||
|
||||
472
src/components/site/venture-stories.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Boxes,
|
||||
Brain,
|
||||
Eye,
|
||||
Gamepad2,
|
||||
Gift,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Pill,
|
||||
Plane,
|
||||
ScanLine,
|
||||
Snowflake,
|
||||
Store,
|
||||
Tag,
|
||||
Truck,
|
||||
Users,
|
||||
Warehouse,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Accent, SectionHeader } from "@/components/site/sections";
|
||||
|
||||
// Short venture stories, summarised from each venture's own website (doormile.com,
|
||||
// loyaly.ai). Plans are worded as plans; marketing outcome figures are left out.
|
||||
|
||||
type Item = { title: string; body: string; icon: LucideIcon };
|
||||
|
||||
/** Cards that stack on top of one another as you scroll (.layer-stack). */
|
||||
function StackedCards({ items }: { items: Item[] }) {
|
||||
return (
|
||||
<ul className="layer-stack mt-12 flex flex-col gap-5 pb-[10vh]">
|
||||
{items.map((it, i) => {
|
||||
const Icon = it.icon;
|
||||
return (
|
||||
<li
|
||||
key={it.title}
|
||||
style={{ "--i": i } as CSSProperties}
|
||||
className="theme-dark group relative flex min-h-[11rem] flex-col justify-between gap-5 overflow-hidden rounded-3xl border border-white/10 bg-background p-7 text-foreground shadow-[0_-24px_60px_-30px_oklch(0.05_0.01_60/0.8)] md:flex-row md:items-center md:p-10"
|
||||
>
|
||||
<div className="glow absolute -right-16 -top-16 h-56 w-56 opacity-40 transition-opacity duration-500 group-hover:opacity-90" />
|
||||
<div className="relative flex items-center gap-5">
|
||||
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-primary text-primary-foreground">
|
||||
<Icon className="h-6 w-6" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
0{i + 1}
|
||||
</div>
|
||||
<div className="mt-1 font-display text-3xl font-bold tracking-tight md:text-4xl">
|
||||
{it.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="relative text-lg leading-relaxed text-muted-foreground md:max-w-md md:text-right">
|
||||
{it.body}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Steps in a row that light up one after another as they cross the screen (.chain-step). */
|
||||
function StepRow({ items }: { items: Item[] }) {
|
||||
return (
|
||||
<ol className="mt-12 grid gap-3 sm:grid-cols-2 lg:grid-flow-col lg:auto-cols-fr lg:grid-cols-none">
|
||||
{items.map((it, i) => {
|
||||
const Icon = it.icon;
|
||||
return (
|
||||
<li
|
||||
key={it.title}
|
||||
style={{ "--i": i } as CSSProperties}
|
||||
className="chain-step relative rounded-3xl border border-border bg-card p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-xl bg-primary text-primary-foreground">
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<span className="font-display text-sm font-semibold tabular-nums text-primary">
|
||||
0{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-6 font-display text-xl font-bold">{it.title}</div>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{it.body}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** A vertical timeline: a stem grows down it and each node lights as it passes. */
|
||||
function Timeline({ items }: { items: { label: string; title: string; body: string }[] }) {
|
||||
return (
|
||||
<ol className="relative mt-12 pl-10 md:pl-16">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-[11px] top-0 w-px bg-white/10 md:left-[19px]"
|
||||
>
|
||||
<span className="stem-grow block h-full w-full bg-gradient-to-b from-primary via-primary to-primary/30" />
|
||||
</span>
|
||||
{items.map((it) => (
|
||||
<li
|
||||
key={it.label}
|
||||
className="relative grid gap-2 border-t border-white/10 py-8 md:grid-cols-12 md:gap-8 md:py-10"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="stem-node absolute -left-10 top-9 h-6 w-6 rounded-full border-2 border-primary bg-primary md:-left-16 md:top-11 md:h-10 md:w-10"
|
||||
/>
|
||||
<div className="font-display text-sm font-semibold uppercase tracking-[0.16em] text-primary md:col-span-3">
|
||||
{it.label}
|
||||
</div>
|
||||
<div className="md:col-span-9">
|
||||
<div className="font-display text-2xl font-bold md:text-3xl">{it.title}</div>
|
||||
<p className="mt-2 text-lg leading-relaxed text-muted-foreground">{it.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Light (cream) or dark section wrapper. */
|
||||
function Band({ light, children }: { light?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
light
|
||||
? "sheet-enter theme-light section-y bg-background text-foreground"
|
||||
: "section-y relative overflow-hidden bg-ink"
|
||||
}
|
||||
>
|
||||
<div className="container-x relative">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// DoorMile Wings (from doormile.com/doormile-wings)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function DoorMileStory() {
|
||||
return (
|
||||
<>
|
||||
<Band light>
|
||||
<SectionHeader
|
||||
eyebrow="What is DoorMile Wings"
|
||||
icon={Plane}
|
||||
title={
|
||||
<>
|
||||
Where quick commerce meets <Accent>quick logistics.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
DoorMile Wings is a dedicated domestic air-cargo network: same-day logistics between
|
||||
India's major regional capitals, with AI coordinating every handoff from runway to
|
||||
doorstep.
|
||||
</p>
|
||||
</SectionHeader>
|
||||
<StackedCards
|
||||
items={[
|
||||
{
|
||||
title: "Same-day air",
|
||||
body: "A maximum of three hours door to door between major regional capitals.",
|
||||
icon: Plane,
|
||||
},
|
||||
{
|
||||
title: "MileTruth™ AI",
|
||||
body: "Forecasts demand and optimises every route and load in real time.",
|
||||
icon: Brain,
|
||||
},
|
||||
{
|
||||
title: "Dedicated freighters",
|
||||
body: "Scheduled cargo capacity, not passenger-belly space competing with baggage.",
|
||||
icon: Boxes,
|
||||
},
|
||||
{
|
||||
title: "EV last mile",
|
||||
body: "Electric vehicles complete every first- and final-mile leg.",
|
||||
icon: Zap,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Band>
|
||||
|
||||
<Band>
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
Pickup to arrival, <Accent>one connected flow.</Accent>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StepRow
|
||||
items={[
|
||||
{
|
||||
title: "Local pickup",
|
||||
body: "On-demand first mile by electric vehicle.",
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
title: "Airport clearance",
|
||||
body: "Automated screening and rapid cross-docking at the hub.",
|
||||
icon: ScanLine,
|
||||
},
|
||||
{
|
||||
title: "Air transit",
|
||||
body: "Regional cargo flights bypass surface congestion.",
|
||||
icon: Plane,
|
||||
},
|
||||
{
|
||||
title: "Destination hub",
|
||||
body: "Immediate de-consolidation and screening on arrival.",
|
||||
icon: Warehouse,
|
||||
},
|
||||
{ title: "EV last mile", body: "Zero-emission delivery to the door.", icon: Zap },
|
||||
]}
|
||||
/>
|
||||
</Band>
|
||||
|
||||
<Band light>
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
Built for shipments that <Accent>can't wait.</Accent>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ul className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[
|
||||
{ t: "Quick commerce", d: "Same-day air capacity from metros to dark stores.", i: Zap },
|
||||
{ t: "Express B2B cargo", d: "Time-critical parts, airport to airport.", i: Package },
|
||||
{ t: "Healthcare & pharma", d: "Cold-chain packaging with chain of custody.", i: Pill },
|
||||
{ t: "Cold chain", d: "Perishables tracked at airline speed.", i: Snowflake },
|
||||
{
|
||||
t: "High-value cargo",
|
||||
d: "Secure handling with electronic custody.",
|
||||
i: PackageCheck,
|
||||
},
|
||||
{
|
||||
t: "E-commerce",
|
||||
d: "Same- and next-day reach into tier-1 and tier-2 cities.",
|
||||
i: Store,
|
||||
},
|
||||
].map(({ t, d, i: Icon }) => (
|
||||
<li
|
||||
key={t}
|
||||
className="tilt-in flex items-start gap-4 rounded-3xl border border-border bg-card p-6"
|
||||
>
|
||||
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-primary/15 text-primary">
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-display text-lg font-bold">{t}</span>
|
||||
<span className="mt-1 block text-sm leading-relaxed text-muted-foreground">
|
||||
{d}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Band>
|
||||
|
||||
<Band>
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
A ten-year path to <Accent>national scale.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>The roadmap DoorMile has set out for Wings.</p>
|
||||
</SectionHeader>
|
||||
<Timeline
|
||||
items={[
|
||||
{
|
||||
label: "Year 1",
|
||||
title: "Launch",
|
||||
body: "Clearance hubs and cargo services across 5–6 key commercial cities.",
|
||||
},
|
||||
{
|
||||
label: "Year 2",
|
||||
title: "Network growth",
|
||||
body: "Serving 15 regional and secondary cargo airports.",
|
||||
},
|
||||
{
|
||||
label: "Year 3",
|
||||
title: "Cross-border",
|
||||
body: "International regional routes across South Asian freight corridors.",
|
||||
},
|
||||
{
|
||||
label: "Year 5",
|
||||
title: "Scale",
|
||||
body: "A fleet of 25 leased cargo aircraft serving 50+ regional airports.",
|
||||
},
|
||||
{
|
||||
label: "Year 10",
|
||||
title: "Global",
|
||||
body: "The asset-light, AI-powered model replicated across Asia, Africa and the Middle East.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Loyaly.ai (from loyaly.ai)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function LoyalyStory() {
|
||||
return (
|
||||
<>
|
||||
<Band light>
|
||||
<div className="grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||||
<div className="lg:col-span-5">
|
||||
<SectionHeader
|
||||
eyebrow="The problem"
|
||||
icon={Eye}
|
||||
title={
|
||||
<>
|
||||
Retail is busy. <Accent>But blind.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Stores struggle to win new customers and keep existing ones without giving away
|
||||
margin on generic discounts.
|
||||
</p>
|
||||
</SectionHeader>
|
||||
<div className="mt-10 font-display text-7xl font-extrabold tracking-tighter text-primary md:text-8xl">
|
||||
68%+
|
||||
</div>
|
||||
<p className="mt-2 font-display text-lg font-semibold">
|
||||
of shoppers walk in once and never return.
|
||||
</p>
|
||||
</div>
|
||||
<ol className="lg:col-span-7">
|
||||
{[
|
||||
"No compelling reason to visit the store",
|
||||
"Discounts erode profit margins",
|
||||
"Digital ads rarely turn into footfall",
|
||||
"No real-time link between online and offline",
|
||||
"Customers forget brands between visits",
|
||||
].map((p, k) => (
|
||||
<li key={p} className="relative flex items-baseline gap-6 py-6">
|
||||
<span aria-hidden className="absolute inset-x-0 top-0 h-px bg-border" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="line-draw absolute inset-x-0 top-0 h-px bg-primary/60"
|
||||
/>
|
||||
<span className="w-10 shrink-0 font-display text-sm font-semibold tabular-nums text-primary">
|
||||
0{k + 1}
|
||||
</span>
|
||||
<span className="font-display text-2xl font-bold tracking-tight">{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</Band>
|
||||
|
||||
<Band>
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
Every visit, <Accent>a loyal customer.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Loyaly bridges digital engagement and offline shopping in one loop that resets with
|
||||
every visit.
|
||||
</p>
|
||||
</SectionHeader>
|
||||
<StepRow
|
||||
items={[
|
||||
{ title: "Understand", body: "See who walks in, when, and why they buy.", icon: Eye },
|
||||
{
|
||||
title: "Engage",
|
||||
body: "Daily streaks, challenges and mini games build a habit.",
|
||||
icon: Gamepad2,
|
||||
},
|
||||
{
|
||||
title: "Convert",
|
||||
body: "Shoppers play to unlock a deal, then buy on the spot.",
|
||||
icon: Tag,
|
||||
},
|
||||
{ title: "Retain", body: "Every redemption sets up the next visit.", icon: Users },
|
||||
]}
|
||||
/>
|
||||
</Band>
|
||||
|
||||
<Band light>
|
||||
<SectionHeader
|
||||
eyebrow="The platform"
|
||||
icon={Brain}
|
||||
title={
|
||||
<>
|
||||
Four agents. <Accent>One AI.</Accent>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StackedCards
|
||||
items={[
|
||||
{
|
||||
title: "Behavision",
|
||||
body: "Footfall analytics: how customers move, dwell and engage in store.",
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
title: "SpendSense",
|
||||
body: "Spending intelligence: high-value segments and buying behaviour.",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
title: "Dyscount",
|
||||
body: "Smart discounts: the right offer, to the right shopper, without killing margin.",
|
||||
icon: Tag,
|
||||
},
|
||||
{
|
||||
title: "Identiq",
|
||||
body: "Consumer loyalty: earn LYTs online, redeem them in real stores.",
|
||||
icon: Gift,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Band>
|
||||
|
||||
<Band>
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
For shoppers: <Accent>earn as you live.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>Through the LytsUp app, shoppers earn LYTs online and spend them in store.</p>
|
||||
</SectionHeader>
|
||||
<Timeline
|
||||
items={[
|
||||
{
|
||||
label: "Step 1",
|
||||
title: "Earn online",
|
||||
body: "Daily logins, step challenges and games like Maze and Spin Wheel earn LYTs and surprise coupons.",
|
||||
},
|
||||
{
|
||||
label: "Step 2",
|
||||
title: "Unlock deals",
|
||||
body: "Merchants run LYT campaigns; shoppers play once to reveal their reward.",
|
||||
},
|
||||
{
|
||||
label: "Step 3",
|
||||
title: "Discover and visit",
|
||||
body: "Find participating stores nearby, then walk in and shop in person.",
|
||||
},
|
||||
{
|
||||
label: "Step 4",
|
||||
title: "Redeem and repeat",
|
||||
body: "Use LYTs and coupons at checkout, then come back tomorrow to earn more.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Route as ContactRouteImport } from './routes/contact'
|
||||
import { Route as AiRouteImport } from './routes/ai'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as VenturesSlugRouteImport } from './routes/ventures/$slug'
|
||||
|
||||
const StudioRoute = StudioRouteImport.update({
|
||||
id: '/studio',
|
||||
@@ -64,6 +65,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const VenturesSlugRoute = VenturesSlugRouteImport.update({
|
||||
id: '/ventures/$slug',
|
||||
path: '/ventures/$slug',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
@@ -75,6 +81,7 @@ export interface FileRoutesByFullPath {
|
||||
'/portfolio': typeof PortfolioRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/studio': typeof StudioRoute
|
||||
'/ventures/$slug': typeof VenturesSlugRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -86,6 +93,7 @@ export interface FileRoutesByTo {
|
||||
'/portfolio': typeof PortfolioRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/studio': typeof StudioRoute
|
||||
'/ventures/$slug': typeof VenturesSlugRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -98,6 +106,7 @@ export interface FileRoutesById {
|
||||
'/portfolio': typeof PortfolioRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/studio': typeof StudioRoute
|
||||
'/ventures/$slug': typeof VenturesSlugRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -111,6 +120,7 @@ export interface FileRouteTypes {
|
||||
| '/portfolio'
|
||||
| '/sitemap.xml'
|
||||
| '/studio'
|
||||
| '/ventures/$slug'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -122,6 +132,7 @@ export interface FileRouteTypes {
|
||||
| '/portfolio'
|
||||
| '/sitemap.xml'
|
||||
| '/studio'
|
||||
| '/ventures/$slug'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -133,6 +144,7 @@ export interface FileRouteTypes {
|
||||
| '/portfolio'
|
||||
| '/sitemap.xml'
|
||||
| '/studio'
|
||||
| '/ventures/$slug'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -145,6 +157,7 @@ export interface RootRouteChildren {
|
||||
PortfolioRoute: typeof PortfolioRoute
|
||||
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
|
||||
StudioRoute: typeof StudioRoute
|
||||
VenturesSlugRoute: typeof VenturesSlugRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -212,6 +225,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/ventures/$slug': {
|
||||
id: '/ventures/$slug'
|
||||
path: '/ventures/$slug'
|
||||
fullPath: '/ventures/$slug'
|
||||
preLoaderRoute: typeof VenturesSlugRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +245,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
PortfolioRoute: PortfolioRoute,
|
||||
SitemapDotxmlRoute: SitemapDotxmlRoute,
|
||||
StudioRoute: StudioRoute,
|
||||
VenturesSlugRoute: VenturesSlugRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type {} from "@tanstack/react-start";
|
||||
import { VENTURES } from "@/components/site/pages";
|
||||
|
||||
const BASE_URL = "";
|
||||
|
||||
@@ -12,6 +13,11 @@ export const Route = createFileRoute("/sitemap.xml")({
|
||||
...["/studio", "/framework", "/portfolio", "/ai", "/investors", "/about", "/contact"].map(
|
||||
(path) => ({ path, changefreq: "monthly", priority: "0.8" }),
|
||||
),
|
||||
...VENTURES.map((v) => ({
|
||||
path: `/ventures/${v.slug}`,
|
||||
changefreq: "monthly",
|
||||
priority: "0.7",
|
||||
})),
|
||||
];
|
||||
const urls = entries.map(
|
||||
(e) =>
|
||||
|
||||
30
src/routes/ventures/$slug.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { SitePage } from "@/components/site/sections";
|
||||
import { VentureDetail, findVenture } from "@/components/site/pages";
|
||||
|
||||
export const Route = createFileRoute("/ventures/$slug")({
|
||||
loader: ({ params }) => {
|
||||
const v = findVenture(params.slug);
|
||||
if (!v) throw notFound();
|
||||
return { name: v.name, pitch: v.pitch };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: loaderData
|
||||
? [
|
||||
{ title: `${loaderData.name} | KapFoundry` },
|
||||
{ name: "description", content: loaderData.pitch },
|
||||
{ property: "og:title", content: `${loaderData.name}, a KapFoundry venture` },
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
component: VenturePage,
|
||||
});
|
||||
|
||||
function VenturePage() {
|
||||
const { slug } = Route.useParams();
|
||||
return (
|
||||
<SitePage page="portfolio">
|
||||
<VentureDetail slug={slug} />
|
||||
</SitePage>
|
||||
);
|
||||
}
|
||||
188
src/styles.css
@@ -855,6 +855,194 @@ html.intro .intro-overlay {
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
Venture pages: scroll-driven motion that acts out each section. Browsers without scroll
|
||||
timelines, and reduced motion, simply show the finished state.
|
||||
------------------------------------------------------------------------------------------ */
|
||||
@keyframes venture-photo-card {
|
||||
to {
|
||||
clip-path: inset(10% 6% 16% 6% round 3rem);
|
||||
scale: 0.9;
|
||||
}
|
||||
}
|
||||
@keyframes venture-name-spread {
|
||||
to {
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
}
|
||||
@keyframes chain-lit {
|
||||
from {
|
||||
opacity: 0.2;
|
||||
transform: translate3d(0, 14px, 0) scale(0.92);
|
||||
filter: grayscale(1);
|
||||
}
|
||||
}
|
||||
@keyframes grow-y {
|
||||
from {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
}
|
||||
@keyframes node-lit {
|
||||
from {
|
||||
background-color: var(--background);
|
||||
border-color: color-mix(in oklab, var(--foreground) 20%, transparent);
|
||||
scale: 0.7;
|
||||
}
|
||||
to {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
@keyframes rise-staggered {
|
||||
from {
|
||||
transform: translate3d(0, calc(70px + var(--i, 0) * 55px), 0);
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
@keyframes tilt-in {
|
||||
from {
|
||||
transform: perspective(900px) rotateX(32deg) translate3d(0, 50px, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Layer cards stack on top of one another as the list scrolls (pure position: sticky). */
|
||||
.layer-stack > li {
|
||||
position: sticky;
|
||||
top: calc(5.5rem + var(--i, 0) * 1.25rem);
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.layer-stack > li {
|
||||
top: calc(7rem + var(--i, 0) * 1.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* Hero: the photo pulls back into a card and the name spreads apart as you leave. */
|
||||
.venture-photo {
|
||||
animation: venture-photo-card linear both;
|
||||
animation-timeline: --hero;
|
||||
animation-range: exit 0% exit 75%;
|
||||
}
|
||||
.venture-name {
|
||||
animation: venture-name-spread linear both;
|
||||
animation-timeline: --hero;
|
||||
animation-range: exit 0% exit 55%;
|
||||
}
|
||||
/* Farm → Food → Consumer → Regeneration: each step lights up in turn. */
|
||||
.chain-step {
|
||||
animation: chain-lit linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry calc(30% + var(--i, 0) * 12%) entry calc(60% + var(--i, 0) * 12%);
|
||||
}
|
||||
/* How it works: a stem grows down the steps, tracking the middle of the screen. */
|
||||
.stem-grow {
|
||||
transform-origin: top;
|
||||
animation: grow-y linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 50vh cover calc(100% - 50vh);
|
||||
}
|
||||
.stem-node {
|
||||
animation: node-lit linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 46% cover 50%;
|
||||
}
|
||||
/* Revenue columns rise at different speeds, like bars growing. */
|
||||
.rise-staggered {
|
||||
animation: rise-staggered linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% cover 45%;
|
||||
}
|
||||
/* Cards tilt up into place from a 3D angle. */
|
||||
.tilt-in {
|
||||
transform-origin: 50% 100%;
|
||||
animation: tilt-in linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% entry 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Looping decoration pauses while its section is off screen (data-offscreen, set from JS). */
|
||||
[data-offscreen]
|
||||
:is(
|
||||
.column-up,
|
||||
.column-down,
|
||||
.spin-slow,
|
||||
.spin-slower,
|
||||
.upright,
|
||||
.signal,
|
||||
.node-pulse,
|
||||
.dot-wave,
|
||||
.orbit-comet,
|
||||
.scroll-cue,
|
||||
.nudge-x,
|
||||
.nudge-y,
|
||||
.animate-ping,
|
||||
.animate-pulse
|
||||
) {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
Header (Loyaly-style). Over the hero it is transparent; once scrolled it becomes a floating
|
||||
cream island with rounded lower corners. The nav sits in a dark capsule either way.
|
||||
------------------------------------------------------------------------------------------ */
|
||||
.header-island {
|
||||
margin-inline: 0;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
transition:
|
||||
margin 0.5s var(--ease-out-expo),
|
||||
border-radius 0.5s var(--ease-out-expo),
|
||||
background-color 0.4s ease,
|
||||
box-shadow 0.4s ease;
|
||||
}
|
||||
.header-island.is-solid {
|
||||
margin-inline: 0.5rem;
|
||||
border-radius: 0 0 1.5rem 1.5rem;
|
||||
background-color: oklch(0.975 0.012 85 / 0.97);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 14px 40px -20px oklch(0.05 0.01 60 / 0.55);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.header-island.is-solid {
|
||||
margin-inline: 1rem;
|
||||
}
|
||||
}
|
||||
.nav-capsule {
|
||||
background-color: oklch(0.13 0.008 60);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(1 0 0 / 0.08),
|
||||
0 8px 24px -12px oklch(0.05 0.01 60 / 0.8);
|
||||
}
|
||||
/* Call to action: a cream pill over the hero, a gold outline on the cream island. */
|
||||
.header-cta {
|
||||
background-color: oklch(0.975 0.012 85);
|
||||
color: oklch(0.17 0.01 60);
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
.header-cta:hover {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
.header-cta.is-outline {
|
||||
background-color: transparent;
|
||||
box-shadow: inset 0 0 0 1.5px oklch(0.62 0.12 68);
|
||||
}
|
||||
.header-cta.is-outline:hover {
|
||||
background-color: oklch(0.62 0.12 68);
|
||||
color: oklch(0.99 0.005 85);
|
||||
}
|
||||
|
||||
/* Magnetic buttons: JS sets `translate`; this eases them back when the pointer leaves. */
|
||||
[data-magnetic] {
|
||||
transition: translate 0.6s var(--ease-out-expo);
|
||||
|
||||