"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
+54
View File
@@ -28,6 +28,9 @@ export default function PublishScreen() {
const [welcomePrompt, setWelcomePrompt] = useState("");
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 [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 })),
welcomePrompt: welcomePrompt.trim() || 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.
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}>
{busy ? (
<ActivityIndicator color="#fff" />
@@ -149,6 +192,17 @@ const styles = StyleSheet.create({
},
ctaBusy: { opacity: 0.8 },
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: {
backgroundColor: "#ffffff",
borderRadius: 16,