"Now" status scan experience — show what you're currently up to
Experience #9 (lite): a published card can carry a "Now" line shown with a live pulsing dot, so a scanned profile reflects what the person is currently doing. - Backend: demo publish accepts optional `now` -> definition.profile.now; test asserts it. Gate green (120 tests). - Web: renders a "Now: …" block with an animated green dot. astro check clean. - Mobile: a "Now" field on the publish screen. Verified live alongside a 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
a61d0221d1
commit
b487195def
@@ -41,6 +41,9 @@ pub struct DemoPublishRequest {
|
|||||||
pub welcome_message: Option<String>,
|
pub welcome_message: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub payload: Option<DemoPayload>,
|
pub payload: Option<DemoPayload>,
|
||||||
|
/// "Now" status — what the person is currently up to.
|
||||||
|
#[serde(default)]
|
||||||
|
pub now: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Published {
|
pub struct Published {
|
||||||
@@ -90,6 +93,11 @@ pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Publi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(now) = req.now.as_deref() {
|
||||||
|
if !now.trim().is_empty() {
|
||||||
|
profile["now"] = serde_json::json!(now.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
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": {
|
||||||
|
|||||||
@@ -345,7 +345,8 @@ async fn demo_publish_creates_scannable_profile_with_welcome() {
|
|||||||
"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" }
|
"payload": { "kind": "code", "label": "20% off", "value": "CARD20" },
|
||||||
|
"now": "Building CardClaws · open to design partners"
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -364,6 +365,10 @@ async fn demo_publish_creates_scannable_profile_with_welcome() {
|
|||||||
profile["definition"]["profile"]["payload"]["value"],
|
profile["definition"]["profile"]["payload"]["value"],
|
||||||
"CARD20"
|
"CARD20"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
profile["definition"]["profile"]["now"],
|
||||||
|
"Building CardClaws · open to design partners"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function PublishScreen() {
|
|||||||
const [payloadKind, setPayloadKind] = useState<"none" | "link" | "code">("none");
|
const [payloadKind, setPayloadKind] = useState<"none" | "link" | "code">("none");
|
||||||
const [payloadLabel, setPayloadLabel] = useState("");
|
const [payloadLabel, setPayloadLabel] = useState("");
|
||||||
const [payloadValue, setPayloadValue] = useState("");
|
const [payloadValue, setPayloadValue] = useState("");
|
||||||
|
const [now, setNow] = 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);
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export default function PublishScreen() {
|
|||||||
payloadKind !== "none" && payloadValue.trim()
|
payloadKind !== "none" && payloadValue.trim()
|
||||||
? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() }
|
? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
now: now.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() });
|
||||||
@@ -101,6 +103,16 @@ export default function PublishScreen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>Now — what you’re up to (optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
editable={!busy}
|
||||||
|
placeholder="e.g. Building CardClaws · open to design partners"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={now}
|
||||||
|
onChangeText={setNow}
|
||||||
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Payload drop (optional)</Text>
|
<Text style={styles.label}>Payload drop (optional)</Text>
|
||||||
<View style={styles.kindRow}>
|
<View style={styles.kindRow}>
|
||||||
{(["none", "link", "code"] as const).map((k) => (
|
{(["none", "link", "code"] as const).map((k) => (
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface PublishPayload {
|
|||||||
welcomePrompt?: string;
|
welcomePrompt?: string;
|
||||||
welcomeMessage?: string;
|
welcomeMessage?: string;
|
||||||
payload?: { kind: "link" | "code"; label: string; value: string };
|
payload?: { kind: "link" | "code"; label: string; value: string };
|
||||||
|
now?: 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. */
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface ProfileData {
|
|||||||
contactFormEnabled?: boolean;
|
contactFormEnabled?: boolean;
|
||||||
/** "Payload drop": the thing handed over on scan (a link CTA or a code). */
|
/** "Payload drop": the thing handed over on scan (a link CTA or a code). */
|
||||||
payload?: { kind: "link" | "code"; label: string; value: string };
|
payload?: { kind: "link" | "code"; label: string; value: string };
|
||||||
|
/** "Now" status — what the person is currently up to. */
|
||||||
|
now?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardSide {
|
export interface CardSide {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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 payload = profileData.payload && profileData.payload.value?.trim() ? profileData.payload : null;
|
||||||
|
const now = profileData.now?.trim();
|
||||||
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}` : ""
|
||||||
@@ -55,6 +56,12 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
|
|||||||
contact={contact}
|
contact={contact}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{now && (
|
||||||
|
<section class="block">
|
||||||
|
<p class="now"><span class="now-dot"></span>Now: {now}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{bio && (
|
{bio && (
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2>About</h2>
|
<h2>About</h2>
|
||||||
@@ -155,6 +162,30 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #d8d8dc;
|
color: #d8d8dc;
|
||||||
}
|
}
|
||||||
|
.now {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #f5f5f7;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.now-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #30d158;
|
||||||
|
box-shadow: 0 0 0 0 rgba(48, 209, 88, 0.6);
|
||||||
|
animation: pulse 1.8s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(48, 209, 88, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(48, 209, 88, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.payload-link {
|
.payload-link {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user