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:
co-authored by
Claude Opus 4.8
parent
8ae1d8f263
commit
d0636577dc
@@ -99,6 +99,63 @@ describe("cardStore undo/redo", () => {
|
|||||||
expect(undos).toBe(MAX_HISTORY);
|
expect(undos).toBe(MAX_HISTORY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reorderLayer swaps adjacent z-indexes and is reversible", () => {
|
||||||
|
const a = { ...textLayer("A"), zIndex: 1 };
|
||||||
|
const b = { ...textLayer("B"), zIndex: 2 };
|
||||||
|
useCardStore.getState().addLayer("face", a);
|
||||||
|
useCardStore.getState().addLayer("face", b);
|
||||||
|
|
||||||
|
// Move A up: A and B swap z-indexes → A above B.
|
||||||
|
useCardStore.getState().reorderLayer("face", a.id, "up");
|
||||||
|
const za = () =>
|
||||||
|
useCardStore.getState().card!.face.layers.find((l) => l.id === a.id)!.zIndex;
|
||||||
|
const zb = () =>
|
||||||
|
useCardStore.getState().card!.face.layers.find((l) => l.id === b.id)!.zIndex;
|
||||||
|
expect(za()).toBe(2);
|
||||||
|
expect(zb()).toBe(1);
|
||||||
|
|
||||||
|
useCardStore.getState().undo();
|
||||||
|
expect(za()).toBe(1);
|
||||||
|
expect(zb()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorderLayer past an edge is a no-op", () => {
|
||||||
|
const a = { ...textLayer("A"), zIndex: 1 };
|
||||||
|
useCardStore.getState().addLayer("face", a);
|
||||||
|
const before = useCardStore.getState().past.length;
|
||||||
|
useCardStore.getState().reorderLayer("face", a.id, "down"); // already bottom
|
||||||
|
// The mutation still snapshots, but ordering is unchanged.
|
||||||
|
expect(useCardStore.getState().card!.face.layers[0].zIndex).toBe(1);
|
||||||
|
expect(useCardStore.getState().past.length).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits profile bio and links with undo support", () => {
|
||||||
|
const s = useCardStore.getState();
|
||||||
|
s.setBio("Founder & CEO");
|
||||||
|
expect(useCardStore.getState().card?.profile?.bio).toBe("Founder & CEO");
|
||||||
|
|
||||||
|
useCardStore.getState().addLink({
|
||||||
|
id: "l1",
|
||||||
|
type: "linkedin",
|
||||||
|
label: "LinkedIn",
|
||||||
|
url: "https://linkedin.com/in/omar",
|
||||||
|
iconSlug: "linkedin",
|
||||||
|
});
|
||||||
|
expect(useCardStore.getState().card?.profile?.links).toHaveLength(1);
|
||||||
|
|
||||||
|
useCardStore.getState().updateLink("l1", { label: "My LinkedIn" });
|
||||||
|
expect(useCardStore.getState().card?.profile?.links[0].label).toBe("My LinkedIn");
|
||||||
|
|
||||||
|
useCardStore.getState().removeLink("l1");
|
||||||
|
expect(useCardStore.getState().card?.profile?.links).toHaveLength(0);
|
||||||
|
|
||||||
|
// Undo the removal, then the label edit.
|
||||||
|
useCardStore.getState().undo();
|
||||||
|
expect(useCardStore.getState().card?.profile?.links).toHaveLength(1);
|
||||||
|
useCardStore.getState().undo();
|
||||||
|
expect(useCardStore.getState().card?.profile?.links[0].label).toBe("LinkedIn");
|
||||||
|
});
|
||||||
|
|
||||||
it("undo/redo are no-ops at the ends", () => {
|
it("undo/redo are no-ops at the ends", () => {
|
||||||
expect(() => useCardStore.getState().undo()).not.toThrow();
|
expect(() => useCardStore.getState().undo()).not.toThrow();
|
||||||
expect(() => useCardStore.getState().redo()).not.toThrow();
|
expect(() => useCardStore.getState().redo()).not.toThrow();
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { FeedEvent, GeoCount, getFeed, getGeo, getSummary } from "../../../src/api/analytics";
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<string, string> = {
|
||||||
|
profile_visit: "Profile visit",
|
||||||
|
qr_scan: "QR scan",
|
||||||
|
nfc_tap: "NFC tap",
|
||||||
|
contact_save: "Contact saved",
|
||||||
|
link_click: "Link click",
|
||||||
|
wallet_add: "Added to wallet",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AnalyticsScreen() {
|
||||||
|
const { cardId } = useLocalSearchParams<{ cardId: string }>();
|
||||||
|
|
||||||
|
const summary = useQuery({
|
||||||
|
queryKey: ["analytics", cardId, "summary"],
|
||||||
|
queryFn: () => getSummary(cardId),
|
||||||
|
enabled: !!cardId,
|
||||||
|
});
|
||||||
|
const feed = useQuery({
|
||||||
|
queryKey: ["analytics", cardId, "feed"],
|
||||||
|
queryFn: () => getFeed(cardId),
|
||||||
|
enabled: !!cardId,
|
||||||
|
});
|
||||||
|
const geo = useQuery({
|
||||||
|
queryKey: ["analytics", cardId, "geo"],
|
||||||
|
queryFn: () => getGeo(cardId),
|
||||||
|
enabled: !!cardId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (summary.isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator color="#ff3b30" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = summary.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
|
||||||
|
<Stack.Screen options={{ title: "Analytics" }} />
|
||||||
|
|
||||||
|
<View style={styles.metrics}>
|
||||||
|
<Metric label="Visits (24h)" value={s?.visits24h ?? 0} />
|
||||||
|
<Metric label="Visits (7d)" value={s?.visits7d ?? 0} />
|
||||||
|
<Metric label="Total visits" value={s?.totalVisits ?? 0} />
|
||||||
|
<Metric label="QR scans" value={s?.qrScans ?? 0} />
|
||||||
|
<Metric label="Contact saves" value={s?.contactSaves ?? 0} />
|
||||||
|
<Metric label="Link clicks" value={s?.linkClicks ?? 0} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{geo.data && geo.data.length > 0 && (
|
||||||
|
<Section title="Top countries">
|
||||||
|
{geo.data.slice(0, 5).map((g: GeoCount) => (
|
||||||
|
<Row key={g.country} left={g.country} right={`${g.visits}`} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Recent activity">
|
||||||
|
{(feed.data ?? []).length === 0 ? (
|
||||||
|
<Text style={styles.empty}>No activity yet.</Text>
|
||||||
|
) : (
|
||||||
|
(feed.data ?? []).slice(0, 30).map((e: FeedEvent) => (
|
||||||
|
<Row
|
||||||
|
key={e.id}
|
||||||
|
left={EVENT_LABELS[e.eventType] ?? e.eventType}
|
||||||
|
right={[e.city, e.country].filter(Boolean).join(", ") || "—"}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.metric}>
|
||||||
|
<Text style={styles.metricValue}>{value}</Text>
|
||||||
|
<Text style={styles.metricLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ left, right }: { left: string; right: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.rowItem}>
|
||||||
|
<Text style={styles.rowLeft}>{left}</Text>
|
||||||
|
<Text style={styles.rowRight}>{right}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
||||||
|
content: { padding: 16, gap: 20 },
|
||||||
|
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#0a0a0c" },
|
||||||
|
metrics: { flexDirection: "row", flexWrap: "wrap", gap: 12 },
|
||||||
|
metric: {
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
width: "47%",
|
||||||
|
},
|
||||||
|
metricValue: { color: "#f5f5f7", fontSize: 28, fontWeight: "800" },
|
||||||
|
metricLabel: { color: "#9a9aa0", fontSize: 13, marginTop: 2 },
|
||||||
|
section: { gap: 8 },
|
||||||
|
sectionTitle: { color: "#f5f5f7", fontSize: 17, fontWeight: "700" },
|
||||||
|
rowItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomColor: "#1a1a1f",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
rowLeft: { color: "#f5f5f7", fontSize: 15 },
|
||||||
|
rowRight: { color: "#9a9aa0", fontSize: 14 },
|
||||||
|
empty: { color: "#6b6b70" },
|
||||||
|
});
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Stack, useLocalSearchParams } from "expo-router";
|
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { ActivityIndicator, View } from "react-native";
|
import { useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { getCard } from "../../../src/api/cards";
|
import { getCard } from "../../../src/api/cards";
|
||||||
import { CardViewer } from "../../../src/components/card/CardViewer";
|
import { CardViewer } from "../../../src/components/card/CardViewer";
|
||||||
|
import { ShareSheet } from "../../../src/components/share/ShareSheet";
|
||||||
|
|
||||||
export default function CardViewScreen() {
|
export default function CardViewScreen() {
|
||||||
const { cardId } = useLocalSearchParams<{ cardId: string }>();
|
const { cardId } = useLocalSearchParams<{ cardId: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["card", cardId],
|
queryKey: ["card", cardId],
|
||||||
queryFn: () => getCard(cardId),
|
queryFn: () => getCard(cardId),
|
||||||
@@ -14,14 +19,44 @@ export default function CardViewScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "#0a0a0c" }}>
|
<View style={{ flex: 1, backgroundColor: "#0a0a0c" }}>
|
||||||
<Stack.Screen options={{ title: "", headerTransparent: true }} />
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerRight: () => (
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable onPress={() => router.push(`/card/${cardId}/analytics`)}>
|
||||||
|
<Text style={styles.headerBtn}>Stats</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => setSharing(true)}>
|
||||||
|
<Text style={[styles.headerBtn, styles.share]}>Share</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{isLoading || !data ? (
|
{isLoading || !data ? (
|
||||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
<View style={styles.center}>
|
||||||
<ActivityIndicator color="#ff3b30" />
|
<ActivityIndicator color="#ff3b30" />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<CardViewer card={data.definition} />
|
<>
|
||||||
|
<CardViewer card={data.definition} />
|
||||||
|
<ShareSheet
|
||||||
|
cardId={cardId}
|
||||||
|
handle={data.handle}
|
||||||
|
visible={sharing}
|
||||||
|
onClose={() => setSharing(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||||
|
actions: { flexDirection: "row", gap: 16 },
|
||||||
|
headerBtn: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 },
|
||||||
|
share: { color: "#ff3b30" },
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Analytics API (PRD §13.5).
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
totalVisits: number;
|
||||||
|
visits7d: number;
|
||||||
|
visits24h: number;
|
||||||
|
qrScans: number;
|
||||||
|
contactSaves: number;
|
||||||
|
linkClicks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedEvent {
|
||||||
|
id: number;
|
||||||
|
eventType: string;
|
||||||
|
shareToken: string | null;
|
||||||
|
country: string | null;
|
||||||
|
city: string | null;
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoCount {
|
||||||
|
country: string;
|
||||||
|
visits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSummary(cardId: string): Promise<AnalyticsSummary> {
|
||||||
|
return (await api.get<AnalyticsSummary>(`/v1/cards/${cardId}/analytics`)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeed(cardId: string): Promise<FeedEvent[]> {
|
||||||
|
return (await api.get<FeedEvent[]>(`/v1/cards/${cardId}/analytics/feed`)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGeo(cardId: string): Promise<GeoCount[]> {
|
||||||
|
return (await api.get<GeoCount[]>(`/v1/cards/${cardId}/analytics/geo`)).data;
|
||||||
|
}
|
||||||
@@ -42,3 +42,9 @@ export async function publishCard(id: string): Promise<CardRecord> {
|
|||||||
export function appleWalletUrl(id: string): string {
|
export function appleWalletUrl(id: string): string {
|
||||||
return `${api.defaults.baseURL}/v1/cards/${id}/wallet/apple`;
|
return `${api.defaults.baseURL}/v1/cards/${id}/wallet/apple`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch the "Add to Google Wallet" save URL for a card. */
|
||||||
|
export async function googleWalletSaveUrl(id: string): Promise<string> {
|
||||||
|
const res = await api.post<{ saveUrl: string }>(`/v1/cards/${id}/wallet/google`);
|
||||||
|
return res.data.saveUrl;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
|||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
export const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? "http://localhost:8080";
|
export const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? "http://localhost:8080";
|
||||||
|
/** Public profile base, e.g. `https://cardclaws.com` — used to build QR/profile URLs. */
|
||||||
|
export const PROFILE_BASE = process.env.EXPO_PUBLIC_PROFILE_BASE ?? "https://cardclaws.com";
|
||||||
|
|
||||||
export const api = axios.create({ baseURL: API_BASE });
|
export const api = axios.create({ baseURL: API_BASE });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Share-link API (PRD §13.4).
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export type ShareModality =
|
||||||
|
| "nfc"
|
||||||
|
| "qr"
|
||||||
|
| "airdrop"
|
||||||
|
| "imessage"
|
||||||
|
| "email"
|
||||||
|
| "link"
|
||||||
|
| "wallet"
|
||||||
|
| "contact";
|
||||||
|
|
||||||
|
export interface ShareLink {
|
||||||
|
token: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a tracked share link for a card and return its short URL. */
|
||||||
|
export async function createShareLink(
|
||||||
|
cardId: string,
|
||||||
|
modality: ShareModality,
|
||||||
|
campaign?: string,
|
||||||
|
): Promise<ShareLink> {
|
||||||
|
const res = await api.post<ShareLink>(`/v1/cards/${cardId}/share`, {
|
||||||
|
modality,
|
||||||
|
campaign,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ import { useState } from "react";
|
|||||||
import { Pressable, StyleSheet, Text, TextInput, useWindowDimensions, View } from "react-native";
|
import { Pressable, StyleSheet, Text, TextInput, useWindowDimensions, View } from "react-native";
|
||||||
import { CardFace } from "../card/CardFace";
|
import { CardFace } from "../card/CardFace";
|
||||||
import { ColorPaletteEditor } from "./ColorPaletteEditor";
|
import { ColorPaletteEditor } from "./ColorPaletteEditor";
|
||||||
|
import { LayerOrderPanel } from "./LayerOrderPanel";
|
||||||
|
import { LayerPropertySheet } from "./LayerPropertySheet";
|
||||||
|
import { ProfileEditor } from "./ProfileEditor";
|
||||||
import { Side, newLayerId, useCardStore } from "../../stores/cardStore";
|
import { Side, newLayerId, useCardStore } from "../../stores/cardStore";
|
||||||
import { TextLayer } from "../../types/card";
|
import { ShapeLayer, TextLayer } from "../../types/card";
|
||||||
|
|
||||||
export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
|
export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
|
||||||
const { width, height } = useWindowDimensions();
|
const { width, height } = useWindowDimensions();
|
||||||
@@ -20,6 +23,9 @@ export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
|
|||||||
const canRedo = useCardStore((s) => s.canRedo());
|
const canRedo = useCardStore((s) => s.canRedo());
|
||||||
|
|
||||||
const [draftText, setDraftText] = useState("");
|
const [draftText, setDraftText] = useState("");
|
||||||
|
const [orderOpen, setOrderOpen] = useState(false);
|
||||||
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!card) return null;
|
if (!card) return null;
|
||||||
const cardWidth = Math.min(width * 0.9, 360);
|
const cardWidth = Math.min(width * 0.9, 360);
|
||||||
@@ -49,6 +55,24 @@ export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
|
|||||||
setDraftText("");
|
setDraftText("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addShape = () => {
|
||||||
|
const layer: ShapeLayer = {
|
||||||
|
id: newLayerId(),
|
||||||
|
type: "shape",
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.35,
|
||||||
|
height: 0.2,
|
||||||
|
opacity: 1,
|
||||||
|
zIndex: card[side].layers.length + 1,
|
||||||
|
shape: "rectangle",
|
||||||
|
fill: "#ff3b30",
|
||||||
|
strokeWidth: 0,
|
||||||
|
cornerRadius: 12,
|
||||||
|
};
|
||||||
|
addLayer(side, layer);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
<View style={styles.canvasArea}>
|
<View style={styles.canvasArea}>
|
||||||
@@ -74,6 +98,21 @@ export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
|
|||||||
<ToolButton label="↶" onPress={undo} disabled={!canUndo} />
|
<ToolButton label="↶" onPress={undo} disabled={!canUndo} />
|
||||||
<ToolButton label="↷" onPress={redo} disabled={!canRedo} />
|
<ToolButton label="↷" onPress={redo} disabled={!canRedo} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.secondaryBar}>
|
||||||
|
<ToolButton label="Shape" onPress={addShape} />
|
||||||
|
<ToolButton label="Layers" onPress={() => setOrderOpen(true)} />
|
||||||
|
<ToolButton label="Profile" onPress={() => setProfileOpen(true)} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<LayerOrderPanel
|
||||||
|
side={side}
|
||||||
|
visible={orderOpen}
|
||||||
|
onClose={() => setOrderOpen(false)}
|
||||||
|
onSelect={setSelectedLayerId}
|
||||||
|
/>
|
||||||
|
<LayerPropertySheet side={side} layerId={selectedLayerId} onClose={() => setSelectedLayerId(null)} />
|
||||||
|
<ProfileEditor visible={profileOpen} onClose={() => setProfileOpen(false)} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,6 +146,12 @@ const styles = StyleSheet.create({
|
|||||||
padding: 16,
|
padding: 16,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
|
secondaryBar: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#1a1a1f",
|
backgroundColor: "#1a1a1f",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Layer list with reorder + select (PRD §6.1.5). Layers shown top-to-bottom by
|
||||||
|
// descending z-index (front to back). Tapping a row selects it for editing.
|
||||||
|
|
||||||
|
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { Side, useCardStore } from "../../stores/cardStore";
|
||||||
|
import { Layer, TextLayer } from "../../types/card";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
side: Side;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (layerId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(layer: Layer): string {
|
||||||
|
if (layer.type === "text") return `Text — “${(layer as TextLayer).text}”`;
|
||||||
|
return layer.type.charAt(0).toUpperCase() + layer.type.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayerOrderPanel({ side, visible, onClose, onSelect }: Props) {
|
||||||
|
const card = useCardStore((s) => s.card);
|
||||||
|
const reorderLayer = useCardStore((s) => s.reorderLayer);
|
||||||
|
|
||||||
|
const layers = [...(card?.[side].layers ?? [])].sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||||
|
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
|
||||||
|
<View style={styles.grabber} />
|
||||||
|
<Text style={styles.title}>Layers</Text>
|
||||||
|
<ScrollView>
|
||||||
|
{layers.length === 0 && <Text style={styles.empty}>No layers yet.</Text>}
|
||||||
|
{layers.map((layer) => (
|
||||||
|
<View key={layer.id} style={styles.row}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.rowLabel}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(layer.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.rowText} numberOfLines={1}>
|
||||||
|
{describe(layer)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => reorderLayer(side, layer.id, "up")} hitSlop={8}>
|
||||||
|
<Text style={styles.move}>↑</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => reorderLayer(side, layer.id, "down")} hitSlop={8}>
|
||||||
|
<Text style={styles.move}>↓</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: 36,
|
||||||
|
maxHeight: "70%",
|
||||||
|
},
|
||||||
|
grabber: {
|
||||||
|
alignSelf: "center",
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: "#3a3a40",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700", marginBottom: 8 },
|
||||||
|
empty: { color: "#6b6b70", paddingVertical: 12 },
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomColor: "#1a1a1f",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
rowLabel: { flex: 1 },
|
||||||
|
rowText: { color: "#f5f5f7", fontSize: 15 },
|
||||||
|
move: { color: "#9a9aa0", fontSize: 22, width: 24, textAlign: "center" },
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Property editor for a selected layer (PRD §6.1.5). Fields vary by layer type;
|
||||||
|
// edits route through cardStore.updateLayer so they're undoable.
|
||||||
|
|
||||||
|
import { Modal, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import { Side, useCardStore } from "../../stores/cardStore";
|
||||||
|
import { ShapeLayer, TextLayer } from "../../types/card";
|
||||||
|
import { ColorPaletteEditor } from "./ColorPaletteEditor";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
side: Side;
|
||||||
|
layerId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayerPropertySheet({ side, layerId, onClose }: Props) {
|
||||||
|
const card = useCardStore((s) => s.card);
|
||||||
|
const updateLayer = useCardStore((s) => s.updateLayer);
|
||||||
|
const removeLayer = useCardStore((s) => s.removeLayer);
|
||||||
|
|
||||||
|
const layer = card?.[side].layers.find((l) => l.id === layerId) ?? null;
|
||||||
|
const palette = card?.palette.colors ?? [];
|
||||||
|
|
||||||
|
const stepOpacity = (delta: number) => {
|
||||||
|
if (!layer) return;
|
||||||
|
const opacity = Math.max(0, Math.min(1, Math.round((layer.opacity + delta) * 10) / 10));
|
||||||
|
updateLayer(side, layer.id, { opacity });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={!!layer} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||||
|
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
|
||||||
|
{layer && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.title}>{layer.type} layer</Text>
|
||||||
|
|
||||||
|
{layer.type === "text" && (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="Text"
|
||||||
|
value={(layer as TextLayer).text}
|
||||||
|
onChangeText={(text) => updateLayer(side, layer.id, { text } as Partial<TextLayer>)}
|
||||||
|
/>
|
||||||
|
<Text style={styles.label}>Color</Text>
|
||||||
|
<ColorPaletteEditor
|
||||||
|
colors={palette}
|
||||||
|
selected={(layer as TextLayer).color}
|
||||||
|
onSelect={(color) => updateLayer(side, layer.id, { color } as Partial<TextLayer>)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layer.type === "shape" && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.label}>Fill</Text>
|
||||||
|
<ColorPaletteEditor
|
||||||
|
colors={palette}
|
||||||
|
selected={(layer as ShapeLayer).fill}
|
||||||
|
onSelect={(fill) => updateLayer(side, layer.id, { fill } as Partial<ShapeLayer>)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<Text style={styles.label}>Opacity {Math.round(layer.opacity * 100)}%</Text>
|
||||||
|
<View style={styles.steppers}>
|
||||||
|
<Stepper label="−" onPress={() => stepOpacity(-0.1)} />
|
||||||
|
<Stepper label="+" onPress={() => stepOpacity(0.1)} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.delete}
|
||||||
|
onPress={() => {
|
||||||
|
removeLayer(side, layer.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.deleteText}>Delete layer</Text>
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (t: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={styles.label}>{label}</Text>
|
||||||
|
<TextInput style={styles.input} value={value} onChangeText={onChangeText} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stepper({ label, onPress }: { label: string; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<Pressable style={styles.stepper} onPress={onPress}>
|
||||||
|
<Text style={styles.stepperText}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: 36,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
title: { color: "#f5f5f7", fontSize: 18, fontWeight: "700", textTransform: "capitalize" },
|
||||||
|
label: { color: "#9a9aa0", fontSize: 14 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: "#222228",
|
||||||
|
color: "#f5f5f7",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
stepRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||||
|
steppers: { flexDirection: "row", gap: 8 },
|
||||||
|
stepper: {
|
||||||
|
backgroundColor: "#222228",
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
stepperText: { color: "#f5f5f7", fontSize: 22, fontWeight: "700" },
|
||||||
|
delete: { paddingVertical: 14, alignItems: "center" },
|
||||||
|
deleteText: { color: "#ff453a", fontWeight: "600", fontSize: 16 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Profile-depth editor (PRD §6.6): bio + links. Edits are undoable via the
|
||||||
|
// cardStore profile actions. Portfolio/testimonials are Pro-tier and land later.
|
||||||
|
|
||||||
|
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import { newLayerId, useCardStore } from "../../stores/cardStore";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LINKS = 12;
|
||||||
|
|
||||||
|
export function ProfileEditor({ visible, onClose }: Props) {
|
||||||
|
const profile = useCardStore((s) => s.card?.profile);
|
||||||
|
const setBio = useCardStore((s) => s.setBio);
|
||||||
|
const addLink = useCardStore((s) => s.addLink);
|
||||||
|
const updateLink = useCardStore((s) => s.updateLink);
|
||||||
|
const removeLink = useCardStore((s) => s.removeLink);
|
||||||
|
|
||||||
|
const links = profile?.links ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||||
|
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
|
||||||
|
<View style={styles.grabber} />
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.title}>Profile</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Bio</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.bio]}
|
||||||
|
multiline
|
||||||
|
maxLength={400}
|
||||||
|
placeholder="A short bio (up to 400 chars)…"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={profile?.bio ?? ""}
|
||||||
|
onChangeText={setBio}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.linksHeader}>
|
||||||
|
<Text style={styles.label}>Links ({links.length}/{MAX_LINKS})</Text>
|
||||||
|
<Pressable
|
||||||
|
disabled={links.length >= MAX_LINKS}
|
||||||
|
onPress={() =>
|
||||||
|
addLink({ id: newLayerId(), type: "website", label: "", url: "", iconSlug: "website" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={[styles.add, links.length >= MAX_LINKS && styles.disabled]}>+ Add</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{links.map((link) => (
|
||||||
|
<View key={link.id} style={styles.linkRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.linkField]}
|
||||||
|
placeholder="Label"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={link.label}
|
||||||
|
onChangeText={(label) => updateLink(link.id, { label })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.linkField]}
|
||||||
|
placeholder="https://…"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
autoCapitalize="none"
|
||||||
|
value={link.url}
|
||||||
|
onChangeText={(url) => updateLink(link.id, { url })}
|
||||||
|
/>
|
||||||
|
<Pressable onPress={() => removeLink(link.id)} hitSlop={8}>
|
||||||
|
<Text style={styles.remove}>✕</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Pressable style={styles.done} onPress={onClose}>
|
||||||
|
<Text style={styles.doneText}>Done</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
paddingTop: 12,
|
||||||
|
maxHeight: "85%",
|
||||||
|
},
|
||||||
|
grabber: {
|
||||||
|
alignSelf: "center",
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: "#3a3a40",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
content: { padding: 20, paddingBottom: 36, gap: 10 },
|
||||||
|
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
|
||||||
|
label: { color: "#9a9aa0", fontSize: 14 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: "#222228",
|
||||||
|
color: "#f5f5f7",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
bio: { minHeight: 80, textAlignVertical: "top" },
|
||||||
|
linksHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 8 },
|
||||||
|
add: { color: "#ff3b30", fontWeight: "600", fontSize: 16 },
|
||||||
|
disabled: { opacity: 0.4 },
|
||||||
|
linkRow: { flexDirection: "row", gap: 8, alignItems: "center" },
|
||||||
|
linkField: { flex: 1 },
|
||||||
|
remove: { color: "#9a9aa0", fontSize: 18, paddingHorizontal: 4 },
|
||||||
|
done: { marginTop: 12, backgroundColor: "#ff3b30", borderRadius: 14, paddingVertical: 16, alignItems: "center" },
|
||||||
|
doneText: { color: "#fff", fontWeight: "700", fontSize: 16 },
|
||||||
|
});
|
||||||
@@ -9,14 +9,18 @@ import {
|
|||||||
ContactLayer,
|
ContactLayer,
|
||||||
Layer,
|
Layer,
|
||||||
LogoLayer,
|
LogoLayer,
|
||||||
|
ShapeLayer,
|
||||||
TextLayer,
|
TextLayer,
|
||||||
} from "../../types/card";
|
} from "../../types/card";
|
||||||
import { API_BASE } from "../../api/client";
|
import { API_BASE } from "../../api/client";
|
||||||
|
import { QRCodeView } from "./QRCodeView";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
side: CardSide;
|
side: CardSide;
|
||||||
width: number;
|
width: number;
|
||||||
height: 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 } {
|
function backgroundStyle(bg: BackgroundConfig): { backgroundColor: string } {
|
||||||
@@ -33,7 +37,17 @@ function assetUri(r2Key: string): string {
|
|||||||
return `${API_BASE}/assets/${r2Key}`;
|
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 = {
|
const frame = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
left: layer.x * width,
|
left: layer.x * width,
|
||||||
@@ -89,17 +103,52 @@ function LayerView({ layer, width, height }: { layer: Layer; width: number; heig
|
|||||||
</View>
|
</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:
|
default:
|
||||||
return <View style={frame} />;
|
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);
|
const ordered = [...side.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.face, { width, height }, backgroundStyle(side.background)]}>
|
<View style={[styles.face, { width, height }, backgroundStyle(side.background)]}>
|
||||||
{ordered.map((layer) => (
|
{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>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Animated, {
|
|||||||
withSequence,
|
withSequence,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { PROFILE_BASE } from "../../api/client";
|
||||||
import { CardDefinition } from "../../types/card";
|
import { CardDefinition } from "../../types/card";
|
||||||
import { CardFace } from "./CardFace";
|
import { CardFace } from "./CardFace";
|
||||||
import { CardFlip } from "./CardFlip";
|
import { CardFlip } from "./CardFlip";
|
||||||
@@ -21,6 +22,7 @@ export function CardViewer({ card }: { card: CardDefinition }) {
|
|||||||
const { width, height } = useWindowDimensions();
|
const { width, height } = useWindowDimensions();
|
||||||
const cardWidth = Math.min(width * 0.92, 380);
|
const cardWidth = Math.min(width * 0.92, 380);
|
||||||
const cardHeight = Math.min(height * 0.72, cardWidth * 1.5);
|
const cardHeight = Math.min(height * 0.72, cardWidth * 1.5);
|
||||||
|
const profileUrl = `${PROFILE_BASE}/${card.handle}`;
|
||||||
|
|
||||||
const entry = useSharedValue(0);
|
const entry = useSharedValue(0);
|
||||||
const float = useSharedValue(0);
|
const float = useSharedValue(0);
|
||||||
@@ -58,8 +60,12 @@ export function CardViewer({ card }: { card: CardDefinition }) {
|
|||||||
durationMs={card.settings.flipDurationMs}
|
durationMs={card.settings.flipDurationMs}
|
||||||
hapticEnabled={card.settings.hapticEnabled}
|
hapticEnabled={card.settings.hapticEnabled}
|
||||||
gesture={card.settings.flipGesture}
|
gesture={card.settings.flipGesture}
|
||||||
front={<CardFace side={card.face} width={cardWidth} height={cardHeight} />}
|
front={
|
||||||
back={<CardFace side={card.back} width={cardWidth} height={cardHeight} />}
|
<CardFace side={card.face} width={cardWidth} height={cardHeight} profileUrl={profileUrl} />
|
||||||
|
}
|
||||||
|
back={
|
||||||
|
<CardFace side={card.back} width={cardWidth} height={cardHeight} profileUrl={profileUrl} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Renders a QR code as a grid of cells from the on-device qrMatrix (PRD §16,
|
||||||
|
// QR layer). Pure RN views — no native QR dependency.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { qrMatrix } from "../../engine/renderer/qrGenerator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
size: number;
|
||||||
|
/** Quiet-zone border in cells (QR spec recommends 4). */
|
||||||
|
quietZone?: number;
|
||||||
|
color?: string;
|
||||||
|
background?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QRCodeView({
|
||||||
|
value,
|
||||||
|
size,
|
||||||
|
quietZone = 2,
|
||||||
|
color = "#000000",
|
||||||
|
background = "#ffffff",
|
||||||
|
}: Props) {
|
||||||
|
const matrix = useMemo(() => qrMatrix(value), [value]);
|
||||||
|
const modules = matrix.length + quietZone * 2;
|
||||||
|
const cell = size / modules;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, { width: size, height: size, backgroundColor: background }]}>
|
||||||
|
{matrix.map((row, r) => (
|
||||||
|
<View key={r} style={styles.row}>
|
||||||
|
{row.map((dark, c) => (
|
||||||
|
<View
|
||||||
|
key={c}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: (c + quietZone) * cell,
|
||||||
|
top: (r + quietZone) * cell,
|
||||||
|
width: cell,
|
||||||
|
height: cell,
|
||||||
|
backgroundColor: dark ? color : "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { borderRadius: 8, overflow: "hidden" },
|
||||||
|
row: { ...StyleSheet.absoluteFillObject },
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Full-screen QR overlay shown for in-person exchange (PRD §6.5.1 QR modality).
|
||||||
|
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { QRCodeView } from "../card/QRCodeView";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
handle: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QRShareOverlay({ url, handle, onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<Pressable style={styles.root} onPress={onClose}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<QRCodeView value={url} size={260} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.handle}>@{handle}</Text>
|
||||||
|
<Text style={styles.hint}>Point a camera here to open the card</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.92)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
card: { backgroundColor: "#fff", padding: 20, borderRadius: 24 },
|
||||||
|
handle: { color: "#f5f5f7", fontSize: 22, fontWeight: "700" },
|
||||||
|
hint: { color: "#9a9aa0", fontSize: 14 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Share modality picker (PRD §6.5.1). Each action first creates a tracked share
|
||||||
|
// link, then performs the action so analytics attribute the visit correctly.
|
||||||
|
//
|
||||||
|
// QR and the OS share sheet (which itself covers AirDrop / iMessage / Email /
|
||||||
|
// Copy) are implemented here with no native dependency. NFC tap and direct
|
||||||
|
// Add-to-Wallet require native modules (react-native-nfc-manager, a PassKit
|
||||||
|
// bridge) and a dev build — they attach in the hardware-integration milestone.
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Linking, Modal, Pressable, Share, StyleSheet, Text, View } from "react-native";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { createShareLink } from "../../api/share";
|
||||||
|
import { googleWalletSaveUrl } from "../../api/cards";
|
||||||
|
import { QRShareOverlay } from "./QRShareOverlay";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cardId: string;
|
||||||
|
handle: string;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareSheet({ cardId, handle, visible, onClose }: Props) {
|
||||||
|
const [qrUrl, setQrUrl] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const showQr = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const link = await createShareLink(cardId, "qr");
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
setQrUrl(link.url);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareLink = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const link = await createShareLink(cardId, "link");
|
||||||
|
await Share.share({ message: link.url, url: link.url });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToGoogleWallet = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const url = await googleWalletSaveUrl(cardId);
|
||||||
|
await Linking.openURL(url);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||||
|
{qrUrl ? (
|
||||||
|
<QRShareOverlay url={qrUrl} handle={handle} onClose={() => setQrUrl(null)} />
|
||||||
|
) : (
|
||||||
|
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||||
|
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
|
||||||
|
<View style={styles.grabber} />
|
||||||
|
<Text style={styles.title}>Share your card</Text>
|
||||||
|
<Action label="Show QR code" onPress={showQr} disabled={busy} />
|
||||||
|
<Action label="Share link…" onPress={shareLink} disabled={busy} />
|
||||||
|
<Action label="Add to Google Wallet" onPress={addToGoogleWallet} disabled={busy} />
|
||||||
|
<Pressable style={styles.cancel} onPress={onClose}>
|
||||||
|
<Text style={styles.cancelText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Action({ label, onPress, disabled }: { label: string; onPress: () => void; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Pressable style={[styles.action, disabled && styles.disabled]} onPress={onPress} disabled={disabled}>
|
||||||
|
<Text style={styles.actionText}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: 36,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
grabber: {
|
||||||
|
alignSelf: "center",
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: "#3a3a40",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700", marginBottom: 8 },
|
||||||
|
action: { backgroundColor: "#222228", borderRadius: 14, paddingVertical: 16, alignItems: "center" },
|
||||||
|
actionText: { color: "#f5f5f7", fontSize: 16, fontWeight: "600" },
|
||||||
|
disabled: { opacity: 0.4 },
|
||||||
|
cancel: { paddingVertical: 16, alignItems: "center" },
|
||||||
|
cancelText: { color: "#9a9aa0", fontSize: 16 },
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
// Zustand store so it is usable both in React and in headless unit tests.
|
// Zustand store so it is usable both in React and in headless unit tests.
|
||||||
|
|
||||||
import { create } from "zustand";
|
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;
|
export const MAX_HISTORY = 50;
|
||||||
|
|
||||||
@@ -21,8 +21,14 @@ interface CardState {
|
|||||||
addLayer: (side: Side, layer: Layer) => void;
|
addLayer: (side: Side, layer: Layer) => void;
|
||||||
updateLayer: (side: Side, layerId: string, patch: Partial<Layer>) => void;
|
updateLayer: (side: Side, layerId: string, patch: Partial<Layer>) => void;
|
||||||
removeLayer: (side: Side, layerId: string) => void;
|
removeLayer: (side: Side, layerId: string) => void;
|
||||||
|
reorderLayer: (side: Side, layerId: string, direction: "up" | "down") => void;
|
||||||
setBackground: (side: Side, background: BackgroundConfig) => 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;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
canUndo: () => boolean;
|
canUndo: () => boolean;
|
||||||
@@ -58,6 +64,16 @@ export const useCardStore = create<CardState>((set, get) => {
|
|||||||
return card;
|
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 {
|
return {
|
||||||
card: null,
|
card: null,
|
||||||
past: [],
|
past: [],
|
||||||
@@ -80,12 +96,45 @@ export const useCardStore = create<CardState>((set, get) => {
|
|||||||
editSide(card, side, (layers) => layers.filter((l) => l.id !== layerId)),
|
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) =>
|
setBackground: (side, background) =>
|
||||||
mutate((card) => {
|
mutate((card) => {
|
||||||
card[side] = { ...card[side], background };
|
card[side] = { ...card[side], background };
|
||||||
return card;
|
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: () => {
|
undo: () => {
|
||||||
const { card, past, future } = get();
|
const { card, past, future } = get();
|
||||||
if (!card || past.length === 0) return;
|
if (!card || past.length === 0) return;
|
||||||
|
|||||||
@@ -66,7 +66,17 @@ export interface ContactLayer extends BaseLayer {
|
|||||||
fields: ContactFields;
|
fields: ContactFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Layer = TextLayer | LogoLayer | ContactLayer | BaseLayer;
|
export interface ShapeLayer extends BaseLayer {
|
||||||
|
type: "shape";
|
||||||
|
shape: "rectangle" | "circle" | "line";
|
||||||
|
fill?: string;
|
||||||
|
stroke?: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
cornerRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `qr` layer carries only geometry; its content is the card's profile URL. */
|
||||||
|
export type Layer = TextLayer | LogoLayer | ContactLayer | ShapeLayer | BaseLayer;
|
||||||
|
|
||||||
export interface GradientStop {
|
export interface GradientStop {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -105,6 +115,21 @@ export interface CardSettings {
|
|||||||
hapticEnabled: boolean;
|
hapticEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileLink {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
iconSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileData {
|
||||||
|
bio: string;
|
||||||
|
avatarR2Key?: string | null;
|
||||||
|
links: ProfileLink[];
|
||||||
|
contactFormEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardDefinition {
|
export interface CardDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@@ -114,6 +139,7 @@ export interface CardDefinition {
|
|||||||
back: CardSide;
|
back: CardSide;
|
||||||
palette: ColorPalette;
|
palette: ColorPalette;
|
||||||
settings: CardSettings;
|
settings: CardSettings;
|
||||||
|
profile?: ProfileData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: CardSettings = {
|
export const DEFAULT_SETTINGS: CardSettings = {
|
||||||
|
|||||||
Reference in New Issue
Block a user