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
@@ -9,14 +9,18 @@ import {
ContactLayer,
Layer,
LogoLayer,
ShapeLayer,
TextLayer,
} from "../../types/card";
import { API_BASE } from "../../api/client";
import { QRCodeView } from "./QRCodeView";
interface Props {
side: CardSide;
width: number;
height: number;
/** URL encoded by any `qr` layer on this side (the card's profile URL). */
profileUrl?: string;
}
function backgroundStyle(bg: BackgroundConfig): { backgroundColor: string } {
@@ -33,7 +37,17 @@ function assetUri(r2Key: string): string {
return `${API_BASE}/assets/${r2Key}`;
}
function LayerView({ layer, width, height }: { layer: Layer; width: number; height: number }) {
function LayerView({
layer,
width,
height,
profileUrl,
}: {
layer: Layer;
width: number;
height: number;
profileUrl?: string;
}) {
const frame = {
position: "absolute" as const,
left: layer.x * width,
@@ -89,17 +103,52 @@ function LayerView({ layer, width, height }: { layer: Layer; width: number; heig
</View>
);
}
case "shape": {
const s = layer as ShapeLayer;
const radius =
s.shape === "circle"
? Math.min(frame.width, frame.height)
: s.shape === "line"
? 0
: s.cornerRadius;
return (
<View
style={[
frame,
{
backgroundColor: s.fill,
borderColor: s.stroke,
borderWidth: s.stroke ? s.strokeWidth : 0,
borderRadius: radius,
height: s.shape === "line" ? Math.max(s.strokeWidth, 1) : frame.height,
},
]}
/>
);
}
case "qr":
return (
<View style={frame}>
<QRCodeView value={profileUrl ?? ""} size={Math.min(frame.width, frame.height)} />
</View>
);
default:
return <View style={frame} />;
}
}
export function CardFace({ side, width, height }: Props) {
export function CardFace({ side, width, height, profileUrl }: Props) {
const ordered = [...side.layers].sort((a, b) => a.zIndex - b.zIndex);
return (
<View style={[styles.face, { width, height }, backgroundStyle(side.background)]}>
{ordered.map((layer) => (
<LayerView key={layer.id} layer={layer} width={width} height={height} />
<LayerView
key={layer.id}
layer={layer}
width={width}
height={height}
profileUrl={profileUrl}
/>
))}
</View>
);