Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/cards-web/src/app/(landing)/hero-card-stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,10 @@ function ProfileCard({ profile }: { profile: Profile }) {
<span className="text-[#7d7d77] flex items-center gap-1.5">
<span className="text-[#7b72e9]">●</span> {profile.updated}
</span>
<span className="text-white font-medium flex items-center gap-1 text-xs">
📅 Book 20m Intro
{/* Dealt-away cards stay mounted at opacity 0 over the page, so a link here
would be an invisible click target: this stays a plain status line. */}
<span className="text-[#a0a09a] flex items-center gap-1">
📅 Open to 20m intros
</span>
</div>
</div>
Expand Down
41 changes: 15 additions & 26 deletions apps/cards-web/src/app/(landing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { useRouter } from "next/navigation";
import { useState, useRef } from "react";

const CARDS_API = process.env.NEXT_PUBLIC_API_URL ?? "https://api.zynd.ai";
// proxy.ts stamps ?ref= into the attribution cookie on every request it sees,
// prefetches included, so these links opt out of prefetch: otherwise merely
// scrolling past the demo would overwrite a visitor's real campaign ref.
const DEMO_CREATE_HREF = "/create?ref=landing_demo";

import { Typewriter } from "./typewriter";
import { AgentCardAuthBar } from "./auth-bar";
Expand All @@ -27,6 +31,8 @@ export default function AgentCardPage() {
const showMyCardCtas = signedIn && Boolean(myHandle);
const signedInNoCard = signedIn && !myHandle;
const myCardPublished = myCard?.status === "published";
// #ingestion only renders for signed-out visitors; signed-in ones get their card panel instead.
const createAnchor = signedIn ? "#your-card" : "#ingestion";

// Hand the pasted link off to /create rather than synthesizing here — the
// create page owns auth, validation and the job polling.
Expand Down Expand Up @@ -77,7 +83,7 @@ export default function AgentCardPage() {
</Link>
{/* Navigation links */}
<nav className="hidden md:flex items-center gap-7 text-xs font-mono text-[#8a8a84]">
<a className="hover:text-white transition-colors" href="#ingestion">Synthesize</a>
<a className="hover:text-white transition-colors" href={createAnchor}>Synthesize</a>
<a className="hover:text-white transition-colors" href="#paradigm">Shift</a>
<a className="hover:text-white transition-colors" href="#signals">Signals</a>
<a className="hover:text-white transition-colors" href="#discovery">Agent Query</a>
Expand Down Expand Up @@ -495,7 +501,7 @@ export default function AgentCardPage() {
</div>
<div className="pt-2 border-t border-white/[0.06] flex items-center justify-between">
<span className="text-[10px] font-mono text-[#7d7d77]">From GitHub</span>
<span className="text-xs font-mono text-[#7b72e9] hover:underline cursor-pointer">📅 Book 20m Intro →</span>
<Link className="text-xs font-mono text-[#7b72e9] hover:underline" href={DEMO_CREATE_HREF} prefetch={false} title="Create your card to book intros">📅 Book 20m Intro →</Link>
</div>
</div>
{/* Candidate 2 */}
Expand All @@ -513,15 +519,15 @@ export default function AgentCardPage() {
</div>
<div className="pt-2 border-t border-white/[0.06] flex items-center justify-between">
<span className="text-[10px] font-mono text-[#7d7d77]">From GitHub</span>
<span className="text-xs font-mono text-[#7b72e9] hover:underline cursor-pointer">📅 Book 20m Intro →</span>
<Link className="text-xs font-mono text-[#7b72e9] hover:underline" href={DEMO_CREATE_HREF} prefetch={false} title="Create your card to book intros">📅 Book 20m Intro →</Link>
</div>
</div>
</div>
</div>
{/* Transition caption */}
<div className="pt-3 border-t border-white/[0.08] flex items-center justify-between text-xs font-mono text-[#a0a09a]">
<span className="">Oh. Zynd isn&apos;t just your profile. <span className="text-white font-bold">It&apos;s a network.</span></span>
<a className="text-[#7b72e9] hover:underline" href="#network">View the Network Layer ↓</a>
<Link className="text-[#7b72e9] hover:underline" href="/directory">View the Network Layer →</Link>
</div>
</div>
</div>
Expand Down Expand Up @@ -639,7 +645,7 @@ export default function AgentCardPage() {
<h4 className="font-bold text-white text-base">Chandan Kumar</h4>
<div className="text-xs text-[#a0a09a]">Founding Engineer &amp; Distributed Systems</div>
</div>
<span className="text-xs font-mono bg-[#7b72e9] text-black font-semibold px-3 py-1 rounded-full cursor-pointer">Book Intro</span>
<Link className="text-xs font-mono bg-[#7b72e9] text-black font-semibold px-3 py-1 rounded-full" href={DEMO_CREATE_HREF} prefetch={false} title="Create your card to book intros">Book Intro</Link>
</div>
<div className="space-y-2 text-xs">
<div className="text-[#d7d7d1]">
Expand Down Expand Up @@ -766,7 +772,7 @@ export default function AgentCardPage() {
</div>
</section>
{/* ========================================================================= */}
{/* SECTION 10 — FOUNDING 25 (EARLY NETWORK FORMATION) */}
{/* SECTION 10 — FOUNDING NETWORK (EARLY NETWORK FORMATION) */}
{/* ========================================================================= */}
<section className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8" data-purpose="founding-network">
<div className="p-8 sm:p-12 lg:p-14 rounded-3xl bg-[#0e1010] border border-[#7b72e9]/40 space-y-8 text-center relative overflow-hidden shadow-[0_0_40px_rgba(123,114,233,0.08)]">
Expand All @@ -775,31 +781,15 @@ export default function AgentCardPage() {
</div>
<div className="space-y-4 max-w-2xl mx-auto">
<h2 className="font-display uppercase text-3xl sm:text-5xl font-normal text-white leading-tight">
We&apos;re building the first layer of the network with 25 people.
We&apos;re building the first layer of the network with people who ship.
</h2>
<p className="text-sm sm:text-base text-[#a0a09a] leading-relaxed">
We&apos;re not trying to create another directory with 10 million empty profiles. We&apos;re starting with people who are actually building.
</p>
</div>
{/* Scarcity indicator & Member initials */}
<div className="max-w-md mx-auto space-y-4">
<div className="p-4 rounded-xl bg-black/60 border border-white/10 flex items-center justify-between text-xs font-mono">
<span className="text-[#a0a09a]">Founding Network Status:</span>
<span className="text-[#7b72e9] font-bold">17 / 25 spots claimed</span>
</div>
{/* Micro avatars of claimed spots */}
<div className="flex items-center justify-center -space-x-2 pt-1">
<span className="w-8 h-8 rounded-full bg-zinc-800 border-2 border-[#080909] text-white flex items-center justify-center text-[10px] font-mono font-bold">CK</span>
<span className="w-8 h-8 rounded-full bg-zinc-800 border-2 border-[#080909] text-white flex items-center justify-center text-[10px] font-mono font-bold">AR</span>
<span className="w-8 h-8 rounded-full bg-zinc-800 border-2 border-[#080909] text-white flex items-center justify-center text-[10px] font-mono font-bold">KD</span>
<span className="w-8 h-8 rounded-full bg-zinc-800 border-2 border-[#080909] text-white flex items-center justify-center text-[10px] font-mono font-bold">SB</span>
<span className="w-8 h-8 rounded-full bg-zinc-800 border-2 border-[#080909] text-white flex items-center justify-center text-[10px] font-mono font-bold">LN</span>
<span className="w-8 h-8 rounded-full bg-[#1c2214] border-2 border-[#080909] text-[#7b72e9] flex items-center justify-center text-[10px] font-mono font-bold">+12</span>
</div>
</div>
<div>
<a className="inline-flex items-center gap-2 bg-[#7b72e9] hover:bg-[#a78bfa] text-black font-mono font-bold text-sm py-4 px-8 rounded-xl active:scale-95 transition-all shadow-[0_0_25px_rgba(123,114,233,0.35)]" href="#ingestion">
<span className="">Apply for Founding Network</span>
<a className="inline-flex items-center gap-2 bg-[#7b72e9] hover:bg-[#a78bfa] text-black font-mono font-bold text-sm py-4 px-8 rounded-xl active:scale-95 transition-all shadow-[0_0_25px_rgba(123,114,233,0.35)]" href={createAnchor}>
<span className="">Join the Founding Network</span>
<span className="text-base leading-none">→</span>
</a>
</div>
Expand Down Expand Up @@ -958,7 +948,6 @@ export default function AgentCardPage() {
</div>
{/* Footer navigation links */}
<div className="flex flex-wrap gap-7 text-xs font-mono text-[#8E8E88]">
<a className="hover:text-white transition-colors" href="#">Manifesto</a>
<Link className="hover:text-white transition-colors" href="/directory">Discovery Index</Link>
<Link className="hover:text-white transition-colors" href="/for-ai">Agent Specs</Link>
<a className="hover:text-white transition-colors" href="https://persona.zynd.ai/privacy">Privacy</a>
Expand Down
2 changes: 1 addition & 1 deletion apps/cards-web/src/app/(site)/home/agent-traffic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function AgentTraffic({ clients }: { clients: OwnerStatus["activity"]["ag
<header className="oh-agent-header">
<div className="oh-agent-heading">
<span className="oh-agent-emblem"><Sparkles size={19} aria-hidden="true" /></span>
<div><span className="oh-agent-eyebrow">PROFILE DISCOVERY</span><h3 id="agent-clients">Which agents visited</h3></div>
<div><span className="oh-agent-eyebrow">PROFILE READS</span><h3 id="agent-clients">Which agents fetched your card</h3></div>
</div>
<div className="oh-agent-window"><span>Latest 1,000</span><span>Last 7 days</span></div>
</header>
Expand Down
2 changes: 1 addition & 1 deletion apps/cards-web/src/app/(site)/home/discovery-analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function DiscoveryAnalytics({ activity, profile, visitors }: {
<header className="oh-discovery-header">
<div className="oh-discovery-title">
<span className="oh-discovery-emblem"><Zap size={16} aria-hidden="true" /></span>
<h2 id="discovery-heading">Who found you · Last 7 days</h2>
<h2 id="discovery-heading">Who read your card · Last 7 days</h2>
</div>
<span className={`oh-discovery-state${activity.available ? " is-active" : ""}`}>
<i aria-hidden="true" />{activity.available ? "Activity recorded" : "Activity unavailable"}
Expand Down
3 changes: 3 additions & 0 deletions apps/cards-web/src/app/(site)/p/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -623,12 +623,14 @@ export default async function PersonPage({ params, searchParams }: PageProps) {

let isOwner = false;
let isSignedIn = false;
let viewerHasCard = false;
try {
const supabase = await createClient();
const { data: { session } } = await supabase.auth.getSession();
if (session?.access_token && !visitorPreview) {
isSignedIn = true;
const myCard = await getMyCard(session.access_token);
viewerHasCard = !!myCard;
isOwner = !!(myCard && myCard.handle === handle);
}
} catch {
Expand Down Expand Up @@ -1531,6 +1533,7 @@ export default async function PersonPage({ params, searchParams }: PageProps) {
personName={identity.name}
prompts={chatPrompts}
bookingLinks={bookingLinks}
showCreateCta={!isOwner && !viewerHasCard}
/>
)}
</>
Expand Down
28 changes: 26 additions & 2 deletions apps/cards-web/src/components/ProfileChatWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import Link from "next/link";
import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { MessageCircle, X, ArrowUp } from "lucide-react";

export interface ChatMessage {
Expand All @@ -21,6 +22,8 @@ interface ProfileChatWidgetProps {
defaultOpen?: boolean;
prompts?: string[];
bookingLinks?: BookingLink[];
/** Offer "make your own" after the first answer; the page turns it off for owners and card holders. */
showCreateCta?: boolean;
}

const CONTACT_WORDS = ["contact", "email", "call", "meet", "book", "intro", "calendly", "linkedin"];
Expand Down Expand Up @@ -77,12 +80,15 @@ export function ProfileChatWidget({
defaultOpen = false,
prompts = [],
bookingLinks = [],
showCreateCta = false,
}: ProfileChatWidgetProps) {
const [open, setOpen] = useState(defaultOpen);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(() => initialMessages ?? []);
const [sending, setSending] = useState(false);
const [introReady, setIntroReady] = useState(false);
// Pinned under the first real answer so the CTA appears once per session, not after every reply.
const [createCtaAfter, setCreateCtaAfter] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const seededRef = useRef(false);
Expand Down Expand Up @@ -191,6 +197,8 @@ export function ProfileChatWidget({
copy[assistantIndex] = { role: "assistant", content: "No response — try again." };
return copy;
});
} else if (showCreateCta) {
setCreateCtaAfter((current) => current ?? assistantIndex);
}
} catch {
setMessages((prev) => {
Expand Down Expand Up @@ -310,7 +318,8 @@ export function ProfileChatWidget({
</div>
)}
{messages.map((m, i) => (
<div key={i} style={{ display: "flex", justifyContent: m.role === "user" ? "flex-end" : "flex-start" }}>
<Fragment key={i}>
<div style={{ display: "flex", justifyContent: m.role === "user" ? "flex-end" : "flex-start" }}>
<div style={{
maxWidth: "82%",
padding: "9px 13px",
Expand All @@ -334,6 +343,21 @@ export function ProfileChatWidget({
)}
</div>
</div>
{i === createCtaAfter && (
<div data-testid="ask-card-create-cta" style={{
alignSelf: "flex-start", maxWidth: "82%",
padding: "8px 12px", borderRadius: "12px",
background: "#fff", border: "1px dashed #E0E0D8",
fontSize: "12px", lineHeight: "1.45", color: "#56565d",
}}>
Want an AI that answers like this for you?{" "}
{/* prefetch off: proxy.ts records ?ref= on prefetches too, which would credit ask_card without a click. */}
<Link href="/create?ref=ask_card" prefetch={false} style={{ color: "#4f46e5", fontWeight: 600, textDecoration: "underline" }}>
Create your card
</Link>
</div>
)}
</Fragment>
))}
</div>

Expand Down
59 changes: 59 additions & 0 deletions apps/cards-web/tests/ask-card-cta.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test, type Page } from "@playwright/test";
import { signIn } from "./fixtures/sign-in";

// The fixture API refuses /v1/chat/claimed, so answers are stubbed at the browser.
async function stubChat(page: Page, reply: string | null) {
await page.route("**/api/chat/profile", (route) => reply === null
? route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "Chatbot unavailable" }) })
: route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `data: ${JSON.stringify({ response: reply })}\n\ndata: [DONE]\n\n`,
}));
}

async function ask(page: Page, question: string) {
const input = page.getByRole("textbox", { name: "Your question" });
await input.fill(question);
await input.press("Enter");
}

test.describe("ask-the-card create CTA", () => {
test("a visitor sees it once, after the first answer", async ({ page }) => {
await stubChat(page, "Alice is building agent tooling.");
await page.goto("/p/claimed");
await page.locator(".pf-chat-fab").click();

const cta = page.getByTestId("ask-card-create-cta");
await expect(cta).toHaveCount(0);

await ask(page, "What is Alice building?");
await expect(cta).toHaveCount(1);
await expect(cta.getByRole("link", { name: "Create your card" })).toHaveAttribute("href", "/create?ref=ask_card");

await ask(page, "Anything else?");
await expect(page.getByText("Alice is building agent tooling.")).toHaveCount(2);
await expect(cta).toHaveCount(1);
});

test("a failed answer does not show it", async ({ page }) => {
await stubChat(page, null);
await page.goto("/p/claimed");
await page.locator(".pf-chat-fab").click();

await ask(page, "What is Alice building?");
await expect(page.getByText("Chatbot unavailable")).toBeVisible();
await expect(page.getByTestId("ask-card-create-cta")).toHaveCount(0);
});

test("a signed-in viewer who already has a card does not see it", async ({ context, page }) => {
await signIn(context);
await stubChat(page, "Alice is building agent tooling.");
await page.goto("/p/claimed");
await page.locator(".pf-chat-fab").click();

await ask(page, "What is Alice building?");
await expect(page.getByText("Alice is building agent tooling.")).toBeVisible();
await expect(page.getByTestId("ask-card-create-cta")).toHaveCount(0);
});
});
4 changes: 2 additions & 2 deletions apps/cards-web/tests/owner-home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test.describe("S10 owner home", () => {

test("filters agent traffic and exports the visible real counts", async ({ page }) => {
await page.goto("/home");
const panel = page.getByRole("region", { name: "Which agents visited" });
const panel = page.getByRole("region", { name: "Which agents fetched your card" });
await expect(panel.locator(".oh-agent-row")).toHaveCount(4);
for (const provider of ["claude", "openai"]) {
const logo = panel.locator(`img[src="/assets/agents/${provider}.svg"]`);
Expand Down Expand Up @@ -76,7 +76,7 @@ test.describe("S10 owner home", () => {
).toBe("rgb(23, 32, 51)");
await expect(page.getByText("12", { exact: true })).toBeVisible();
await expect(page.locator(".oh-discovery-agent strong").filter({ hasText: /^7$/ })).toBeVisible();
const agentBreakdown = page.getByRole("region", { name: "Which agents visited" });
const agentBreakdown = page.getByRole("region", { name: "Which agents fetched your card" });
await expect(agentBreakdown.getByText("Claude-User", { exact: true })).toBeVisible();
await expect(agentBreakdown.getByText("GPTBot", { exact: true })).toBeVisible();
await expect(agentBreakdown.getByText("2 requests", { exact: true })).toBeVisible();
Expand Down
Loading