From 2f21ffc8450757a853ef82083f2e756a60fc8fc8 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sun, 28 Jun 2026 14:17:31 +0000
Subject: [PATCH 1/3] feat: gender preference toggle, unified Gifts tab, Maxi
bundles with delivery dates
- Add recipient-side gender/preference selector (he/she/they) in invite flow
- Prioritize swipe deck items based on selected gender preference
- Rename 'Group Gifts' nav tab to 'Gifts' with solo/group sub-tabs
- Show Maxi's gift bundle suggestions from completed challenges in Gifts tab
- Add delivery date awareness (items marked 'Late' if won't ship by occasion)
- One-click checkout button scaffolded for bundles
- Store genderPref in soft profiles (localStorage + API backend)
- Add GET /bundles API route for server-side bundle generation
- Include .agents/DEPLOY.md with Terraform plan/apply instructions
Co-Authored-By: Saksham
---
.agents/DEPLOY.md | 122 ++++++++++++++
infra/src/handler.mjs | 72 ++++++++-
web/app/feed/pools/page.tsx | 260 +++++++++++++++++++++++++++---
web/app/invite/[code]/page.tsx | 73 ++++++++-
web/components/app/sidebar.tsx | 4 +-
web/components/app/swipe-deck.tsx | 8 +-
web/lib/api.ts | 2 +
web/lib/gender-prefs.ts | 85 ++++++++++
web/lib/soft-profile.ts | 1 +
9 files changed, 598 insertions(+), 29 deletions(-)
create mode 100644 .agents/DEPLOY.md
create mode 100644 web/lib/gender-prefs.ts
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..f63a15d 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,11 @@ 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;
const item = {
userId: senderId,
connectionId: `conn_${rid}`,
@@ -2200,6 +2205,7 @@ 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) : [],
@@ -2306,6 +2312,68 @@ 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" });
+ // 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)) {
+ try {
+ const out = await ddb.send(new GetCommand({ TableName: POSTS, Key: { postId: seed } }));
+ if (out.Item) bundleItems.push(out.Item);
+ } catch { /* skip missing */ }
+ }
+ }
+ // 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) || 50;
+ const deliveryDays = price > 200 ? 7 : price > 100 ? 5 : 3;
+ const canDeliverByDeadline = deadlineDays === null || deliveryDays <= deadlineDays;
+ return {
+ postId: item.postId,
+ title: item.title,
+ image: item.image,
+ price,
+ category: item.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..8078a8b 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,46 @@ 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";
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 +67,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 +112,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 +140,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 +152,8 @@ export default function PoolsPage() {
+ {/* Tab switcher */}
+
+
+
+
+
{error && (
⚠️
@@ -103,19 +192,152 @@ 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 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;
+
+ 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 (
+
+
+
{pin.emoji}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

{ 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/invite/[code]/page.tsx b/web/app/invite/[code]/page.tsx
index c55c8d9..5f13320 100644
--- a/web/app/invite/[code]/page.tsx
+++ b/web/app/invite/[code]/page.tsx
@@ -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,6 +97,7 @@ export default function InvitePage() {
birthday: guestBirthday,
vibes,
seeds,
+ genderPref: genderPref ?? undefined,
yesCount: swipes.filter((s) => s.dir === "yes").length,
totalSwipes: swipes.length,
});
@@ -106,6 +113,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 +125,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 +247,7 @@ export default function InvitePage() {
-
+
diff --git a/web/components/app/sidebar.tsx b/web/components/app/sidebar.tsx
index 3a192e1..7734f6d 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: "gift" },
{ 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: "gift" },
{ 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..2ddc3df 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([]);
@@ -103,10 +106,11 @@ export function SwipeDeck({
useEffect(() => {
// SSR-safe: read localStorage only after mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
- setDeck(buildDeck());
+ const rawDeck = buildDeck();
+ setDeck(genderPref ? sortByGenderPref(rawDeck, genderPref) : rawDeck);
setStats(swipeStats());
setMounted(true);
- }, []);
+ }, [genderPref]);
const eligible = stats.yes >= GOAL || (mounted && deck.length > 0 && idx >= deck.length);
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 78a96f4..e5367d0 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -483,6 +483,7 @@ export type GuestSoftProfile = {
name: string;
handle?: string;
birthday?: string;
+ genderPref?: string;
vibes?: string[];
seeds?: string[];
interests?: string[];
@@ -499,6 +500,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..7b0523d
--- /dev/null
+++ b/web/lib/gender-prefs.ts
@@ -0,0 +1,85 @@
+// ────────────────────────────────────────────────────────────────────────────
+// 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. Neutral categories always appear.
+const CATEGORY_WEIGHTS: Record> = {
+ he: {
+ tech: 5,
+ sports: 5,
+ wellness: 4,
+ travel: 3,
+ kitchen: 2,
+ home: 2,
+ gifts: 3,
+ jewelry: 1,
+ plants: 2,
+ art: 2,
+ vintage: 1,
+ },
+ she: {
+ jewelry: 5,
+ vintage: 4,
+ home: 3,
+ plants: 3,
+ art: 3,
+ wellness: 4,
+ kitchen: 2,
+ gifts: 3,
+ travel: 2,
+ tech: 1,
+ sports: 1,
+ },
+ they: {
+ gifts: 3,
+ home: 3,
+ plants: 3,
+ wellness: 3,
+ kitchen: 3,
+ jewelry: 3,
+ tech: 3,
+ art: 3,
+ travel: 3,
+ vintage: 3,
+ sports: 3,
+ },
+};
+
+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;
};
From 9bb6f776f5692b14fd9688b12c2465991c22b8f9 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sun, 28 Jun 2026 14:27:24 +0000
Subject: [PATCH 2/3] fix: address review feedback + add cart to bundle items
- Fix duplicate gift icon: Gifts tab reverts to 'users' icon
- Fix /bundles handler field normalization (caption/product.image/product.price)
- Add auth check: verify auth.sub === userId on GET /bundles
- Remove silent error swallowing in seed lookup loop
- Add per-item 'Add to cart' button + 'Add entire bundle' on gift cards
- Fix startOver() to preserve genderPref sort order
- Boost category weights for guy-friendly items (10x for tech/sports)
- Add aria-pressed to gender preference selector buttons
Co-Authored-By: Saksham
---
infra/src/handler.mjs | 19 +++++----
web/app/feed/pools/page.tsx | 36 ++++++++++++++++-
web/app/invite/[code]/page.tsx | 1 +
web/components/app/sidebar.tsx | 4 +-
web/components/app/swipe-deck.tsx | 9 +++--
web/lib/gender-prefs.ts | 64 ++++++++++++++++---------------
6 files changed, 87 insertions(+), 46 deletions(-)
diff --git a/infra/src/handler.mjs b/infra/src/handler.mjs
index f63a15d..a8beef4 100644
--- a/infra/src/handler.mjs
+++ b/infra/src/handler.mjs
@@ -2320,6 +2320,11 @@ export const handler = async (event) => {
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 } })
@@ -2334,10 +2339,8 @@ export const handler = async (event) => {
if (seeds.length > 0) {
// Look up seed pins from the posts table
for (const seed of seeds.slice(0, 8)) {
- try {
- const out = await ddb.send(new GetCommand({ TableName: POSTS, Key: { postId: seed } }));
- if (out.Item) bundleItems.push(out.Item);
- } catch { /* skip missing */ }
+ 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
@@ -2350,15 +2353,15 @@ export const handler = async (event) => {
deadlineDays = Math.ceil((target.getTime() - today.getTime()) / 86_400_000);
}
const bundle = bundleItems.map((item) => {
- const price = Number(item.price) || 50;
+ 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.title,
- image: item.image,
+ title: item.caption ?? item.product?.name ?? item.title ?? "",
+ image: item.product?.image ?? item.image ?? "",
price,
- category: item.category,
+ category: item.category ?? item.product?.category,
deliveryDays,
canDeliverByDeadline,
};
diff --git a/web/app/feed/pools/page.tsx b/web/app/feed/pools/page.tsx
index 8078a8b..a6479aa 100644
--- a/web/app/feed/pools/page.tsx
+++ b/web/app/feed/pools/page.tsx
@@ -24,6 +24,8 @@ 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];
@@ -248,6 +250,25 @@ function SoloGiftCard({ conn }: { conn: SoftConnection }) {
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());
+
+ 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 (
@@ -313,6 +334,13 @@ function SoloGiftCard({ conn }: { conn: SoftConnection }) {
${pin.price}
{deliveryDays}d ship
+ handleAddToCart(pin)}
+ disabled={addedIds.has(pin.id)}
+ className="mt-1 w-full rounded-md bg-coral px-1 py-0.5 text-[9px] font-bold text-white transition-opacity hover:opacity-90 disabled:bg-ink-faint disabled:opacity-60"
+ >
+ {addedIds.has(pin.id) ? "Added" : "+ Cart"}
+
);
@@ -323,8 +351,12 @@ function SoloGiftCard({ conn }: { conn: SoftConnection }) {
📦 Items marked ship within the {conn.birthday} deadline. Ones marked “Late” may not arrive in time.
)}
-
- One-click checkout bundle
+
+ {addedIds.size === bundle.length ? "All added to cart ✓" : "Add entire bundle to cart"}
)}
diff --git a/web/app/invite/[code]/page.tsx b/web/app/invite/[code]/page.tsx
index 5f13320..c4d7347 100644
--- a/web/app/invite/[code]/page.tsx
+++ b/web/app/invite/[code]/page.tsx
@@ -284,6 +284,7 @@ export default function InvitePage() {
setGenderPref(key)}
+ aria-pressed={selected}
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition-colors ${
selected
? "border-coral bg-coral-soft"
diff --git a/web/components/app/sidebar.tsx b/web/components/app/sidebar.tsx
index 7734f6d..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: "Gifts", href: "/feed/pools", icon: "gift" },
+ { 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: "Gifts", href: "/feed/pools", icon: "gift" },
+ { 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 2ddc3df..140abe8 100644
--- a/web/components/app/swipe-deck.tsx
+++ b/web/components/app/swipe-deck.tsx
@@ -105,10 +105,12 @@ export function SwipeDeck({
useEffect(() => {
// SSR-safe: read localStorage only after mount.
- // eslint-disable-next-line react-hooks/set-state-in-effect
const rawDeck = buildDeck();
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setDeck(genderPref ? sortByGenderPref(rawDeck, genderPref) : rawDeck);
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setStats(swipeStats());
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, [genderPref]);
@@ -218,14 +220,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/gender-prefs.ts b/web/lib/gender-prefs.ts
index 7b0523d..e6ffcdd 100644
--- a/web/lib/gender-prefs.ts
+++ b/web/lib/gender-prefs.ts
@@ -16,46 +16,48 @@ export const GENDER_PREF_META: Record> = {
he: {
- tech: 5,
- sports: 5,
- wellness: 4,
- travel: 3,
- kitchen: 2,
- home: 2,
- gifts: 3,
+ tech: 10,
+ sports: 10,
+ kitchen: 7,
+ travel: 7,
+ wellness: 6,
+ gifts: 5,
+ home: 3,
+ art: 3,
+ plants: 1,
jewelry: 1,
- plants: 2,
- art: 2,
vintage: 1,
},
she: {
- jewelry: 5,
- vintage: 4,
- home: 3,
- plants: 3,
- art: 3,
- wellness: 4,
- kitchen: 2,
- gifts: 3,
- travel: 2,
- tech: 1,
+ jewelry: 10,
+ vintage: 9,
+ wellness: 7,
+ plants: 6,
+ art: 6,
+ home: 5,
+ gifts: 5,
+ kitchen: 3,
+ travel: 3,
+ tech: 2,
sports: 1,
},
they: {
- gifts: 3,
- home: 3,
- plants: 3,
- wellness: 3,
- kitchen: 3,
- jewelry: 3,
- tech: 3,
- art: 3,
- travel: 3,
- vintage: 3,
- sports: 3,
+ gifts: 5,
+ home: 5,
+ plants: 5,
+ wellness: 5,
+ kitchen: 5,
+ jewelry: 5,
+ tech: 5,
+ art: 5,
+ travel: 5,
+ vintage: 5,
+ sports: 5,
},
};
From a81e5517e6b567c3434ea7e787015b18f9521707 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sun, 28 Jun 2026 14:38:52 +0000
Subject: [PATCH 3/3] feat: browse similar items, clickable bundle cards, swipe
dwell timing signals
- Add 'Browse similar' button next to 'Add all to cart' in Maxi bundles
- Make each bundle item card clickable (opens item in new tab)
- Shop page supports ?category= param to highlight/scroll to matching section
- Track swipe dwell time (ms between card shown and swipe action)
- Store dwellSignals in localStorage swipe records
- Send dwellSignals to backend on connection creation (POST /connections)
- Backend persists dwellSignals in DynamoDB for future recommendation enrichment
Co-Authored-By: Saksham
---
infra/src/handler.mjs | 8 ++++++
web/app/feed/pools/page.tsx | 48 +++++++++++++++++++++++++------
web/app/feed/shop/page.tsx | 33 +++++++++++++++++----
web/app/invite/[code]/page.tsx | 3 +-
web/components/app/swipe-deck.tsx | 11 +++++--
web/lib/api.ts | 1 +
web/lib/swipes.ts | 14 +++++++--
7 files changed, 97 insertions(+), 21 deletions(-)
diff --git a/infra/src/handler.mjs b/infra/src/handler.mjs
index a8beef4..90cb4da 100644
--- a/infra/src/handler.mjs
+++ b/infra/src/handler.mjs
@@ -2197,6 +2197,13 @@ export const handler = async (event) => {
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}`,
@@ -2211,6 +2218,7 @@ export const handler = async (event) => {
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(),
};
diff --git a/web/app/feed/pools/page.tsx b/web/app/feed/pools/page.tsx
index a6479aa..2ff74dd 100644
--- a/web/app/feed/pools/page.tsx
+++ b/web/app/feed/pools/page.tsx
@@ -245,6 +245,7 @@ export default function PoolsPage() {
// 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
@@ -252,6 +253,20 @@ function SoloGiftCard({ conn }: { conn: SoftConnection }) {
: 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);
@@ -311,7 +326,14 @@ function SoloGiftCard({ conn }: { conn: SoftConnection }) {
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 */}
@@ -335,7 +357,7 @@ function SoloGiftCard({ conn }: { conn: SoftConnection }) {
{deliveryDays}d ship
handleAddToCart(pin)}
+ onClick={(e) => { e.stopPropagation(); handleAddToCart(pin); }}
disabled={addedIds.has(pin.id)}
className="mt-1 w-full rounded-md bg-coral px-1 py-0.5 text-[9px] font-bold text-white transition-opacity hover:opacity-90 disabled:bg-ink-faint disabled:opacity-60"
>
@@ -351,13 +373,21 @@ function SoloGiftCard({ conn }: { conn: SoftConnection }) {
📦 Items marked ship within the {conn.birthday} deadline. Ones marked “Late” may not arrive in time.
)}
-
- {addedIds.size === bundle.length ? "All added to cart ✓" : "Add entire bundle to cart"}
-
+
+
+ {addedIds.size === bundle.length ? "All added to cart ✓" : "Add all to cart"}
+
+ router.push(`/feed/shop${topCategory ? `?category=${encodeURIComponent(topCategory)}` : ""}`)}
+ className="flex-1 rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-bold text-ink transition-opacity hover:opacity-90"
+ >
+ Browse similar
+
+
)}
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 c4d7347..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";
@@ -100,6 +100,7 @@ export default function InvitePage() {
genderPref: genderPref ?? undefined,
yesCount: swipes.filter((s) => s.dir === "yes").length,
totalSwipes: swipes.length,
+ dwellSignals: swipeTimingSignals(swipes),
});
}
diff --git a/web/components/app/swipe-deck.tsx b/web/components/app/swipe-deck.tsx
index 140abe8..450345e 100644
--- a/web/components/app/swipe-deck.tsx
+++ b/web/components/app/swipe-deck.tsx
@@ -102,16 +102,17 @@ 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(genderPref ? sortByGenderPref(rawDeck, genderPref) : rawDeck);
- // eslint-disable-next-line react-hooks/set-state-in-effect
setStats(swipeStats());
- // eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
+ cardShownAtRef.current = Date.now();
}, [genderPref]);
const eligible = stats.yes >= GOAL || (mounted && deck.length > 0 && idx >= deck.length);
@@ -121,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);
@@ -133,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]
diff --git a/web/lib/api.ts b/web/lib/api.ts
index e5367d0..ac6c23d 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -489,6 +489,7 @@ export type GuestSoftProfile = {
interests?: string[];
yesCount?: number;
totalSwipes?: number;
+ dwellSignals?: { id: string; dir: string; dwellMs: number }[];
};
// A soft profile as stored under the sender (GET /connections).
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([]);
}