"Payload drop" scan experience — hand over a link or code on scan
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:
co-authored by
Claude Opus 4.8
parent
895ca4993a
commit
a61d0221d1
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user