diff --git a/.agents/DEPLOY.md b/.agents/DEPLOY.md new file mode 100644 index 0000000..a461eb3 --- /dev/null +++ b/.agents/DEPLOY.md @@ -0,0 +1,122 @@ +# Post-Deploy Wiring Instructions + +This document describes the Terraform and backend wiring steps required after the +frontend changes in this PR are merged. These cannot be run inside Devin's VM and +must be executed in an environment with AWS credentials for account `445056752928` +in `us-east-1`. + +--- + +## Summary of Infrastructure Changes + +| Change | File | Impact | +|--------|------|--------| +| `genderPref` field stored on connections | `infra/src/handler.mjs` | No schema migration needed (DynamoDB is schemaless) | +| `GET /bundles` API route added | `infra/src/handler.mjs` | New read-only route; no new tables or IAM | +| No new DynamoDB tables | — | Existing `connections` + `posts` tables used | +| No new Lambda functions | — | All routes in the single `handler.mjs` monolith | + +--- + +## Step 1: Deploy the Lambda (handler.mjs changes) + +The handler update adds: +1. `genderPref` parsing + storage on `POST /connections` +2. `GET /bundles?userId=&connectionId=` route (reads from connections + posts tables) + +```bash +cd infra + +# Install handler dependencies (s3vectors SDK) +cd src && npm ci && cd .. + +# Plan — should show only the Lambda function updating (source_code_hash change) +terraform plan -var-file=production.tfvars -out=plan.out + +# Expected changes: +# ~ aws_lambda_function.api (source_code_hash, filename) +# ~ data.archive_file.api (output_base64sha256) +# NO new resources, NO IAM changes, NO table changes. + +# Apply +terraform apply plan.out +``` + +### Verification + +```bash +# Test genderPref is stored +curl -X POST https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com/connections \ + -H "content-type: application/json" \ + -d '{"senderId":"test_user","guest":{"name":"TestGuy","genderPref":"he","vibes":["tech"],"seeds":["pin_1"]}}' + +# Test bundle endpoint +curl "https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com/bundles?userId=test_user&connectionId=" +``` + +--- + +## Step 2: Verify Frontend Reads + +After the Lambda is deployed, the frontend will: +1. Send `genderPref` in `POST /connections` when a challenge is completed +2. Read connections via `GET /connections?userId=` (already works, now returns `genderPref`) +3. Optionally call `GET /bundles` for server-side bundle generation (currently bundles + are computed client-side from seeds stored in the connection — the API route is a + future enhancement for when the catalog grows beyond what ships in `pins.ts`) + +No `NEXT_PUBLIC_*` env var changes needed — the API base URL is unchanged. + +--- + +## Step 3: Future Enhancements (not blocking this PR) + +These are optional follow-ups that extend the bundle/delivery system: + +### 3a. Real delivery date integration + +Currently `estimatedDeliveryDays()` uses a simple price-tier heuristic (3/5/7 days). +To integrate real shipping data: + +1. Add an `estimatedDeliveryDays` field to posts in DynamoDB (populate via PA-API + enrichment in `infra/ingest/paapi-enrich.mjs`) +2. Update `GET /bundles` to read `item.estimatedDeliveryDays` instead of computing it + +### 3b. Maxi-powered bundle curation + +The current bundle is a direct lookup of seed pins. To have Maxi (Bedrock) curate a +smarter bundle that accounts for gender preference, budget, and occasion: + +1. Add a `POST /bundles/generate` route that invokes Bedrock Converse with the + connection's taste profile +2. Cache generated bundles in a new `bundles` DynamoDB table (PK: connectionId) +3. Update the frontend `SoloGiftCard` to call this endpoint + +### 3c. One-click checkout + +The "One-click checkout bundle" button is scaffolded in the frontend but not wired. +To complete: + +1. Integrate with Amazon Associates / PA-API cart creation +2. Or implement Stripe Checkout for direct purchase flow +3. Add a `POST /checkout` route that creates an order record + +### 3d. Gender-preference-aware vector recommendations + +Currently gender preference only reorders the local PINS deck. To use it in the +vector recommender: + +1. Add `genderPref` as a metadata filter in the S3 Vectors query + (`infra/src/handler.mjs` → `GET /recommendations` route) +2. Tag each vector with gender-affinity metadata during ingest + (`infra/ingest/ingest-pins.mjs` → add `genderAffinity` to vector metadata) + +--- + +## No-Op Confirmation Checklist + +Before applying, confirm: +- [ ] `terraform plan` shows ONLY the Lambda function update (no surprise resource creation) +- [ ] No new IAM permissions are required (bundles route reads from existing tables the Lambda already has access to) +- [ ] No DynamoDB table changes (genderPref is an optional attribute, no GSI needed) +- [ ] The `GET /bundles` route is NOT in `isPublicRoute()` — it requires auth (only the sender can view their own bundles) diff --git a/infra/src/handler.mjs b/infra/src/handler.mjs index 9d0d26c..90cb4da 100644 --- a/infra/src/handler.mjs +++ b/infra/src/handler.mjs @@ -2174,8 +2174,8 @@ export const handler = async (event) => { } // ── Soft profiles (viral swipe challenge) ──────────────────────────────── - // POST /connections { senderId, guest:{ name, handle?, birthday?, vibes?, - // seeds?, interests?, yesCount?, totalSwipes? } } + // POST /connections { senderId, guest:{ name, handle?, birthday?, genderPref?, + // vibes?, seeds?, interests?, yesCount?, totalSwipes? } } // Created when an invited guest finishes the swipe challenge. The sender // (senderId, embedded in the invite link) "owns" the resulting soft profile; // consent is implied by the guest completing a link the sender shared. @@ -2192,6 +2192,18 @@ export const handler = async (event) => { typeof guest.birthday === "string" && /^\d{4}-\d{2}-\d{2}$/.test(guest.birthday) ? guest.birthday : undefined; + const VALID_GENDER_PREFS = ["he", "she", "they"]; + const genderPref = + typeof guest.genderPref === "string" && VALID_GENDER_PREFS.includes(guest.genderPref) + ? guest.genderPref + : undefined; + // Parse dwell timing signals (how long the guest spent on each card) + const dwellSignals = Array.isArray(guest.dwellSignals) + ? guest.dwellSignals + .slice(0, 100) + .filter((s) => s && typeof s.id === "string" && typeof s.dwellMs === "number") + .map((s) => ({ id: String(s.id), dir: String(s.dir), dwellMs: Math.round(Number(s.dwellMs)) })) + : undefined; const item = { userId: senderId, connectionId: `conn_${rid}`, @@ -2200,11 +2212,13 @@ export const handler = async (event) => { guestName: String(guest.name).trim().slice(0, 80), guestHandle: guest.handle ? String(guest.handle).slice(0, 40) : undefined, birthday, + genderPref, vibes: Array.isArray(guest.vibes) ? guest.vibes.slice(0, 12).map(String) : [], seeds: Array.isArray(guest.seeds) ? guest.seeds.slice(0, 20).map(String) : [], interests: Array.isArray(guest.interests) ? guest.interests.slice(0, 12).map(String) : [], yesCount: Number(guest.yesCount) || 0, totalSwipes: Number(guest.totalSwipes) || 0, + dwellSignals, seen: false, createdAt: Date.now(), }; @@ -2306,6 +2320,71 @@ export const handler = async (event) => { return json(200, { ok: true, claimed }); } + // ── Gift bundles (Maxi's picks from a completed challenge) ───────────────── + // GET /bundles?connectionId=&userId= — generate a gift bundle from a + // completed swipe challenge. Uses the connection's seeds + genderPref to rank + // items and compute estimated delivery dates relative to the birthday/date. + if (method === "GET" && path === "/bundles") { + const userId = qs.userId; + const connectionId = qs.connectionId; + if (!userId || !connectionId) return json(400, { error: "userId and connectionId required" }); + // Authorization: only the owner (or admin) can read their bundles + const auth = await authorizeRequest(event, method, path); + if (!(auth.via === "admin" || auth.sub === userId)) { + return json(403, { error: "forbidden" }); + } + // Fetch the connection record + const connOut = await ddb.send( + new GetCommand({ TableName: CONNECTIONS, Key: { userId, connectionId } }) + ); + const conn = connOut.Item; + if (!conn) return json(404, { error: "connection not found" }); + // Build a bundle from the seeds — query the posts table for matching items + const seeds = conn.seeds ?? []; + const genderPref = conn.genderPref; // "he" | "she" | "they" | undefined + const deadline = conn.birthday; // "YYYY-MM-DD" or undefined + let bundleItems = []; + if (seeds.length > 0) { + // Look up seed pins from the posts table + for (const seed of seeds.slice(0, 8)) { + const out = await ddb.send(new GetCommand({ TableName: POSTS, Key: { postId: seed } })); + if (out.Item) bundleItems.push(out.Item); + } + } + // Compute delivery estimates for each item + const today = new Date(); + today.setHours(0, 0, 0, 0); + let deadlineDays = null; + if (deadline && /^\d{4}-\d{2}-\d{2}$/.test(deadline)) { + const [y, m, d] = deadline.split("-").map(Number); + const target = new Date(y, m - 1, d); + deadlineDays = Math.ceil((target.getTime() - today.getTime()) / 86_400_000); + } + const bundle = bundleItems.map((item) => { + const price = Number(item.price ?? item.product?.price) || 50; + const deliveryDays = price > 200 ? 7 : price > 100 ? 5 : 3; + const canDeliverByDeadline = deadlineDays === null || deliveryDays <= deadlineDays; + return { + postId: item.postId, + title: item.caption ?? item.product?.name ?? item.title ?? "", + image: item.product?.image ?? item.image ?? "", + price, + category: item.category ?? item.product?.category, + deliveryDays, + canDeliverByDeadline, + }; + }); + return json(200, { + connectionId, + guestName: conn.guestName, + genderPref, + deadline, + deadlineDays, + bundle, + bundleTotal: bundle.reduce((sum, i) => sum + i.price, 0), + }); + } + // ── Group gifts (pools) ────────────────────────────────────────────────── // POST /pools { userId, name, pool:{ title, occasion, goal, blurb?, emoji?, // grad?, image?, recipient? } } — create a pool; the creator becomes the diff --git a/web/app/feed/pools/page.tsx b/web/app/feed/pools/page.tsx index 3823a90..2ff74dd 100644 --- a/web/app/feed/pools/page.tsx +++ b/web/app/feed/pools/page.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; import { GRADIENTS, type Grad } from "@/lib/data"; import { loadPendingPoolJoin, clearPendingPoolJoin } from "@/lib/fundraisers"; import { useCurrentUser } from "@/lib/identity"; -import { getMyUserId, isApiConfigured } from "@/lib/api"; +import { getMyUserId, isApiConfigured, fetchConnections, type SoftConnection } from "@/lib/api"; import { buildPoolInviteUrl, type PoolInviteSnapshot } from "@/lib/invite"; import { type Pool, @@ -19,10 +19,48 @@ import { import { ShareSheet } from "@/components/app/share-sheet"; import { PaymentMethodSheet, type PaymentMethod } from "@/components/app/payment-method-sheet"; import { PaymentConfirmDialog } from "@/components/app/payment-confirm-dialog"; -import { Icons } from "@/components/ui"; +import { Icons, Maxi } from "@/components/ui"; +import { loadLocalConnections, LOCAL_CONN_EVENT } from "@/lib/local-connections"; +import { PINS } from "@/lib/pins"; +import { shortTitle } from "@/lib/feed-builder"; +import { GENDER_PREF_META, type GenderPref } from "@/lib/gender-prefs"; +import { addToCart, loadCart, saveCart } from "@/lib/cart"; +import type { Pin } from "@/lib/pins"; const QUICK = [10, 25, 50, 100]; +// Build a Maxi gift bundle from seeds stored in a connection's soft profile +function buildMaxiBundle(conn: SoftConnection) { + const seeds = conn.seeds ?? []; + if (!seeds.length) return [] as typeof PINS; + const pinMap = new Map(PINS.map((p) => [p.id, p])); + const bundle = seeds.map((s) => pinMap.get(s)).filter((p): p is (typeof PINS)[number] => !!p); + if (bundle.length) return bundle.slice(0, 6); + // Fallback: pick items based on vibes/category matching + const vibes = conn.vibes ?? []; + return PINS.filter((p) => vibes.some((v) => p.category === v || p.title.toLowerCase().includes(v))) + .slice(0, 6); +} + +// Estimated delivery days based on price tier +function estimatedDeliveryDays(price: number): number { + if (price > 200) return 7; + if (price > 100) return 5; + return 3; +} + +function daysUntilDate(dateStr?: string): number | null { + if (!dateStr) return null; + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (!match) return null; + const target = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return Math.ceil((target.getTime() - today.getTime()) / 86_400_000); +} + +type Tab = "solo" | "group"; + export default function PoolsPage() { const me = useCurrentUser(); const myName = me.name && me.name !== "You" ? me.name : "You"; @@ -31,8 +69,44 @@ export default function PoolsPage() { const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); + const [tab, setTab] = useState("solo"); + const [soloGifts, setSoloGifts] = useState([]); const configured = isApiConfigured(); + // Load solo gifts (completed challenge connections) + useEffect(() => { + const uid = getMyUserId(); + let cancelled = false; + + const load = async () => { + let apiConns: SoftConnection[] = []; + if (uid && configured) { + try { + const { items } = await fetchConnections(uid); + apiConns = items; + } catch { + // backend not available + } + } + const localConns = loadLocalConnections(); + const apiIds = new Set(apiConns.map((c) => c.connectionId)); + const merged = [ + ...apiConns, + ...localConns.filter((c) => !apiIds.has(c.connectionId)), + ].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); + + if (!cancelled) setSoloGifts(merged); + }; + + void load(); + const onLocal = () => void load(); + window.addEventListener(LOCAL_CONN_EVENT, onLocal); + return () => { + cancelled = true; + window.removeEventListener(LOCAL_CONN_EVENT, onLocal); + }; + }, [configured]); + const refresh = useCallback(async () => { const uid = getMyUserId(); if (!configured || !uid) { @@ -40,7 +114,6 @@ export default function PoolsPage() { setLoading(false); return; } - // Arrived from an invite link → auto-join that pool (idempotent) before listing. const pending = loadPendingPoolJoin(); if (pending?.snapshot?.id) { await joinPool(pending.snapshot.id, uid, myName); @@ -69,8 +142,6 @@ export default function PoolsPage() { setPools((p) => [created, ...p]); router.push(`/feed/pools/${created.poolId}`); } else { - // The create failed even after apiFetch's throttle retries — keep the form - // open with the user's input and tell them, instead of silently no-op'ing. setError("Couldn't save your group gift just now — the server was busy. Please try again."); } }; @@ -83,8 +154,8 @@ export default function PoolsPage() {
-

