Phase 2 web: render profile depth (About/Links) + contact form
CI / policy (push) Successful in 5s
CI / profile (push) Successful in 12s
CI / backend (push) Failing after 3m45s
CI / mobile (push) Successful in 21s

- Astro profile renders bio + links sections and a contact form wired to
  POST /v1/profile/{handle}/contact (empty sections omitted)
- README: Phase 2 status

Verified live end-to-end: mobile profile edit → web render → form submit.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 12:32:32 -05:00
co-authored by Claude Opus 4.8
parent d0636577dc
commit 9b7c845319
3 changed files with 169 additions and 4 deletions
+21 -2
View File
@@ -98,8 +98,27 @@ bash scripts/policy-grep.sh # no stub/placeholder markers (PRD §15.4)
lookup is behind a `GeoResolver` trait — real MaxMind under the `geoip` lookup is behind a `GeoResolver` trait — real MaxMind under the `geoip`
feature, no-op otherwise; the raw IP is resolved then discarded (only the feature, no-op otherwise; the raw IP is resolved then discarded (only the
hash + coarse country/city are stored). hash + coarse country/city are stored).
- **Remaining:** mobile advanced layers + share/QR/NFC UI + analytics dashboard - **Mobile share + analytics + layers** — done (type-checked, logic unit-tested).
screen (P2-3), then Android + Google Wallet. `ShapeLayer` type + layer reordering in `cardStore`; QR rendering (`QRCodeView`
from the on-device matrix); `CardFace` renders shape + qr layers; share/
analytics API clients; `ShareSheet` (QR overlay + OS share sheet); per-card
analytics dashboard (summary + top countries + activity feed).
- **Google Wallet (backend + mobile link)** — done. `cardclaws-wallet::google`
builds the inline GenericObject and an RS256-signed `savetowallet` JWT
(`Rs256Signer` via `jsonwebtoken`, fake signer for tests); wired at
`POST /v1/cards/{id}/wallet/google``{ saveUrl }`. Mobile share sheet has an
"Add to Google Wallet" action that opens the save URL.
- **Profile depth + contact form** — done + verified live. Web profile renders
About (bio) + Links sections + a contact form; `POST /v1/profile/{handle}/contact`
validates, rate-limits, records a `contact_form_submission`, and emails the
owner (Resend). End-to-end: mobile edits profile → web renders it → form
submits to the owner.
- **Builder depth (mobile)** — done. `LayerPropertySheet` (per-type edit:
text/color/fill/opacity + delete), `LayerOrderPanel` (reorder front↔back,
select), `ProfileEditor` (bio + up to 12 links), shape-layer creation — all
undoable via `cardStore` (profile/link actions unit-tested).
- **Remaining:** NFC tap + native Add-to-Apple-Wallet (need native modules + a
dev build); Android build; portfolio/testimonials (Pro).
Backend tests: **67 passing** (unit + integration). Run with a live Postgres Backend tests: **67 passing** (unit + integration). Run with a live Postgres
(`TEST_DATABASE_URL`) to exercise the integration suite. (`TEST_DATABASE_URL`) to exercise the integration suite.
+15
View File
@@ -16,9 +16,24 @@ export interface PublicProfile {
export interface CardDefinition { export interface CardDefinition {
face?: CardSide; face?: CardSide;
back?: CardSide; back?: CardSide;
profile?: ProfileData;
[key: string]: unknown; [key: string]: unknown;
} }
export interface ProfileLink {
id: string;
type: string;
label: string;
url: string;
iconSlug: string;
}
export interface ProfileData {
bio?: string;
links?: ProfileLink[];
contactFormEnabled?: boolean;
}
export interface CardSide { export interface CardSide {
layers?: Layer[]; layers?: Layer[];
background?: { type?: string; value?: string }; background?: { type?: string; value?: string };
+133 -2
View File
@@ -16,6 +16,9 @@ if (!profile) {
const contact = extractContact(profile.definition); const contact = extractContact(profile.definition);
const bgHex = faceBackground(profile.definition); const bgHex = faceBackground(profile.definition);
const name = profile.ownerDisplayName; const name = profile.ownerDisplayName;
const profileData = profile.definition.profile ?? {};
const bio = profileData.bio?.trim();
const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0);
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}` : ""
@@ -41,6 +44,39 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
bgHex={bgHex} bgHex={bgHex}
contact={contact} contact={contact}
/> />
{bio && (
<section class="block">
<h2>About</h2>
<p class="bio">{bio}</p>
</section>
)}
{links.length > 0 && (
<section class="block">
<h2>Links</h2>
<ul class="links">
{links.map((l) => (
<li>
<a href={l.url} target="_blank" rel="noopener noreferrer">
{l.label || l.url}
</a>
</li>
))}
</ul>
</section>
)}
<section class="block">
<h2>Get in touch</h2>
<form id="contact-form" class="contact-form">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Message" rows="4" maxlength="1000" required></textarea>
<button type="submit">Send</button>
<p id="contact-status" class="status"></p>
</form>
</section>
</main> </main>
<nav class="actions" aria-label="Contact actions"> <nav class="actions" aria-label="Contact actions">
@@ -59,10 +95,76 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
} }
main { main {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
min-height: 100vh; min-height: 100vh;
padding: 48px 0 120px; padding: 48px 20px 120px;
gap: 32px;
}
.block {
width: 100%;
max-width: 420px;
color: #f5f5f7;
}
.block h2 {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9a9aa0;
margin: 0 0 12px;
}
.bio {
margin: 0;
line-height: 1.5;
color: #d8d8dc;
}
.links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.links a {
display: block;
padding: 14px 16px;
border-radius: 14px;
background: #15151a;
color: #f5f5f7;
text-decoration: none;
font-weight: 600;
}
.contact-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.contact-form input,
.contact-form textarea {
background: #15151a;
border: 1px solid #222228;
border-radius: 12px;
padding: 14px;
color: #f5f5f7;
font-size: 16px;
font-family: inherit;
}
.contact-form button {
background: #ff3b30;
color: #fff;
border: none;
border-radius: 14px;
padding: 14px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.status {
margin: 0;
font-size: 14px;
color: #9a9aa0;
min-height: 18px;
} }
.actions { .actions {
position: fixed; position: fixed;
@@ -114,5 +216,34 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
}).catch(() => {}); }).catch(() => {});
}); });
</script> </script>
<script define:vars={{ handle, apiBase: publicApiBase() }}>
const form = document.getElementById("contact-form");
const status = document.getElementById("contact-status");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
const data = new FormData(form);
status.textContent = "Sending…";
try {
const res = await fetch(`${apiBase}/v1/profile/${handle}/contact`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: data.get("name"),
email: data.get("email"),
message: data.get("message"),
}),
});
if (res.ok) {
form.reset();
status.textContent = "Thanks — your message was sent.";
} else {
status.textContent = "Please check your details and try again.";
}
} catch {
status.textContent = "Something went wrong. Try again later.";
}
});
</script>
</body> </body>
</html> </html>