Phase 2 web: render profile depth (About/Links) + contact form
- 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:
co-authored by
Claude Opus 4.8
parent
d0636577dc
commit
9b7c845319
@@ -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.
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user