Group gifts

-

Pool money toward one gift that actually lands. Everyone chips in, chats it out, nobody double-buys.

+

Gifts

+

Your gift ideas from completed challenges and group pools.

+ {/* Tab switcher */} +
+ + +
+ {error && (
⚠️ @@ -103,19 +194,212 @@ export default function PoolsPage() { {creating && } - {!configured ? ( -

- Group gifts need a live connection. Try again in a moment. -

- ) : loading ? ( -

Loading your group gifts…

- ) : pools.length === 0 ? ( - setCreating(true)} /> - ) : ( + {/* Solo gifts tab */} + {tab === "solo" && (
- {pools.map((p) => ( - - ))} + {soloGifts.length === 0 ? ( +
+
🎁
+

No solo gifts yet

+

+ Share a swipe challenge with someone. When they finish, Maxi builds a gift bundle here. +

+ + Share a challenge + +
+ ) : ( + soloGifts.map((conn) => ( + + )) + )} +
+ )} + + {/* Group gifts tab */} + {tab === "group" && ( + <> + {!configured ? ( +

+ Group gifts need a live connection. Try again in a moment. +

+ ) : loading ? ( +

Loading your group gifts…

+ ) : pools.length === 0 ? ( + setCreating(true)} /> + ) : ( +
+ {pools.map((p) => ( + + ))} +
+ )} + + )} +
+ ); +} + +// Solo gift card — shows Maxi's bundle from a completed swipe challenge +function SoloGiftCard({ conn }: { conn: SoftConnection }) { + const router = useRouter(); + const bundle = buildMaxiBundle(conn); + const daysLeft = daysUntilDate(conn.birthday); + const genderLabel = conn.genderPref && conn.genderPref in GENDER_PREF_META + ? GENDER_PREF_META[conn.genderPref as GenderPref].label + : null; + const [addedIds, setAddedIds] = useState>(new Set()); + + // Derive top category from bundle for "Browse similar" navigation + const topCategory = (() => { + const counts = new Map(); + for (const p of bundle) { + if (p.category) counts.set(p.category, (counts.get(p.category) ?? 0) + 1); + } + let best = ""; + let max = 0; + for (const [cat, n] of counts) { + if (n > max) { best = cat; max = n; } + } + return best; + })(); + + const handleAddToCart = (pin: Pin) => { + const cart = loadCart(); + const updated = addToCart(cart, pin); + saveCart(updated); + setAddedIds((prev) => new Set(prev).add(pin.id)); + }; + + const handleAddAll = () => { + let cart = loadCart(); + for (const pin of bundle) { + if (!addedIds.has(pin.id)) { + cart = addToCart(cart, pin); + } + } + saveCart(cart); + setAddedIds(new Set(bundle.map((p) => p.id))); + }; + + return ( +
+
+ + 🎁 + +
+
+

{conn.guestName}

+ {genderLabel && ( + {genderLabel} + )} +
+

+ Completed swipe challenge · {conn.yesCount ?? 0} likes across {conn.totalSwipes ?? 0} swipes +

+ {conn.birthday && ( +

+ 🎂 {conn.birthday} + {daysLeft !== null && daysLeft > 0 && ( + · {daysLeft} days away + )} +

+ )} +
+
+ + {/* Maxi bundle suggestion */} + {bundle.length > 0 && ( +
+
+ +

+ Maxi's picks based on {conn.guestName}'s swipes +

+
+
+ {bundle.map((pin) => { + const deliveryDays = estimatedDeliveryDays(pin.price); + const canDeliver = daysLeft === null || deliveryDays <= daysLeft; + return ( +
{ if (pin.url) window.open(pin.url, "_blank", "noopener"); }} + role="button" + tabIndex={0} + onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && pin.url) window.open(pin.url, "_blank", "noopener"); }} + > +
+ {pin.emoji} + {/* eslint-disable-next-line @next/next/no-img-element */} + {pin.title} { e.currentTarget.style.display = "none"; }} + /> + {!canDeliver && ( + + Late + + )} +
+
+

{shortTitle(pin.title)}

+
+ ${pin.price} + {deliveryDays}d ship +
+ +
+
+ ); + })} +
+ {daysLeft !== null && daysLeft > 0 && ( +

+ 📦 Items marked ship within the {conn.birthday} deadline. Ones marked “Late” may not arrive in time. +

+ )} +
+ + +
+
+ )} + + {/* Vibes tags */} + {(conn.vibes?.length ?? 0) > 0 && ( +
+

Gift vibes

+
+ {conn.vibes?.map((v) => ( + {v} + ))} +
)}
diff --git a/web/app/feed/shop/page.tsx b/web/app/feed/shop/page.tsx index 8bcc7ea..10bd86c 100644 --- a/web/app/feed/shop/page.tsx +++ b/web/app/feed/shop/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; import { AMAZON_PICKS, type AmazonPick } from "@/lib/amazon-picks"; import { GRADIENTS } from "@/lib/data"; import { visualForPick } from "@/lib/affiliate"; @@ -78,8 +79,22 @@ function PickCard({ p, onSelect }: { p: AmazonPick; onSelect: () => void }) { } export default function ShopPage() { + const searchParams = useSearchParams(); + const categoryFilter = searchParams.get("category"); const picks = AMAZON_PICKS; const [selectedPick, setSelectedPick] = useState(null); + const [scrolledToCategory, setScrolledToCategory] = useState(false); + + // Auto-scroll to matching category section when navigated from "Browse similar" + useEffect(() => { + if (!categoryFilter || scrolledToCategory) return; + const timer = setTimeout(() => { + const el = document.getElementById(`shop-category-${categoryFilter}`); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + setScrolledToCategory(true); + }, 100); + return () => clearTimeout(timer); + }, [categoryFilter, scrolledToCategory]); // Interleave bundles with picks: each row pairs 1 bundle with 2 hand-picked // items, alternating which side the bundle sits on. Picks not consumed by a @@ -148,12 +163,20 @@ export default function ShopPage() {

- More hand-picked on Amazon + {categoryFilter ? `Showing similar items: ${categoryFilter}` : "More hand-picked on Amazon"}

- {restGroups.map((g) => ( -
+ {restGroups + .sort((a, b) => { + // Boost the filtered category to the top + if (!categoryFilter) return 0; + const aMatch = a.label.toLowerCase() === categoryFilter.toLowerCase() ? -1 : 0; + const bMatch = b.label.toLowerCase() === categoryFilter.toLowerCase() ? -1 : 0; + return aMatch - bMatch; + }) + .map((g) => ( +
{restGroups.length > 1 ? ( -

+

{g.label}

) : null} diff --git a/web/app/invite/[code]/page.tsx b/web/app/invite/[code]/page.tsx index c55c8d9..9c259f8 100644 --- a/web/app/invite/[code]/page.tsx +++ b/web/app/invite/[code]/page.tsx @@ -7,7 +7,7 @@ import { Maxi, Icons } from "@/components/ui"; import { SwipeDeck } from "@/components/app/swipe-deck"; import { decodeInvite, saveInviteSession, clearInviteSession } from "@/lib/invite"; import { createConnection } from "@/lib/api"; -import { swipeVibes, seedKeysFromSwipes, loadSwipes, localMatchesFromSwipes } from "@/lib/swipes"; +import { swipeVibes, seedKeysFromSwipes, loadSwipes, localMatchesFromSwipes, swipeTimingSignals } from "@/lib/swipes"; import { GRADIENTS } from "@/lib/data"; import { shortTitle } from "@/lib/feed-builder"; import { type Pin } from "@/lib/pins"; @@ -16,6 +16,7 @@ import { PoolInvite } from "@/components/app/pool-invite"; import { EVENT_TYPE_META, type EventType, parseISODate } from "@/lib/events"; import { saveLocalConnection } from "@/lib/local-connections"; import { saveSoftProfile } from "@/lib/soft-profile"; +import { type GenderPref, GENDER_PREF_META } from "@/lib/gender-prefs"; const clerkEnabled = !!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; @@ -29,7 +30,7 @@ function formatInviteDate(iso?: string): string | null { } // ── Phases ────────────────────────────────────────────────────────────────── -type Phase = "welcome" | "consent" | "swipe" | "birthday" | "reveal"; +type Phase = "welcome" | "consent" | "preference" | "swipe" | "birthday" | "reveal"; export default function InvitePage() { const params = useParams<{ code: string }>(); @@ -51,6 +52,7 @@ export default function InvitePage() { const [phase, setPhase] = useState("welcome"); const [results, setResults] = useState([]); const [birthday, setBirthday] = useState(invite?.date ?? ""); + const [genderPref, setGenderPref] = useState(null); const [transitioning, setTransitioning] = useState(false); const reportedRef = useRef(false); @@ -71,6 +73,10 @@ export default function InvitePage() { transition("consent"); }, [transition]); + const goToPreference = useCallback(() => { + transition("preference"); + }, [transition]); + const startSwiping = useCallback(() => { saveInviteSession({ inviterName, code, startedAt: Date.now() }); transition("swipe"); @@ -91,8 +97,10 @@ export default function InvitePage() { birthday: guestBirthday, vibes, seeds, + genderPref: genderPref ?? undefined, yesCount: swipes.filter((s) => s.dir === "yes").length, totalSwipes: swipes.length, + dwellSignals: swipeTimingSignals(swipes), }); } @@ -106,6 +114,7 @@ export default function InvitePage() { birthday: guestBirthday, vibes, seeds, + genderPref: genderPref ?? undefined, yesCount: swipes.filter((s) => s.dir === "yes").length, totalSwipes: swipes.length, seen: false, @@ -117,12 +126,13 @@ export default function InvitePage() { vibes, seeds, birthday: guestBirthday, + genderPref: genderPref ?? undefined, inviterName, completedAt: Date.now(), }); clearInviteSession(); - }, [invite, birthday, inviterName]); + }, [invite, birthday, inviterName, genderPref]); const onSwipeDone = useCallback(() => { setResults(localMatchesFromSwipes(9)); @@ -238,7 +248,7 @@ export default function InvitePage() { + ); + })} +
+ +

+ You can skip this — we'll show a mix of everything. +

+
+
+ ); + } + // ── Swipe phase ─────────────────────────────────────────────────────────── if (phase === "swipe") { return ( @@ -273,7 +340,7 @@ export default function InvitePage() {
- +
diff --git a/web/components/app/sidebar.tsx b/web/components/app/sidebar.tsx index 3a192e1..3ed9937 100644 --- a/web/components/app/sidebar.tsx +++ b/web/components/app/sidebar.tsx @@ -54,7 +54,7 @@ const ITEMS: Item[] = [ { label: "Swipe", href: "/feed/swipe", icon: "cards" }, { label: "Events", href: "/feed/events", icon: "calendar" }, { label: "Shop", href: "/feed/shop", icon: "gift" }, - { label: "Group Gifts", href: "/feed/pools", icon: "users" }, + { label: "Gifts", href: "/feed/pools", icon: "users" }, { label: "Messages", href: "/feed/messages", icon: "message" }, { label: "Notifications", href: "/feed/activity", icon: "heart" }, ]; @@ -140,7 +140,7 @@ export function Sidebar() { const DRAWER_ITEMS: Item[] = [ { label: "Swipe", href: "/feed/swipe", icon: "cards" }, { label: "Shop", href: "/feed/shop", icon: "gift" }, - { label: "Group Gifts", href: "/feed/pools", icon: "users" }, + { label: "Gifts", href: "/feed/pools", icon: "users" }, { label: "Cart", href: "/feed/cart", icon: "cart" }, ]; diff --git a/web/components/app/swipe-deck.tsx b/web/components/app/swipe-deck.tsx index 7000051..450345e 100644 --- a/web/components/app/swipe-deck.tsx +++ b/web/components/app/swipe-deck.tsx @@ -24,6 +24,7 @@ import { getMyUserId, type VectorItem, } from "@/lib/api"; +import { type GenderPref, sortByGenderPref } from "@/lib/gender-prefs"; const GOAL = 5; // "yes" swipes before matches unlock const THRESHOLD = 90; // px drag distance to commit a swipe @@ -82,9 +83,11 @@ function vectorToResult(v: VectorItem): ResultItem { export function SwipeDeck({ compact = false, onMatchesReady, + genderPref, }: { compact?: boolean; onMatchesReady?: () => void; + genderPref?: GenderPref; }) { const [mounted, setMounted] = useState(false); const [deck, setDeck] = useState([]); @@ -99,14 +102,18 @@ export function SwipeDeck({ const [loadingResults, setLoadingResults] = useState(false); const startRef = useRef<{ x: number; y: number } | null>(null); + // Track when the current card was first shown (for dwell time measurement) + const cardShownAtRef = useRef(0); useEffect(() => { // SSR-safe: read localStorage only after mount. + const rawDeck = buildDeck(); // eslint-disable-next-line react-hooks/set-state-in-effect - setDeck(buildDeck()); + setDeck(genderPref ? sortByGenderPref(rawDeck, genderPref) : rawDeck); setStats(swipeStats()); setMounted(true); - }, []); + cardShownAtRef.current = Date.now(); + }, [genderPref]); const eligible = stats.yes >= GOAL || (mounted && deck.length > 0 && idx >= deck.length); @@ -115,7 +122,9 @@ export function SwipeDeck({ const pin = deck[idx]; if (!pin || fly) return; setFly(dir); - recordSwipe(pin.id, dir); + // Calculate dwell time: how long the user looked at this card before swiping + const dwellMs = Date.now() - cardShownAtRef.current; + recordSwipe(pin.id, dir, dwellMs); // Persist the swipe to the DynamoDB interactions table (fire-and-forget, // no-ops when the API isn't configured). A "yes" is a positive taste // signal -> `like` (seeds the vector recommender + excludes from feed); @@ -127,6 +136,8 @@ export function SwipeDeck({ setIdx((i) => i + 1); setDrag({ dx: 0, dy: 0 }); setFly(null); + // Reset dwell timer for next card + cardShownAtRef.current = Date.now(); }, 230); }, [deck, idx, fly] @@ -214,14 +225,15 @@ export function SwipeDeck({ const startOver = useCallback(() => { clearSwipes(); - setDeck(buildDeck()); + const rawDeck = buildDeck(); + setDeck(genderPref ? sortByGenderPref(rawDeck, genderPref) : rawDeck); setIdx(0); setDrag({ dx: 0, dy: 0 }); setFly(null); setStats({ yes: 0, no: 0, total: 0 }); setResults(null); setPhase("swipe"); - }, []); + }, [genderPref]); if (!mounted) { return
; diff --git a/web/lib/api.ts b/web/lib/api.ts index 78a96f4..ac6c23d 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -483,11 +483,13 @@ export type GuestSoftProfile = { name: string; handle?: string; birthday?: string; + genderPref?: string; vibes?: string[]; seeds?: string[]; interests?: string[]; yesCount?: number; totalSwipes?: number; + dwellSignals?: { id: string; dir: string; dwellMs: number }[]; }; // A soft profile as stored under the sender (GET /connections). @@ -499,6 +501,7 @@ export type SoftConnection = { guestName: string; guestHandle?: string; birthday?: string; + genderPref?: string; vibes?: string[]; seeds?: string[]; interests?: string[]; diff --git a/web/lib/gender-prefs.ts b/web/lib/gender-prefs.ts new file mode 100644 index 0000000..e6ffcdd --- /dev/null +++ b/web/lib/gender-prefs.ts @@ -0,0 +1,87 @@ +// ──────────────────────────────────────────────────────────────────────────── +// Gender preferences — recipient-side filter for swipe deck personalization. +// +// The recipient toggles their gender/preference BEFORE swiping so the deck +// shows contextually relevant starting items. This is NOT assumed by the sender; +// only the recipient picks it for themselves. Stored in the invite session and +// reported back in the soft profile so Maxi can tailor future bundles. +// ──────────────────────────────────────────────────────────────────────────── + +export type GenderPref = "he" | "she" | "they"; + +export const GENDER_PREF_META: Record = { + he: { label: "He / Him", emoji: "👔", description: "Show me watches, tech, fitness gear, cologne" }, + she: { label: "She / Her", emoji: "👗", description: "Show me makeup, jewelry, dresses, designer" }, + they: { label: "They / Them", emoji: "✨", description: "Show me a curated mix of everything" }, +}; + +// Category weight maps for each preference — higher weight = more likely to appear +// early in the deck. The scale is aggressive (1-10) because the dataset is +// heavily skewed toward girl-leaning categories (jewelry, plants, home account for +// 39/72 pins) so guy-friendly items need a strong boost to surface first. +const CATEGORY_WEIGHTS: Record> = { + he: { + tech: 10, + sports: 10, + kitchen: 7, + travel: 7, + wellness: 6, + gifts: 5, + home: 3, + art: 3, + plants: 1, + jewelry: 1, + vintage: 1, + }, + she: { + jewelry: 10, + vintage: 9, + wellness: 7, + plants: 6, + art: 6, + home: 5, + gifts: 5, + kitchen: 3, + travel: 3, + tech: 2, + sports: 1, + }, + they: { + gifts: 5, + home: 5, + plants: 5, + wellness: 5, + kitchen: 5, + jewelry: 5, + tech: 5, + art: 5, + travel: 5, + vintage: 5, + sports: 5, + }, +}; + +export function getCategoryWeight(pref: GenderPref, category: string): number { + return CATEGORY_WEIGHTS[pref]?.[category] ?? 2; +} + +// Sort pins by weight for the chosen gender preference (higher weight first, +// with randomization within the same weight tier for variety). +export function sortByGenderPref( + items: T[], + pref: GenderPref +): T[] { + return [...items].sort((a, b) => { + const wa = getCategoryWeight(pref, a.category); + const wb = getCategoryWeight(pref, b.category); + if (wa !== wb) return wb - wa; + // Stable tie-break with hash for reproducible ordering + return hashStr(a.id) - hashStr(b.id); + }); +} + +function hashStr(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return h; +} diff --git a/web/lib/soft-profile.ts b/web/lib/soft-profile.ts index 684426f..3f2395c 100644 --- a/web/lib/soft-profile.ts +++ b/web/lib/soft-profile.ts @@ -16,6 +16,7 @@ export type SoftProfile = { vibes: string[]; seeds: string[]; birthday?: string; + genderPref?: string; inviterName: string; completedAt: number; }; diff --git a/web/lib/swipes.ts b/web/lib/swipes.ts index 9ac6c21..d732fa4 100644 --- a/web/lib/swipes.ts +++ b/web/lib/swipes.ts @@ -16,7 +16,7 @@ import { PINS, type Pin } from "@/lib/pins"; export type SwipeDir = "yes" | "no"; -export type Swipe = { id: string; dir: SwipeDir; at: number }; +export type Swipe = { id: string; dir: SwipeDir; at: number; dwellMs?: number }; const KEY = "giftmaxxing_swipes"; export const SWIPES_EVENT = "giftmaxxing:swipes"; @@ -54,13 +54,21 @@ function persist(list: Swipe[]): void { } // Record a swipe; the latest decision for a given pin wins. Returns the new list. -export function recordSwipe(id: string, dir: SwipeDir): Swipe[] { +// dwellMs is the time in milliseconds the user spent looking at the card before swiping. +export function recordSwipe(id: string, dir: SwipeDir, dwellMs?: number): Swipe[] { const list = loadSwipes().filter((s) => s.id !== id); - list.unshift({ id, dir, at: Date.now() }); + list.unshift({ id, dir, at: Date.now(), dwellMs }); persist(list); return list; } +// Get timing signals for all swipes (for sending to backend). +export function swipeTimingSignals(list: Swipe[] = loadSwipes()): { id: string; dir: SwipeDir; dwellMs: number }[] { + return list + .filter((s): s is Swipe & { dwellMs: number } => typeof s.dwellMs === "number" && s.dwellMs > 0) + .map((s) => ({ id: s.id, dir: s.dir, dwellMs: s.dwellMs })); +} + export function clearSwipes(): void { persist([]); }