diff --git a/apps/cards-web/src/app/(site)/p/[handle]/edit/edit-form.ts b/apps/cards-web/src/app/(site)/p/[handle]/edit/edit-form.ts index 19577aae..3f6cc3c1 100644 --- a/apps/cards-web/src/app/(site)/p/[handle]/edit/edit-form.ts +++ b/apps/cards-web/src/app/(site)/p/[handle]/edit/edit-form.ts @@ -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; } @@ -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), }; } @@ -88,15 +91,19 @@ export function formLinks(form: EditForm): Record { } 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, diff --git a/apps/cards-web/src/app/(site)/p/[handle]/edit/edit.css b/apps/cards-web/src/app/(site)/p/[handle]/edit/edit.css index e2813dbf..df03c9a5 100644 --- a/apps/cards-web/src/app/(site)/p/[handle]/edit/edit.css +++ b/apps/cards-web/src/app/(site)/p/[handle]/edit/edit.css @@ -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; diff --git a/apps/cards-web/src/app/(site)/p/[handle]/edit/profile-tab.tsx b/apps/cards-web/src/app/(site)/p/[handle]/edit/profile-tab.tsx index b9d6376a..d04007a6 100644 --- a/apps/cards-web/src/app/(site)/p/[handle]/edit/profile-tab.tsx +++ b/apps/cards-web/src/app/(site)/p/[handle]/edit/profile-tab.tsx @@ -149,6 +149,15 @@ export function ProfileTab({ form, set, handle, token }: { set("gcal", e.target.value)} placeholder="https://calendar.app.google/…" inputMode="url" /> + ); diff --git a/apps/cards-web/src/app/(site)/p/[handle]/page.tsx b/apps/cards-web/src/app/(site)/p/[handle]/page.tsx index cd2ec54c..ed55e5db 100644 --- a/apps/cards-web/src/app/(site)/p/[handle]/page.tsx +++ b/apps/cards-web/src/app/(site)/p/[handle]/page.tsx @@ -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"; @@ -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 = { is_founder_of: { label: "Founded", icon: "🏢" }, @@ -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 ─────────────────────────────────── */ @@ -1251,7 +1254,7 @@ export default async function PersonPage({ params, searchParams }: PageProps) { >
BOOK A CALL - OPEN + {pitchToBook ? "PITCH FIRST" : "OPEN"}
@@ -1275,11 +1278,20 @@ export default async function PersonPage({ params, searchParams }: PageProps) { 1:1
- + {bookingLinks[0] && } {/* One link: the usual CTA. Both: one button per provider. */}
+ {pitchToBook && ( + + Pitch to book a call + → + + )} {bookingLinks.map((l) => ( )} diff --git a/apps/cards-web/src/app/api/chat/pitch/route.ts b/apps/cards-web/src/app/api/chat/pitch/route.ts new file mode 100644 index 00000000..855f3e23 --- /dev/null +++ b/apps/cards-web/src/app/api/chat/pitch/route.ts @@ -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 = { + 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 }); + } +} diff --git a/apps/cards-web/src/components/PitchToBookButton.tsx b/apps/cards-web/src/components/PitchToBookButton.tsx new file mode 100644 index 00000000..47334200 --- /dev/null +++ b/apps/cards-web/src/components/PitchToBookButton.tsx @@ -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 ( + + ); +} diff --git a/apps/cards-web/src/components/ProfileChatWidget.tsx b/apps/cards-web/src/components/ProfileChatWidget.tsx index e59df3a2..6e87d01b 100644 --- a/apps/cards-web/src/components/ProfileChatWidget.tsx +++ b/apps/cards-web/src/components/ProfileChatWidget.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { MessageCircle, X, ArrowUp } from "lucide-react"; +import { OPEN_PITCH_EVENT } from "./PitchToBookButton"; export interface ChatMessage { role: "user" | "assistant"; @@ -24,6 +25,33 @@ interface ProfileChatWidgetProps { bookingLinks?: BookingLink[]; /** Offer "make your own" after the first answer; the page turns it off for owners and card holders. */ showCreateCta?: boolean; + /** The owner asks for a pitch first: `bookingLinks` is empty and the links arrive only from a passed pitch. */ + pitchToBook?: boolean; +} + +/** Mirrors cards-api PITCH_MAX_CHARS; the server enforces it. */ +const PITCH_MAX_CHARS = 500; + +/** The pitch step's panel; once a pitch passes, the panel shows the unlocked links instead. */ +type PitchState = + | { stage: "idle" } + | { stage: "open"; error?: string } + | { stage: "checking" } + | { stage: "declined"; reason: string }; + +interface UnlockedBooking { reason: string; links: BookingLink[]; } + +type PitchResult = { verdict: "pass"; reason: string; links: BookingLink[] } | { verdict: "decline"; reason: string }; + +function readPitchResult(body: unknown): PitchResult | null { + if (typeof body !== "object" || body === null) return null; + const { verdict, reason, links } = body as Record; + const text = typeof reason === "string" ? reason : ""; + if (verdict === "decline") return { verdict, reason: text }; + if (verdict !== "pass" || !Array.isArray(links)) return null; + const valid = links.filter((l): l is BookingLink => + typeof l === "object" && l !== null && typeof l.label === "string" && typeof l.url === "string" && isHttp(l.url)); + return { verdict, reason: text, links: valid }; } const CONTACT_WORDS = ["contact", "email", "call", "meet", "book", "intro", "calendly", "linkedin"]; @@ -81,6 +109,7 @@ export function ProfileChatWidget({ prompts = [], bookingLinks = [], showCreateCta = false, + pitchToBook = false, }: ProfileChatWidgetProps) { const [open, setOpen] = useState(defaultOpen); const [input, setInput] = useState(""); @@ -92,6 +121,13 @@ export function ProfileChatWidget({ const scrollRef = useRef(null); const inputRef = useRef(null); const seededRef = useRef(false); + const [pitch, setPitch] = useState({ stage: "idle" }); + const [pitchText, setPitchText] = useState(""); + const [unlocked, setUnlocked] = useState(null); + const pitchRef = useRef(null); + const firstName = personName.split(" ")[0]; + const visibleLinks = bookingLinks.length > 0 ? bookingLinks : unlocked?.links ?? []; + const canPitch = pitchToBook && !unlocked; useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 80); @@ -106,14 +142,62 @@ export function ProfileChatWidget({ useEffect(() => { const openChat = () => setOpen(true); const markIntro = () => setIntroReady(true); + const openPitch = () => { + setOpen(true); + setPitch({ stage: "open" }); + setTimeout(() => pitchRef.current?.focus(), 80); + }; window.addEventListener("zynd:open-profile-chat", openChat); window.addEventListener("zynd:intro-ready", markIntro); + window.addEventListener(OPEN_PITCH_EVENT, openPitch); return () => { window.removeEventListener("zynd:open-profile-chat", openChat); window.removeEventListener("zynd:intro-ready", markIntro); + window.removeEventListener(OPEN_PITCH_EVENT, openPitch); }; }, []); + function startPitch() { + setPitch({ stage: "open" }); + setTimeout(() => pitchRef.current?.focus(), 80); + } + + function backToChat() { + setPitch({ stage: "idle" }); + setTimeout(() => inputRef.current?.focus(), 80); + } + + async function submitPitch() { + const text = pitchText.trim(); + if (!text || pitch.stage === "checking") return; + setPitch({ stage: "checking" }); + try { + const res = await fetch("/api/chat/pitch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ handle, pitch: text }), + }); + const body: unknown = await res.json().catch(() => null); + if (!res.ok) { + const error = typeof body === "object" && body !== null && typeof (body as { error?: unknown }).error === "string" + ? (body as { error: string }).error : "Couldn't check your pitch right now."; + setPitch({ stage: "open", error }); + return; + } + const result = readPitchResult(body); + if (!result) { + setPitch({ stage: "open", error: "Couldn't check your pitch right now." }); + } else if (result.verdict === "pass") { + setUnlocked({ reason: result.reason, links: result.links }); + setPitch({ stage: "open" }); + } else { + setPitch({ stage: "declined", reason: result.reason }); + } + } catch { + setPitch({ stage: "open", error: "Couldn't reach the assistant. Check your connection and try again." }); + } + } + function scrollToBottom() { requestAnimationFrame(() => { const el = scrollRef.current; @@ -242,6 +326,98 @@ export function ProfileChatWidget({ ); } + const pitchChecking = pitch.stage === "checking"; + const pitchReady = pitchText.trim().length > 0 && !pitchChecking; + const pitchPanel = ( +
+ {!unlocked && (pitch.stage === "open" || pitch.stage === "checking") && ( + <> + +

+ {firstName} asks for a short pitch before sharing their calendar. The assistant checks it against what they're looking for. Your pitch isn't saved. +

+