"Payload drop" scan experience — hand over a link or code on scan
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

Experience #8: a published card can carry a payload that's shown prominently on
scan — a primary link CTA, or a discount/access code with tap-to-copy.

- Backend: demo publish accepts an optional payload {kind, label, value},
  stored in definition.profile.payload (flows through the public profile). Test
  extended to assert it. Gate green (120 tests).
- Web ([handle].astro): renders a "link" payload as a big CTA button and a
  "code" payload as a dashed chip with copy-on-tap. astro check clean.
- Mobile (publish.tsx): payload kind chips (None/Link/Code) + label + value,
  sent with publishToWeb.

Verified live: publish with a code payload -> profile shows a copyable CARD20
chip; link payload -> CTA button.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 20:49:55 -05:00
co-authored by Claude Opus 4.8
parent 895ca4993a
commit a61d0221d1
6 changed files with 149 additions and 3 deletions
@@ -18,6 +18,15 @@ pub struct DemoLink {
pub url: String, pub url: String,
} }
/// "Payload drop": the thing handed over on scan — a link CTA or a code.
#[derive(Deserialize)]
pub struct DemoPayload {
/// "link" | "code".
pub kind: String,
pub label: String,
pub value: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DemoPublishRequest { pub struct DemoPublishRequest {
@@ -30,6 +39,8 @@ pub struct DemoPublishRequest {
pub welcome_prompt: Option<String>, pub welcome_prompt: Option<String>,
#[serde(default)] #[serde(default)]
pub welcome_message: Option<String>, pub welcome_message: Option<String>,
#[serde(default)]
pub payload: Option<DemoPayload>,
} }
pub struct Published { pub struct Published {
@@ -69,6 +80,16 @@ pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Publi
.filter(|l| !l.url.trim().is_empty()) .filter(|l| !l.url.trim().is_empty())
.map(|l| serde_json::json!({ "label": l.label, "url": l.url })) .map(|l| serde_json::json!({ "label": l.label, "url": l.url }))
.collect(); .collect();
let mut profile = serde_json::json!({ "links": links });
if let Some(p) = &req.payload {
if !p.value.trim().is_empty() {
profile["payload"] = serde_json::json!({
"kind": p.kind,
"label": p.label,
"value": p.value,
});
}
}
let definition = serde_json::json!({ let definition = serde_json::json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } }, "face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
"back": { "back": {
@@ -80,7 +101,7 @@ pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Publi
}], }],
"background": { "type": "solid", "value": "#101014" } "background": { "type": "solid", "value": "#101014" }
}, },
"profile": { "links": links } "profile": profile
}); });
let card = card_service::create(state, user.id, &card_handle, &definition).await?; let card = card_service::create(state, user.id, &card_handle, &definition).await?;
@@ -344,7 +344,8 @@ async fn demo_publish_creates_scannable_profile_with_welcome() {
"title": "Founder", "title": "Founder",
"links": [{ "label": "Site", "url": "https://cardclaws.com" }], "links": [{ "label": "Site", "url": "https://cardclaws.com" }],
"welcomePrompt": "a calm forest at dawn", "welcomePrompt": "a calm forest at dawn",
"welcomeMessage": "Great to meet you" "welcomeMessage": "Great to meet you",
"payload": { "kind": "code", "label": "20% off", "value": "CARD20" }
})), })),
) )
.await; .await;
@@ -352,13 +353,17 @@ async fn demo_publish_creates_scannable_profile_with_welcome() {
let handle = body["handle"].as_str().unwrap(); let handle = body["handle"].as_str().unwrap();
assert!(body["profileUrl"].as_str().unwrap().ends_with(handle)); assert!(body["profileUrl"].as_str().unwrap().ends_with(handle));
// The published card resolves publicly with the owner name + welcome. // The published card resolves publicly with the owner name + welcome + payload.
let (status, profile) = app let (status, profile) = app
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None) .request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await; .await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
assert_eq!(profile["ownerDisplayName"], "Omar Sobh"); assert_eq!(profile["ownerDisplayName"], "Omar Sobh");
assert_eq!(profile["welcome"]["message"], "Great to meet you"); assert_eq!(profile["welcome"]["message"], "Great to meet you");
assert_eq!(
profile["definition"]["profile"]["payload"]["value"],
"CARD20"
);
} }
#[tokio::test] #[tokio::test]
+54
View File
@@ -28,6 +28,9 @@ export default function PublishScreen() {
const [welcomePrompt, setWelcomePrompt] = useState(""); const [welcomePrompt, setWelcomePrompt] = useState("");
const [welcomeMessage, setWelcomeMessage] = useState("Great to meet you"); const [welcomeMessage, setWelcomeMessage] = useState("Great to meet you");
const [payloadKind, setPayloadKind] = useState<"none" | "link" | "code">("none");
const [payloadLabel, setPayloadLabel] = useState("");
const [payloadValue, setPayloadValue] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null); const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null);
@@ -49,6 +52,10 @@ export default function PublishScreen() {
links: card.links.filter((l) => l.url.trim()).map((l) => ({ label: l.label, url: l.url })), links: card.links.filter((l) => l.url.trim()).map((l) => ({ label: l.label, url: l.url })),
welcomePrompt: welcomePrompt.trim() || undefined, welcomePrompt: welcomePrompt.trim() || undefined,
welcomeMessage: welcomePrompt.trim() ? welcomeMessage : undefined, welcomeMessage: welcomePrompt.trim() ? welcomeMessage : undefined,
payload:
payloadKind !== "none" && payloadValue.trim()
? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() }
: undefined,
}); });
// Point the card's QR at the real profile + remember it. // Point the card's QR at the real profile + remember it.
upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() }); upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() });
@@ -94,6 +101,42 @@ export default function PublishScreen() {
</> </>
)} )}
<Text style={styles.label}>Payload drop (optional)</Text>
<View style={styles.kindRow}>
{(["none", "link", "code"] as const).map((k) => (
<Pressable
key={k}
onPress={() => setPayloadKind(k)}
style={[styles.kindChip, payloadKind === k && styles.kindOn]}
>
<Text style={[styles.kindText, payloadKind === k && styles.kindTextOn]}>
{k === "none" ? "None" : k === "link" ? "Link" : "Code"}
</Text>
</Pressable>
))}
</View>
{payloadKind !== "none" && (
<>
<TextInput
style={styles.input}
editable={!busy}
placeholder={payloadKind === "code" ? "Label (e.g. 20% off)" : "Button label (e.g. Get the deck)"}
placeholderTextColor="#6b6b70"
value={payloadLabel}
onChangeText={setPayloadLabel}
/>
<TextInput
style={styles.input}
editable={!busy}
autoCapitalize={payloadKind === "code" ? "characters" : "none"}
placeholder={payloadKind === "code" ? "CODE123" : "https://…"}
placeholderTextColor="#6b6b70"
value={payloadValue}
onChangeText={setPayloadValue}
/>
</>
)}
<Pressable style={[styles.cta, busy && styles.ctaBusy]} disabled={busy} onPress={publish}> <Pressable style={[styles.cta, busy && styles.ctaBusy]} disabled={busy} onPress={publish}>
{busy ? ( {busy ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color="#fff" />
@@ -149,6 +192,17 @@ const styles = StyleSheet.create({
}, },
ctaBusy: { opacity: 0.8 }, ctaBusy: { opacity: 0.8 },
ctaText: { color: "#fff", fontWeight: "700", fontSize: 17 }, ctaText: { color: "#fff", fontWeight: "700", fontSize: 17 },
kindRow: { flexDirection: "row", gap: 8 },
kindChip: {
flex: 1,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: "#222228",
alignItems: "center",
},
kindOn: { backgroundColor: "#ff3b30" },
kindText: { color: "#9a9aa0", fontWeight: "600" },
kindTextOn: { color: "#fff" },
qrWrap: { qrWrap: {
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
borderRadius: 16, borderRadius: 16,
+1
View File
@@ -56,6 +56,7 @@ export interface PublishPayload {
links: { label: string; url: string }[]; links: { label: string; url: string }[];
welcomePrompt?: string; welcomePrompt?: string;
welcomeMessage?: string; welcomeMessage?: string;
payload?: { kind: "link" | "code"; label: string; value: string };
} }
/** Publish a demo card to the web (no auth) → a real, scannable profile URL. */ /** Publish a demo card to the web (no auth) → a real, scannable profile URL. */
+2
View File
@@ -41,6 +41,8 @@ export interface ProfileData {
bio?: string; bio?: string;
links?: ProfileLink[]; links?: ProfileLink[];
contactFormEnabled?: boolean; contactFormEnabled?: boolean;
/** "Payload drop": the thing handed over on scan (a link CTA or a code). */
payload?: { kind: "link" | "code"; label: string; value: string };
} }
export interface CardSide { export interface CardSide {
@@ -25,6 +25,7 @@ const name = profile.ownerDisplayName;
const profileData = profile.definition.profile ?? {}; const profileData = profile.definition.profile ?? {};
const bio = profileData.bio?.trim(); const bio = profileData.bio?.trim();
const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0); const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0);
const payload = profileData.payload && profileData.payload.value?.trim() ? profileData.payload : null;
const vcard = buildVcard(name, contact); const vcard = buildVcard(name, contact);
const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
contact.company ? ` at ${contact.company}` : "" contact.company ? ` at ${contact.company}` : ""
@@ -61,6 +62,21 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
</section> </section>
)} )}
{payload && (
<section class="block">
{payload.kind === "code" ? (
<button id="payload-code" class="payload-code" data-code={payload.value}>
<span class="payload-label">{payload.label || "Tap to copy"}</span>
<span class="payload-value">{payload.value}</span>
</button>
) : (
<a class="payload-link" href={payload.value} target="_blank" rel="noopener noreferrer">
{payload.label || "Open"}
</a>
)}
</section>
)}
{links.length > 0 && ( {links.length > 0 && (
<section class="block"> <section class="block">
<h2>Links</h2> <h2>Links</h2>
@@ -139,6 +155,40 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
line-height: 1.5; line-height: 1.5;
color: #d8d8dc; color: #d8d8dc;
} }
.payload-link {
display: block;
text-align: center;
padding: 16px;
border-radius: 14px;
background: #ff3b30;
color: #fff;
text-decoration: none;
font-weight: 700;
font-size: 16px;
}
.payload-code {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: 14px;
background: #15151a;
border: 1px dashed #ff3b30;
color: #f5f5f7;
font: inherit;
cursor: pointer;
}
.payload-label {
color: #9a9aa0;
font-size: 14px;
}
.payload-value {
font-weight: 800;
font-size: 18px;
letter-spacing: 0.04em;
}
.links { .links {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -295,5 +345,18 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
} }
}); });
</script> </script>
<script>
const codeBtn = document.getElementById("payload-code");
codeBtn?.addEventListener("click", async () => {
const code = codeBtn.getAttribute("data-code") || "";
try {
await navigator.clipboard.writeText(code);
const label = codeBtn.querySelector(".payload-label");
if (label) label.textContent = "Copied!";
} catch {
/* clipboard unavailable */
}
});
</script>
</body> </body>
</html> </html>