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
21 changes: 14 additions & 7 deletions apps/cards-web/src/app/(site)/p/[handle]/edit/edit-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface EditForm {
skills: AgentProfileCard["skills"];
calendly: string;
gcal: string;
/** Visitors pitch before the booking links are shown. Saved in identity, like the edition. */
pitchToBook: boolean;
/** Limited-edition look; null is the classic card. */
edition: CardEdition | null;
}
Expand Down Expand Up @@ -79,6 +81,7 @@ export function formFromCard(card: AgentProfileCard): EditForm {
skills: card.skills,
calendly: card.calendly_url ?? "",
gcal: card.google_calendar_url ?? "",
pitchToBook: card.identity.pitch_to_book === true,
edition: readEdition(card.identity.edition),
};
}
Expand All @@ -88,15 +91,19 @@ export function formLinks(form: EditForm): Record<string, string> {
}

export function cardFromForm(base: AgentProfileCard, form: EditForm): AgentProfileCard {
const identity: AgentProfileCard["identity"] = {
...base.identity,
name: form.name, headline: form.headline, location: form.location,
links: formLinks(form),
// Classic cards that never had an edition keep sending the identity they always did.
...(form.edition || "edition" in base.identity ? { edition: form.edition } : {}),
pitch_to_book: true,
};
// Off is an absent key, the way cards-api stores it; sending `false` would read as a changed identity.
if (!form.pitchToBook) delete identity.pitch_to_book;
return {
...base,
identity: {
...base.identity,
name: form.name, headline: form.headline, location: form.location,
links: formLinks(form),
// Classic cards that never had an edition keep sending the identity they always did.
...(form.edition || "edition" in base.identity ? { edition: form.edition } : {}),
},
identity,
summary: form.summary,
availability: form.availability,
industries: form.industries,
Expand Down
4 changes: 4 additions & 0 deletions apps/cards-web/src/app/(site)/p/[handle]/edit/edit.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@
/* ── Fields ────────────────────────────────────────────────────────────── */
.pe-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.pe-grid .full { grid-column: 1 / -1; }
.pe-check { display: flex; align-items: flex-start; gap: 10px; margin-top: 16px; cursor: pointer; }
.pe-check input { width: 18px; height: 18px; margin: 1px 0 0; flex-shrink: 0; accent-color: var(--pe-accent); cursor: pointer; }
.pe-check-label { display: block; font-size: 14px; font-weight: 600; color: var(--pe-ink); }
.pe-check-hint { display: block; margin-top: 2px; font-size: 13px; color: var(--pe-soft); max-width: 62ch; }
.pe-label { font-size: 13px; font-weight: 600; color: var(--pe-soft); margin-bottom: 6px !important; }
.pe-input, .pe-select {
width: 100%; height: 44px; padding: 0 14px;
Expand Down
9 changes: 9 additions & 0 deletions apps/cards-web/src/app/(site)/p/[handle]/edit/profile-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ export function ProfileTab({ form, set, handle, token }: {
<input id="pe-gcal" className="pe-input" value={form.gcal} onChange={(e) => set("gcal", e.target.value)} placeholder="https://calendar.app.google/…" inputMode="url" />
</Field>
</div>
<label className="pe-check" htmlFor="pe-pitch">
<input id="pe-pitch" type="checkbox" checked={form.pitchToBook} onChange={(e) => set("pitchToBook", e.target.checked)} aria-describedby="pe-pitch-hint" />
<span>
<span className="pe-check-label">Ask visitors to pitch before booking</span>
<span className="pe-check-hint" id="pe-pitch-hint">
Your booking link stays hidden until a visitor&apos;s short note on why they want to meet passes your card&apos;s AI, which checks it against who you want to connect with, what you can help with and what you&apos;re working on.
</span>
</span>
</label>
</Card>
</>
);
Expand Down
19 changes: 16 additions & 3 deletions apps/cards-web/src/app/(site)/p/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { ProfileView } from "@/components/ProfileView";
import { logAgentFetch } from "@/lib/track";
import { parseSearchRef } from "@/lib/search-link";
import { ProfileChatWidget } from "@/components/ProfileChatWidget";
import { PitchToBookButton } from "@/components/PitchToBookButton";
import { FallbackAvatar } from "@/components/FallbackAvatar";
import { WorkExperienceCard } from "./work-experience-card";
import { HeroAskPanel } from "./hero-agent-bar";
Expand Down Expand Up @@ -502,6 +503,8 @@ export default async function PersonPage({ params, searchParams }: PageProps) {
{ label: "Calendly", url: safeUrl(card.calendly_url) },
{ label: "Google Calendar", url: safeUrl(card.google_calendar_url) },
].filter((l): l is { label: string; url: string } => !!l.url);
// The API withholds the links when this is set; the chat widget's pitch step is the only way to them.
const pitchToBook = identity.pitch_to_book === true;

const MEMORY_GROUPS: Record<string, { label: string; icon: string }> = {
is_founder_of: { label: "Founded", icon: "🏢" },
Expand Down Expand Up @@ -655,7 +658,7 @@ export default async function PersonPage({ params, searchParams }: PageProps) {
const xAvatarSrc = imgProxyUrl(safeUrl(card.x_stats?.avatar) ?? rawAvatar, ghAvatar, card.handle || handle);
const xImpressions = v.x.impressions != null && String(v.x.impressions).trim() !== "—" ? v.x.impressions : null;
const showMemory = claimed && memoryTotal > 0;
const showBooking = claimed && bookingLinks.length > 0;
const showBooking = claimed && (bookingLinks.length > 0 || pitchToBook);
const socialSlots = [showLinkedin, showX, showMemory, showBooking].filter(Boolean).length;

/* ── shared card style ─────────────────────────────────── */
Expand Down Expand Up @@ -1251,7 +1254,7 @@ export default async function PersonPage({ params, searchParams }: PageProps) {
>
<div className="pf-mono flex justify-between items-center" style={{ fontSize: "0.65rem", fontWeight: 700, letterSpacing: "0.08em", position: "relative", minHeight: 18 }}>
<span className="flex items-center gap-1.5"><Calendar size={12} /> BOOK A CALL</span>
<span style={{ background: "rgba(255,255,255,0.16)", border: "1px solid rgba(255,255,255,0.22)", borderRadius: 99, padding: "3px 8px", fontSize: "0.58rem" }}>OPEN</span>
<span style={{ background: "rgba(255,255,255,0.16)", border: "1px solid rgba(255,255,255,0.22)", borderRadius: 99, padding: "3px 8px", fontSize: "0.58rem" }}>{pitchToBook ? "PITCH FIRST" : "OPEN"}</span>
</div>

<div className="pf-social-id" style={{ position: "relative" }}>
Expand All @@ -1275,11 +1278,20 @@ export default async function PersonPage({ params, searchParams }: PageProps) {
<span className="pf-mono" style={{ background: "rgba(255,255,255,0.12)", borderRadius: 8, padding: "5px 8px", fontSize: "0.6rem", fontWeight: 700 }}>1:1</span>
</div>

<BookingAvailability url={bookingLinks[0].url} name={firstName} />
{bookingLinks[0] && <BookingAvailability url={bookingLinks[0].url} name={firstName} />}
</div>

{/* One link: the usual CTA. Both: one button per provider. */}
<div style={{ display: "flex", gap: 8, marginTop: "auto" }}>
{pitchToBook && (
<PitchToBookButton
className="pf-book-cta pf-mono pf-social-cta"
style={{ flex: 1, gap: 8, background: "#fff", color: "#1e3a8a", cursor: "pointer", letterSpacing: "0.04em", border: "none" }}
>
Pitch to book a call
<span aria-hidden>→</span>
</PitchToBookButton>
)}
{bookingLinks.map((l) => (
<a
key={l.label}
Expand Down Expand Up @@ -1533,6 +1545,7 @@ export default async function PersonPage({ params, searchParams }: PageProps) {
personName={identity.name}
prompts={chatPrompts}
bookingLinks={bookingLinks}
pitchToBook={pitchToBook}
showCreateCta={!isOwner && !viewerHasCard}
/>
)}
Expand Down
46 changes: 46 additions & 0 deletions apps/cards-web/src/app/api/chat/pitch/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";

const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://api.zynd.ai";
// Only allow slug-safe handles — prevents path traversal / URL injection
const HANDLE_RE = /^[a-zA-Z0-9_-]{1,80}$/;

/** Visitor-facing messages for the statuses cards-api answers a pitch with; anything else is "Upstream error". */
const STATUS_MESSAGES: Record<number, string> = {
403: "This card has no assistant",
404: "Card not found",
409: "This card doesn't ask for a pitch",
503: "Chatbot unavailable",
};

export async function POST(req: NextRequest) {
try {
const { handle, pitch } = await req.json();

if (!handle || !HANDLE_RE.test(handle) || typeof pitch !== "string") {
return NextResponse.json({ error: "handle and pitch required" }, { status: 400 });
}

const res = await fetch(`${API_BASE}/v1/chat/${handle}/pitch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-forwarded-for": req.headers.get("x-forwarded-for") ?? "",
},
body: JSON.stringify({ pitch }),
cache: "no-store",
});

if (res.ok) return NextResponse.json(await res.json(), { headers: { "Cache-Control": "no-store" } });

// These details are cards-api's own visitor-facing copy (length rule, which limit, "try again"); pass them through.
if (res.status === 400 || res.status === 429 || res.status === 502) {
const body = (await res.json().catch(() => ({}))) as { detail?: unknown };
const error = typeof body.detail === "string" ? body.detail : "Couldn't check your pitch right now.";
return NextResponse.json({ error }, { status: res.status });
}
const status = res.status in STATUS_MESSAGES ? res.status : 502;
return NextResponse.json({ error: STATUS_MESSAGES[status] ?? "Upstream error" }, { status });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
15 changes: 15 additions & 0 deletions apps/cards-web/src/components/PitchToBookButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use client";

import type { CSSProperties, ReactNode } from "react";

/** Event ProfileChatWidget listens for to open on its pitch step. */
export const OPEN_PITCH_EVENT = "zynd:open-pitch";

/** The booking card's CTA when the owner asks for a pitch first: there is no link to show yet. */
export function PitchToBookButton({ className, style, children }: { className?: string; style?: CSSProperties; children: ReactNode }) {
return (
<button type="button" className={className} style={style} onClick={() => window.dispatchEvent(new Event(OPEN_PITCH_EVENT))}>
{children}
</button>
);
}
Loading