Phase 2 mobile: share UI, advanced layers, analytics dashboard, builder depth

- ShapeLayer type; cardStore reorderLayer + profile/link actions (undoable)
- QRCodeView (renders on-device qr matrix); CardFace renders shape + qr layers
- share.ts / analytics.ts API clients
- ShareSheet (QR overlay, OS share, Add to Google Wallet); per-card analytics
  dashboard (summary + top countries + activity feed)
- Builder: LayerPropertySheet, LayerOrderPanel, ProfileEditor, shape creation

25 logic unit tests; tsc clean.

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 8ae1d8f263
commit d0636577dc
18 changed files with 1054 additions and 13 deletions
+50 -1
View File
@@ -6,7 +6,7 @@
// Zustand store so it is usable both in React and in headless unit tests.
import { create } from "zustand";
import { BackgroundConfig, CardDefinition, Layer } from "../types/card";
import { BackgroundConfig, CardDefinition, Layer, ProfileData, ProfileLink } from "../types/card";
export const MAX_HISTORY = 50;
@@ -21,8 +21,14 @@ interface CardState {
addLayer: (side: Side, layer: Layer) => void;
updateLayer: (side: Side, layerId: string, patch: Partial<Layer>) => void;
removeLayer: (side: Side, layerId: string) => void;
reorderLayer: (side: Side, layerId: string, direction: "up" | "down") => void;
setBackground: (side: Side, background: BackgroundConfig) => void;
setBio: (bio: string) => void;
addLink: (link: ProfileLink) => void;
updateLink: (linkId: string, patch: Partial<ProfileLink>) => void;
removeLink: (linkId: string) => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
@@ -58,6 +64,16 @@ export const useCardStore = create<CardState>((set, get) => {
return card;
};
const emptyProfile = (): ProfileData => ({ bio: "", links: [] });
const editProfile = (
card: CardDefinition,
fn: (profile: ProfileData) => ProfileData,
): CardDefinition => {
card.profile = fn(card.profile ?? emptyProfile());
return card;
};
return {
card: null,
past: [],
@@ -80,12 +96,45 @@ export const useCardStore = create<CardState>((set, get) => {
editSide(card, side, (layers) => layers.filter((l) => l.id !== layerId)),
),
reorderLayer: (side, layerId, direction) =>
mutate((card) =>
editSide(card, side, (layers) => {
const sorted = [...layers].sort((a, b) => a.zIndex - b.zIndex);
const i = sorted.findIndex((l) => l.id === layerId);
const j = direction === "up" ? i + 1 : i - 1;
if (i === -1 || j < 0 || j >= sorted.length) return layers;
// Swap the z-indexes of the two adjacent layers.
const zi = sorted[i].zIndex;
sorted[i] = { ...sorted[i], zIndex: sorted[j].zIndex };
sorted[j] = { ...sorted[j], zIndex: zi };
return sorted;
}),
),
setBackground: (side, background) =>
mutate((card) => {
card[side] = { ...card[side], background };
return card;
}),
setBio: (bio) => mutate((card) => editProfile(card, (p) => ({ ...p, bio }))),
addLink: (link) =>
mutate((card) => editProfile(card, (p) => ({ ...p, links: [...p.links, link] }))),
updateLink: (linkId, patch) =>
mutate((card) =>
editProfile(card, (p) => ({
...p,
links: p.links.map((l) => (l.id === linkId ? { ...l, ...patch } : l)),
})),
),
removeLink: (linkId) =>
mutate((card) =>
editProfile(card, (p) => ({ ...p, links: p.links.filter((l) => l.id !== linkId) })),
),
undo: () => {
const { card, past, future } = get();
if (!card || past.length === 0) return;