Phase 1 Foundations: onboarding profile, nav padding, backpack views, AI persona
- profileStore (MMKV) + pure profileLogic (types, options, isProfileComplete, buildPersona). Rich first-run onboarding (app/onboarding.tsx): name/role, industry, vibes (multi), goal, brand colors, social links, inspirations. - First-launch gate in (tabs)/_layout via <Redirect> guarded by a hydration flag; onboarding registered in the root stack. - Bottom tab bar made taller with safe-area bottom padding. - Backpack (Cards tab): Grid / Showcase view toggle + add-new entry. - Business cards skip data entry: demo.tsx prefills name/title/links from the profile (creative tools lead). - Persona → AI: SceneBrief/refine_prompt gain an optional persona (role/vibe/ colors/goal); mobile generators pass buildPersona(profile). FakeAiClient echoes the role. genwizard gains MultiChips. - Tests: mobile 28 (new profileLogic), backend 121 (new refine_includes_persona). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e0b6200091
commit
8865dc9683
@@ -8,7 +8,7 @@
|
|||||||
//! Behind an [`AiClient`] trait so handlers/tests don't depend on the network.
|
//! Behind an [`AiClient`] trait so handlers/tests don't depend on the network.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const GEMINI_BASE: &str = "https://generativelanguage.googleapis.com/v1beta";
|
const GEMINI_BASE: &str = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
const TEXT_MODEL: &str = "gemini-2.5-flash";
|
const TEXT_MODEL: &str = "gemini-2.5-flash";
|
||||||
@@ -25,12 +25,23 @@ pub enum AiError {
|
|||||||
Empty(&'static str),
|
Empty(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's persona (from their onboarding profile) used to tailor generation.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Persona {
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub vibe: Option<String>,
|
||||||
|
pub colors: Option<Vec<String>>,
|
||||||
|
pub goal: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Rough fields collected by the "Describe your scene" wizard.
|
/// Rough fields collected by the "Describe your scene" wizard.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct SceneBrief {
|
pub struct SceneBrief {
|
||||||
pub scene: String,
|
pub scene: String,
|
||||||
pub style: Option<String>,
|
pub style: Option<String>,
|
||||||
pub mood: Option<String>,
|
pub mood: Option<String>,
|
||||||
|
pub persona: Option<Persona>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A generated image as base64 plus its MIME type.
|
/// A generated image as base64 plus its MIME type.
|
||||||
@@ -109,10 +120,11 @@ impl AiClient for GeminiClient {
|
|||||||
let instruction = format!(
|
let instruction = format!(
|
||||||
"You are helping design a digital business card image. Turn the \
|
"You are helping design a digital business card image. Turn the \
|
||||||
following into ONE vivid, concrete image-generation prompt (2-3 \
|
following into ONE vivid, concrete image-generation prompt (2-3 \
|
||||||
sentences, no preamble, no quotes).\nScene: {}\nStyle: {}\nMood: {}",
|
sentences, no preamble, no quotes).\nScene: {}\nStyle: {}\nMood: {}{}",
|
||||||
brief.scene,
|
brief.scene,
|
||||||
brief.style.as_deref().unwrap_or("(any)"),
|
brief.style.as_deref().unwrap_or("(any)"),
|
||||||
brief.mood.as_deref().unwrap_or("(any)"),
|
brief.mood.as_deref().unwrap_or("(any)"),
|
||||||
|
persona_clause(brief.persona.as_ref()),
|
||||||
);
|
);
|
||||||
let json = self.generate_content(TEXT_MODEL, &instruction).await?;
|
let json = self.generate_content(TEXT_MODEL, &instruction).await?;
|
||||||
first_text(&json).ok_or(AiError::Empty("text"))
|
first_text(&json).ok_or(AiError::Empty("text"))
|
||||||
@@ -192,6 +204,22 @@ impl AiClient for GeminiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the persona suffix for the refine instruction (empty when no persona).
|
||||||
|
fn persona_clause(persona: Option<&Persona>) -> String {
|
||||||
|
match persona {
|
||||||
|
None => String::new(),
|
||||||
|
Some(p) => format!(
|
||||||
|
"\nPersona (bias the image to fit this person): role={}; aesthetic vibe={}; \
|
||||||
|
brand colors={}; goal={}. Weave the brand colors and vibe into the composition; \
|
||||||
|
do not render any text.",
|
||||||
|
p.role.as_deref().unwrap_or("(any)"),
|
||||||
|
p.vibe.as_deref().unwrap_or("(any)"),
|
||||||
|
p.colors.as_ref().map(|c| c.join(", ")).unwrap_or_default(),
|
||||||
|
p.goal.as_deref().unwrap_or("(any)"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract the first text part from a generateContent response.
|
/// Extract the first text part from a generateContent response.
|
||||||
fn first_text(json: &serde_json::Value) -> Option<String> {
|
fn first_text(json: &serde_json::Value) -> Option<String> {
|
||||||
json["candidates"][0]["content"]["parts"]
|
json["candidates"][0]["content"]["parts"]
|
||||||
@@ -246,10 +274,16 @@ const PIXEL_PNG_B64: &str =
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AiClient for FakeAiClient {
|
impl AiClient for FakeAiClient {
|
||||||
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError> {
|
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError> {
|
||||||
|
let role = brief
|
||||||
|
.persona
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.role.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"A {} {} scene: {}",
|
"A {} {} scene for {}: {}",
|
||||||
brief.mood.as_deref().unwrap_or("striking"),
|
brief.mood.as_deref().unwrap_or("striking"),
|
||||||
brief.style.as_deref().unwrap_or("cinematic"),
|
brief.style.as_deref().unwrap_or("cinematic"),
|
||||||
|
role,
|
||||||
brief.scene,
|
brief.scene,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use base64::Engine;
|
|||||||
use cardclaws_types::AppError;
|
use cardclaws_types::AppError;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::ai::{AiError, SceneBrief, VideoStatus};
|
use crate::ai::{AiError, Persona, SceneBrief, VideoStatus};
|
||||||
use crate::error::ApiResult;
|
use crate::error::ApiResult;
|
||||||
use crate::handlers::analytics::client_ip;
|
use crate::handlers::analytics::client_ip;
|
||||||
use crate::middleware::rate_limit;
|
use crate::middleware::rate_limit;
|
||||||
@@ -23,6 +23,8 @@ pub struct RefineRequest {
|
|||||||
pub style: Option<String>,
|
pub style: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mood: Option<String>,
|
pub mood: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub persona: Option<Persona>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -63,6 +65,7 @@ pub async fn refine(
|
|||||||
scene: req.scene,
|
scene: req.scene,
|
||||||
style: req.style,
|
style: req.style,
|
||||||
mood: req.mood,
|
mood: req.mood,
|
||||||
|
persona: req.persona,
|
||||||
};
|
};
|
||||||
let prompt = state.ai.refine_prompt(&brief).await.map_err(map_ai_err)?;
|
let prompt = state.ai.refine_prompt(&brief).await.map_err(map_ai_err)?;
|
||||||
Ok(Json(RefineResponse { prompt }))
|
Ok(Json(RefineResponse { prompt }))
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub async fn set_welcome(
|
|||||||
scene: prompt.to_string(),
|
scene: prompt.to_string(),
|
||||||
style: style.map(str::to_string),
|
style: style.map(str::to_string),
|
||||||
mood: mood.map(str::to_string),
|
mood: mood.map(str::to_string),
|
||||||
|
persona: None,
|
||||||
};
|
};
|
||||||
let refined = state.ai.refine_prompt(&brief).await.map_err(ai_to_app)?;
|
let refined = state.ai.refine_prompt(&brief).await.map_err(ai_to_app)?;
|
||||||
let img = state.ai.generate_image(&refined).await.map_err(ai_to_app)?;
|
let img = state.ai.generate_image(&refined).await.map_err(ai_to_app)?;
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ async fn refine_returns_a_prompt() {
|
|||||||
assert!(prompt.contains("neon Tokyo street"));
|
assert!(prompt.contains("neon Tokyo street"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refine_includes_persona() {
|
||||||
|
let app = require_app!();
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/ai/refine",
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"scene": "a calm forest at dawn",
|
||||||
|
"persona": { "role": "Founder", "vibe": "Cinematic, Luxe", "colors": ["#ff3b30"], "goal": "networking" }
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
// FakeAiClient echoes the persona role into the refined prompt.
|
||||||
|
assert!(body["prompt"].as_str().unwrap().contains("Founder"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refine_requires_a_scene() {
|
async fn refine_requires_a_scene() {
|
||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
buildPersona,
|
||||||
|
EMPTY_PROFILE,
|
||||||
|
isProfileComplete,
|
||||||
|
Profile,
|
||||||
|
} from "../../src/stores/profileLogic";
|
||||||
|
|
||||||
|
const base: Profile = { ...EMPTY_PROFILE };
|
||||||
|
|
||||||
|
describe("profileLogic", () => {
|
||||||
|
test("isProfileComplete requires a non-empty name", () => {
|
||||||
|
expect(isProfileComplete(base)).toBe(false);
|
||||||
|
expect(isProfileComplete({ ...base, displayName: " " })).toBe(false);
|
||||||
|
expect(isProfileComplete({ ...base, displayName: "Omar" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPersona returns undefined for an empty profile", () => {
|
||||||
|
expect(buildPersona(base)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPersona maps fields and joins vibes, dropping empties", () => {
|
||||||
|
const persona = buildPersona({
|
||||||
|
...base,
|
||||||
|
title: "Founder",
|
||||||
|
vibes: ["Cinematic", "Luxe"],
|
||||||
|
brandColors: ["#ff3b30"],
|
||||||
|
goal: "networking",
|
||||||
|
});
|
||||||
|
expect(persona).toEqual({
|
||||||
|
role: "Founder",
|
||||||
|
vibe: "Cinematic, Luxe",
|
||||||
|
colors: ["#ff3b30"],
|
||||||
|
goal: "networking",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
// robot → work-agent cards
|
// robot → work-agent cards
|
||||||
// cog → settings
|
// cog → settings
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { Tabs } from "expo-router";
|
import { Redirect, Tabs } from "expo-router";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useProfileStore } from "../../src/stores/profileStore";
|
||||||
|
|
||||||
type IconName = keyof typeof MaterialCommunityIcons.glyphMap;
|
type IconName = keyof typeof MaterialCommunityIcons.glyphMap;
|
||||||
|
|
||||||
@@ -15,6 +18,15 @@ function tabIcon(name: IconName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const hydrated = useProfileStore((s) => s.hydrated);
|
||||||
|
const onboarded = useProfileStore((s) => s.onboarded);
|
||||||
|
|
||||||
|
// Wait for MMKV to rehydrate before deciding, then send first-run users to
|
||||||
|
// onboarding (avoids a flash of the tabs).
|
||||||
|
if (!hydrated) return null;
|
||||||
|
if (!onboarded) return <Redirect href="/onboarding" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -23,8 +35,14 @@ export default function TabsLayout() {
|
|||||||
tabBarInactiveTintColor: "#6b6b70",
|
tabBarInactiveTintColor: "#6b6b70",
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: "#0e0e12",
|
backgroundColor: "#0e0e12",
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderTopColor: "#1c1c22",
|
borderTopColor: "#1c1c22",
|
||||||
|
height: 64 + insets.bottom,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 14),
|
||||||
},
|
},
|
||||||
|
tabBarLabelStyle: { fontSize: 11, fontWeight: "600", marginBottom: 2 },
|
||||||
|
tabBarItemStyle: { paddingVertical: 4 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs.Screen name="index" options={{ title: "Cards", tabBarIcon: tabIcon("bag-personal") }} />
|
<Tabs.Screen name="index" options={{ title: "Cards", tabBarIcon: tabIcon("bag-personal") }} />
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// Gallery of cards saved on this device (standalone demo). Clean, chrome-free:
|
// Cards (Backpack): your collection of business cards, with switchable views
|
||||||
// just the cards, auto-arranging by count, plus a New-card button.
|
// (Grid / Showcase) and an always-present "New card" entry.
|
||||||
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
|
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { CardThumb } from "../../src/components/CardThumb";
|
import { CardThumb } from "../../src/components/CardThumb";
|
||||||
import { LocalCard, useLocalCardsStore } from "../../src/stores/localCardsStore";
|
import { LocalCard, useLocalCardsStore } from "../../src/stores/localCardsStore";
|
||||||
|
|
||||||
/// Shrink tiles as the gallery grows: 2 columns → 4 fit a screen, 3 columns →
|
type ViewMode = "grid" | "showcase";
|
||||||
/// ~8 fit, 4 columns beyond that.
|
|
||||||
|
/// Shrink grid tiles as the collection grows.
|
||||||
function columnsFor(count: number): number {
|
function columnsFor(count: number): number {
|
||||||
if (count <= 4) return 2;
|
if (count <= 4) return 2;
|
||||||
if (count <= 9) return 3;
|
if (count <= 9) return 3;
|
||||||
@@ -19,52 +21,100 @@ export default function Gallery() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const cards = useLocalCardsStore((s) => s.cards);
|
const cards = useLocalCardsStore((s) => s.cards);
|
||||||
const columns = columnsFor(cards.length);
|
const [view, setView] = useState<ViewMode>("grid");
|
||||||
|
const columns = view === "showcase" ? 1 : columnsFor(cards.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
|
<View style={[styles.topBar, { paddingTop: insets.top + 10 }]}>
|
||||||
|
<Segmented value={view} onChange={setView} />
|
||||||
|
</View>
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={cards}
|
data={cards}
|
||||||
keyExtractor={(c) => c.id}
|
keyExtractor={(c) => c.id}
|
||||||
// numColumns can't change on the fly without remounting the list.
|
key={`${view}-${columns}`}
|
||||||
key={`cols-${columns}`}
|
|
||||||
numColumns={columns}
|
numColumns={columns}
|
||||||
columnWrapperStyle={cards.length > 0 ? styles.column : undefined}
|
columnWrapperStyle={columns > 1 ? styles.column : undefined}
|
||||||
contentContainerStyle={[styles.list, { paddingTop: insets.top + 12 }]}
|
contentContainerStyle={styles.list}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={styles.emptyTitle}>No cards yet</Text>
|
<Text style={styles.emptyTitle}>No cards yet</Text>
|
||||||
<Text style={styles.emptyHint}>Tap “New card” to create your first.</Text>
|
<Text style={styles.emptyHint}>Tap “New card” to create your first.</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
renderItem={({ item }: { item: LocalCard }) => (
|
ListFooterComponent={
|
||||||
<Pressable style={styles.tile} onPress={() => router.push(`/demo?cardId=${item.id}`)}>
|
view === "showcase" && cards.length > 0 ? (
|
||||||
<CardThumb item={item} style={styles.tileImage} />
|
<Pressable style={styles.addTile} onPress={() => router.push("/demo")}>
|
||||||
<View style={[styles.tileLabel, columns >= 3 && styles.tileLabelCompact]}>
|
<Text style={styles.addTileText}>+ New card</Text>
|
||||||
<Text style={[styles.tileName, columns >= 3 && styles.tileNameCompact]} numberOfLines={1}>
|
</Pressable>
|
||||||
{item.name}
|
) : null
|
||||||
</Text>
|
}
|
||||||
{!!item.title && columns < 4 && (
|
renderItem={({ item }: { item: LocalCard }) =>
|
||||||
<Text style={[styles.tileTitle, columns >= 3 && styles.tileNameCompact]} numberOfLines={1}>
|
view === "showcase" ? (
|
||||||
{item.title}
|
<Pressable style={styles.showTile} onPress={() => router.push(`/demo?cardId=${item.id}`)}>
|
||||||
|
<CardThumb item={item} style={styles.showThumb} />
|
||||||
|
<View style={styles.showOverlay}>
|
||||||
|
<Text style={styles.showName} numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
{!!item.title && (
|
||||||
</View>
|
<Text style={styles.showTitle} numberOfLines={1}>
|
||||||
</Pressable>
|
{item.title}
|
||||||
)}
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Pressable style={styles.tile} onPress={() => router.push(`/demo?cardId=${item.id}`)}>
|
||||||
|
<CardThumb item={item} style={styles.tileImage} />
|
||||||
|
<View style={[styles.tileLabel, columns >= 3 && styles.tileLabelCompact]}>
|
||||||
|
<Text style={[styles.tileName, columns >= 3 && styles.tileNameCompact]} numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
{!!item.title && columns < 4 && (
|
||||||
|
<Text style={[styles.tileTitle, columns >= 3 && styles.tileNameCompact]} numberOfLines={1}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Pressable style={[styles.fab, { bottom: 24 }]} onPress={() => router.push("/demo")}>
|
<Pressable style={[styles.fab, { bottom: insets.bottom + 80 }]} onPress={() => router.push("/demo")}>
|
||||||
<Text style={styles.fabText}>+ New card</Text>
|
<Text style={styles.fabText}>+ New card</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Segmented({ value, onChange }: { value: ViewMode; onChange: (v: ViewMode) => void }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.segment}>
|
||||||
|
{(["grid", "showcase"] as const).map((v) => (
|
||||||
|
<Pressable key={v} onPress={() => onChange(v)} style={[styles.segBtn, value === v && styles.segOn]}>
|
||||||
|
<Text style={[styles.segText, value === v && styles.segTextOn]}>
|
||||||
|
{v === "grid" ? "Grid" : "Showcase"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
||||||
list: { paddingHorizontal: 16, paddingBottom: 96, flexGrow: 1 },
|
topBar: { paddingHorizontal: 16, paddingBottom: 10 },
|
||||||
|
segment: { flexDirection: "row", backgroundColor: "#15151a", borderRadius: 12, padding: 4, gap: 4 },
|
||||||
|
segBtn: { flex: 1, paddingVertical: 9, borderRadius: 9, alignItems: "center" },
|
||||||
|
segOn: { backgroundColor: "#ff3b30" },
|
||||||
|
segText: { color: "#9a9aa0", fontWeight: "600", fontSize: 14 },
|
||||||
|
segTextOn: { color: "#fff" },
|
||||||
|
list: { paddingHorizontal: 16, paddingBottom: 120, flexGrow: 1 },
|
||||||
column: { gap: 12 },
|
column: { gap: 12 },
|
||||||
|
// Grid
|
||||||
tile: { flex: 1, marginBottom: 12, borderRadius: 18, overflow: "hidden", backgroundColor: "#15151a" },
|
tile: { flex: 1, marginBottom: 12, borderRadius: 18, overflow: "hidden", backgroundColor: "#15151a" },
|
||||||
tileImage: { width: "100%", aspectRatio: 2 / 3, overflow: "hidden" },
|
tileImage: { width: "100%", aspectRatio: 2 / 3, overflow: "hidden" },
|
||||||
tileLabel: { padding: 12 },
|
tileLabel: { padding: 12 },
|
||||||
@@ -72,6 +122,28 @@ const styles = StyleSheet.create({
|
|||||||
tileName: { color: "#f5f5f7", fontSize: 16, fontWeight: "700" },
|
tileName: { color: "#f5f5f7", fontSize: 16, fontWeight: "700" },
|
||||||
tileNameCompact: { fontSize: 12 },
|
tileNameCompact: { fontSize: 12 },
|
||||||
tileTitle: { color: "#9a9aa0", fontSize: 13, marginTop: 2 },
|
tileTitle: { color: "#9a9aa0", fontSize: 13, marginTop: 2 },
|
||||||
|
// Showcase
|
||||||
|
showTile: { marginBottom: 16, borderRadius: 22, overflow: "hidden", backgroundColor: "#15151a" },
|
||||||
|
showThumb: { width: "100%", aspectRatio: 3 / 4, overflow: "hidden" },
|
||||||
|
showOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 18,
|
||||||
|
backgroundColor: "rgba(10,10,12,0.72)",
|
||||||
|
},
|
||||||
|
showName: { color: "#fff", fontSize: 22, fontWeight: "800" },
|
||||||
|
showTitle: { color: "#cfcfd4", fontSize: 14, marginTop: 2 },
|
||||||
|
addTile: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#2a2a30",
|
||||||
|
borderStyle: "dashed",
|
||||||
|
borderRadius: 22,
|
||||||
|
paddingVertical: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
addTileText: { color: "#f5f5f7", fontWeight: "700", fontSize: 16 },
|
||||||
empty: { flex: 1, alignItems: "center", justifyContent: "center", paddingTop: 120, gap: 6 },
|
empty: { flex: 1, alignItems: "center", justifyContent: "center", paddingTop: 120, gap: 6 },
|
||||||
emptyTitle: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
|
emptyTitle: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
|
||||||
emptyHint: { color: "#6b6b70" },
|
emptyHint: { color: "#6b6b70" },
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ export default function RootLayout() {
|
|||||||
contentStyle: { backgroundColor: "#0a0a0c" },
|
contentStyle: { backgroundColor: "#0a0a0c" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* The tab bar owns its own chrome; no stack header above it. */}
|
{/* The tab bar + onboarding own their own chrome; no stack header. */}
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { defaultLinks, LinksEditor } from "../src/components/LinksEditor";
|
|||||||
import { newLayerId } from "../src/stores/cardStore";
|
import { newLayerId } from "../src/stores/cardStore";
|
||||||
import { useDraftStore } from "../src/stores/draftStore";
|
import { useDraftStore } from "../src/stores/draftStore";
|
||||||
import { CardLink, useLocalCardsStore } from "../src/stores/localCardsStore";
|
import { CardLink, useLocalCardsStore } from "../src/stores/localCardsStore";
|
||||||
|
import { useProfileStore } from "../src/stores/profileStore";
|
||||||
import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card";
|
import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card";
|
||||||
import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore";
|
import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore";
|
||||||
|
|
||||||
@@ -105,13 +106,18 @@ export default function CardEditorScreen() {
|
|||||||
const getById = useLocalCardsStore((s) => s.getById);
|
const getById = useLocalCardsStore((s) => s.getById);
|
||||||
|
|
||||||
// A stable id for the lifetime of this editor (new card or the one we're editing).
|
// A stable id for the lifetime of this editor (new card or the one we're editing).
|
||||||
|
// New cards prefill from the user's profile (so there's no data entry); the
|
||||||
|
// edit path (cardId) overrides these in the effect below.
|
||||||
|
const profile = useProfileStore((s) => s.profile);
|
||||||
const [id] = useState(() => cardId ?? newLayerId());
|
const [id] = useState(() => cardId ?? newLayerId());
|
||||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||||
const [videoUri, setVideoUri] = useState<string | null>(null);
|
const [videoUri, setVideoUri] = useState<string | null>(null);
|
||||||
const [name, setName] = useState("Omar Sobh");
|
const [name, setName] = useState(() => (cardId ? "" : profile.displayName));
|
||||||
const [title, setTitle] = useState("Founder & CEO");
|
const [title, setTitle] = useState(() => (cardId ? "" : profile.title));
|
||||||
const [url, setUrl] = useState("https://cardclaws.com/omar");
|
const [url, setUrl] = useState("");
|
||||||
const [links, setLinks] = useState<CardLink[]>(() => defaultLinks());
|
const [links, setLinks] = useState<CardLink[]>(() =>
|
||||||
|
cardId ? [] : profile.socialLinks.length > 0 ? profile.socialLinks : defaultLinks(),
|
||||||
|
);
|
||||||
const [showing, setShowing] = useState(false);
|
const [showing, setShowing] = useState(false);
|
||||||
const [onBack, setOnBack] = useState(false);
|
const [onBack, setOnBack] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -303,6 +309,7 @@ export default function CardEditorScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionLabel}>Details · prefilled from your profile</Text>
|
||||||
<Field label="Name" value={name} onChangeText={setName} />
|
<Field label="Name" value={name} onChangeText={setName} />
|
||||||
<Field label="Title" value={title} onChangeText={setTitle} />
|
<Field label="Title" value={title} onChangeText={setTitle} />
|
||||||
<Field label="QR link" value={url} onChangeText={setUrl} autoCapitalize="none" />
|
<Field label="QR link" value={url} onChangeText={setUrl} autoCapitalize="none" />
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
import { generateImage, refineScene } from "../src/api/ai";
|
import { generateImage, refineScene } from "../src/api/ai";
|
||||||
import { Chips, wizardInputStyle } from "../src/components/genwizard/Wizard";
|
import { Chips, wizardInputStyle } from "../src/components/genwizard/Wizard";
|
||||||
import { useDraftStore } from "../src/stores/draftStore";
|
import { useDraftStore } from "../src/stores/draftStore";
|
||||||
|
import { buildPersona } from "../src/stores/profileLogic";
|
||||||
|
import { useProfileStore } from "../src/stores/profileStore";
|
||||||
|
|
||||||
const STYLES = ["Cinematic", "Minimal", "Neon", "Studio portrait", "Nature", "Abstract"];
|
const STYLES = ["Cinematic", "Minimal", "Neon", "Studio portrait", "Nature", "Abstract"];
|
||||||
const MOODS = ["Bold", "Calm", "Luxe", "Playful", "Dark"];
|
const MOODS = ["Bold", "Calm", "Luxe", "Playful", "Dark"];
|
||||||
@@ -24,6 +26,7 @@ const MOODS = ["Bold", "Calm", "Luxe", "Playful", "Dark"];
|
|||||||
export default function GenerateImage() {
|
export default function GenerateImage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setPendingImageUri = useDraftStore((s) => s.setPendingImageUri);
|
const setPendingImageUri = useDraftStore((s) => s.setPendingImageUri);
|
||||||
|
const profile = useProfileStore((s) => s.profile);
|
||||||
const [scene, setScene] = useState("");
|
const [scene, setScene] = useState("");
|
||||||
const [style, setStyle] = useState("");
|
const [style, setStyle] = useState("");
|
||||||
const [mood, setMood] = useState("");
|
const [mood, setMood] = useState("");
|
||||||
@@ -40,6 +43,7 @@ export default function GenerateImage() {
|
|||||||
scene,
|
scene,
|
||||||
style: style || undefined,
|
style: style || undefined,
|
||||||
mood: mood || undefined,
|
mood: mood || undefined,
|
||||||
|
persona: buildPersona(profile),
|
||||||
});
|
});
|
||||||
const img = await generateImage(prompt);
|
const img = await generateImage(prompt);
|
||||||
const path = `${FileSystem.cacheDirectory}ai-${Date.now()}.png`;
|
const path = `${FileSystem.cacheDirectory}ai-${Date.now()}.png`;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
import { pollVideo, startVideo } from "../src/api/ai";
|
import { pollVideo, startVideo } from "../src/api/ai";
|
||||||
import { Chips, wizardInputStyle } from "../src/components/genwizard/Wizard";
|
import { Chips, wizardInputStyle } from "../src/components/genwizard/Wizard";
|
||||||
import { useDraftStore } from "../src/stores/draftStore";
|
import { useDraftStore } from "../src/stores/draftStore";
|
||||||
|
import { buildPersona } from "../src/stores/profileLogic";
|
||||||
|
import { useProfileStore } from "../src/stores/profileStore";
|
||||||
|
|
||||||
const MOTION = ["Parallax", "Particles", "Slow zoom", "Liquid", "Aurora", "Glitch"];
|
const MOTION = ["Parallax", "Particles", "Slow zoom", "Liquid", "Aurora", "Glitch"];
|
||||||
const LENGTH = ["3 seconds", "5 seconds", "8 seconds"];
|
const LENGTH = ["3 seconds", "5 seconds", "8 seconds"];
|
||||||
@@ -26,6 +28,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|||||||
export default function GenerateVideo() {
|
export default function GenerateVideo() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setPendingVideoUri = useDraftStore((s) => s.setPendingVideoUri);
|
const setPendingVideoUri = useDraftStore((s) => s.setPendingVideoUri);
|
||||||
|
const profile = useProfileStore((s) => s.profile);
|
||||||
const [concept, setConcept] = useState("");
|
const [concept, setConcept] = useState("");
|
||||||
const [motion, setMotion] = useState("");
|
const [motion, setMotion] = useState("");
|
||||||
const [length, setLength] = useState("");
|
const [length, setLength] = useState("");
|
||||||
@@ -38,7 +41,11 @@ export default function GenerateVideo() {
|
|||||||
}
|
}
|
||||||
const m = motion ? `${motion.toLowerCase()} ` : "";
|
const m = motion ? `${motion.toLowerCase()} ` : "";
|
||||||
const len = length ? ` (${length})` : "";
|
const len = length ? ` (${length})` : "";
|
||||||
const prompt = `A ${m}motion clip for a digital business card: ${concept}${len}.`
|
const persona = buildPersona(profile);
|
||||||
|
const personaPrefix = persona
|
||||||
|
? `For a ${persona.role ?? "professional"}${persona.vibe ? ` (${persona.vibe} aesthetic)` : ""}: `
|
||||||
|
: "";
|
||||||
|
const prompt = `${personaPrefix}A ${m}motion clip for a digital business card: ${concept}${len}.`
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// First-run onboarding: builds a rich user profile that personalizes AI assets
|
||||||
|
// and prefills business cards. Shown once (gated by profileStore.onboarded).
|
||||||
|
|
||||||
|
import { Redirect, Stack, useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Chips, MultiChips, wizardInputStyle } from "../src/components/genwizard/Wizard";
|
||||||
|
import { LinksEditor } from "../src/components/LinksEditor";
|
||||||
|
import { CardLink } from "../src/stores/localCardsStore";
|
||||||
|
import {
|
||||||
|
BRAND_COLOR_SWATCHES,
|
||||||
|
EMPTY_PROFILE,
|
||||||
|
Goal,
|
||||||
|
GOAL_OPTIONS,
|
||||||
|
INDUSTRY_OPTIONS,
|
||||||
|
INSPIRATION_OPTIONS,
|
||||||
|
Profile,
|
||||||
|
VIBE_OPTIONS,
|
||||||
|
} from "../src/stores/profileLogic";
|
||||||
|
import { useProfileStore } from "../src/stores/profileStore";
|
||||||
|
|
||||||
|
const STEPS = 8;
|
||||||
|
|
||||||
|
export default function Onboarding() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const onboarded = useProfileStore((s) => s.onboarded);
|
||||||
|
const completeOnboarding = useProfileStore((s) => s.completeOnboarding);
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [p, setP] = useState<Profile>(EMPTY_PROFILE);
|
||||||
|
const patch = (u: Partial<Profile>) => setP((cur) => ({ ...cur, ...u }));
|
||||||
|
const toggle = (key: "vibes" | "inspirations" | "brandColors", v: string, max?: number) =>
|
||||||
|
setP((cur) => {
|
||||||
|
const has = cur[key].includes(v);
|
||||||
|
let next = has ? cur[key].filter((x) => x !== v) : [...cur[key], v];
|
||||||
|
if (!has && max && next.length > max) next = next.slice(next.length - max);
|
||||||
|
return { ...cur, [key]: next };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onboarded) return <Redirect href="/(tabs)" />;
|
||||||
|
|
||||||
|
const canNext = step !== 0 || p.displayName.trim().length > 0;
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
completeOnboarding(p);
|
||||||
|
router.replace("/(tabs)");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, { paddingTop: insets.top + 8 }]}>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{Array.from({ length: STEPS }).map((_, i) => (
|
||||||
|
<View key={i} style={[styles.dot, i === step && styles.dotOn, i < step && styles.dotDone]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||||
|
{step === 0 && (
|
||||||
|
<Step title="Welcome 👋" subtitle="Let's set up your profile so the AI can tailor your cards.">
|
||||||
|
<Text style={styles.label}>Your name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="e.g. Omar Sobh"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={p.displayName}
|
||||||
|
onChangeText={(t) => patch({ displayName: t })}
|
||||||
|
/>
|
||||||
|
<Text style={styles.label}>Role / title</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="e.g. Founder & CEO"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={p.title}
|
||||||
|
onChangeText={(t) => patch({ title: t })}
|
||||||
|
/>
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Step title="Your industry" subtitle="Helps frame the look.">
|
||||||
|
<Chips options={INDUSTRY_OPTIONS} value={p.industry} onSelect={(v) => patch({ industry: v })} />
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<Step title="Your aesthetic" subtitle="Pick the vibes you like — choose a few.">
|
||||||
|
<MultiChips options={VIBE_OPTIONS} values={p.vibes} onToggle={(v) => toggle("vibes", v)} />
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<Step title="Primary goal" subtitle="What are these cards for?">
|
||||||
|
<Chips
|
||||||
|
options={GOAL_OPTIONS.map((g) => g.label)}
|
||||||
|
value={GOAL_OPTIONS.find((g) => g.value === p.goal)?.label ?? ""}
|
||||||
|
onSelect={(label) =>
|
||||||
|
patch({ goal: (GOAL_OPTIONS.find((g) => g.label === label)?.value ?? "") as Goal })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<Step title="Brand colors" subtitle="Pick up to 3 — we'll weave them into your art.">
|
||||||
|
<View style={styles.swatches}>
|
||||||
|
{BRAND_COLOR_SWATCHES.map((c) => (
|
||||||
|
<Pressable
|
||||||
|
key={c}
|
||||||
|
onPress={() => toggle("brandColors", c, 3)}
|
||||||
|
style={[
|
||||||
|
styles.swatch,
|
||||||
|
{ backgroundColor: c },
|
||||||
|
p.brandColors.includes(c) && styles.swatchOn,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<Step title="Social links" subtitle="Add any you want on your cards (optional).">
|
||||||
|
<LinksEditor links={p.socialLinks} onChange={(l: CardLink[]) => patch({ socialLinks: l })} />
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 6 && (
|
||||||
|
<Step title="Inspiration" subtitle="Any looks you're drawn to? (up to 2)">
|
||||||
|
<MultiChips
|
||||||
|
options={INSPIRATION_OPTIONS}
|
||||||
|
values={p.inspirations}
|
||||||
|
onToggle={(v) => toggle("inspirations", v, 2)}
|
||||||
|
/>
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 7 && (
|
||||||
|
<Step title="You're set 🎉" subtitle="You can tweak any of this later in Settings.">
|
||||||
|
<View style={styles.review}>
|
||||||
|
<ReviewRow label="Name" value={p.displayName || "—"} />
|
||||||
|
<ReviewRow label="Role" value={p.title || "—"} />
|
||||||
|
<ReviewRow label="Industry" value={p.industry || "—"} />
|
||||||
|
<ReviewRow label="Vibes" value={p.vibes.join(", ") || "—"} />
|
||||||
|
<ReviewRow label="Goal" value={GOAL_OPTIONS.find((g) => g.value === p.goal)?.label ?? "—"} />
|
||||||
|
<ReviewRow label="Colors" value={`${p.brandColors.length} picked`} />
|
||||||
|
<ReviewRow label="Links" value={`${p.socialLinks.filter((l) => l.url).length}`} />
|
||||||
|
</View>
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={[styles.footer, { paddingBottom: insets.bottom + 16 }]}>
|
||||||
|
{step > 0 && (
|
||||||
|
<Pressable style={styles.back} onPress={() => setStep(step - 1)}>
|
||||||
|
<Text style={styles.backText}>Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<Pressable
|
||||||
|
style={[styles.next, !canNext && styles.disabled]}
|
||||||
|
disabled={!canNext}
|
||||||
|
onPress={() => (step === STEPS - 1 ? finish() : setStep(step + 1))}
|
||||||
|
>
|
||||||
|
<Text style={styles.nextText}>{step === STEPS - 1 ? "Get started" : "Next"}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
<Text style={styles.h1}>{title}</Text>
|
||||||
|
<Text style={styles.sub}>{subtitle}</Text>
|
||||||
|
<View style={{ marginTop: 8 }}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.reviewRow}>
|
||||||
|
<Text style={styles.reviewLabel}>{label}</Text>
|
||||||
|
<Text style={styles.reviewValue} numberOfLines={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
||||||
|
dots: { flexDirection: "row", gap: 6, paddingHorizontal: 20, paddingBottom: 8 },
|
||||||
|
dot: { flex: 1, height: 4, borderRadius: 2, backgroundColor: "#222228" },
|
||||||
|
dotOn: { backgroundColor: "#ff3b30" },
|
||||||
|
dotDone: { backgroundColor: "#7a2620" },
|
||||||
|
content: { padding: 20, paddingBottom: 40 },
|
||||||
|
h1: { color: "#f5f5f7", fontSize: 28, fontWeight: "800" },
|
||||||
|
sub: { color: "#9a9aa0", fontSize: 15 },
|
||||||
|
label: { color: "#9a9aa0", fontSize: 14, marginTop: 8 },
|
||||||
|
input: { ...wizardInputStyle, minHeight: 0 },
|
||||||
|
swatches: { flexDirection: "row", flexWrap: "wrap", gap: 14 },
|
||||||
|
swatch: { width: 48, height: 48, borderRadius: 24, borderWidth: 3, borderColor: "transparent" },
|
||||||
|
swatchOn: { borderColor: "#ffffff" },
|
||||||
|
review: { backgroundColor: "#15151a", borderRadius: 16, paddingHorizontal: 16 },
|
||||||
|
reviewRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: "#22222a",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
reviewLabel: { color: "#9a9aa0", fontSize: 14 },
|
||||||
|
reviewValue: { color: "#f5f5f7", fontSize: 14, fontWeight: "600", flexShrink: 1 },
|
||||||
|
footer: { flexDirection: "row", gap: 12, paddingHorizontal: 20, paddingTop: 12 },
|
||||||
|
back: {
|
||||||
|
backgroundColor: "#222228",
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
backText: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 },
|
||||||
|
next: { flex: 1, backgroundColor: "#ff3b30", borderRadius: 16, paddingVertical: 16, alignItems: "center" },
|
||||||
|
disabled: { opacity: 0.4 },
|
||||||
|
nextText: { color: "#fff", fontWeight: "700", fontSize: 17 },
|
||||||
|
});
|
||||||
@@ -3,10 +3,19 @@
|
|||||||
|
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export interface Persona {
|
||||||
|
role?: string;
|
||||||
|
vibe?: string;
|
||||||
|
colors?: string[];
|
||||||
|
goal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SceneBrief {
|
export interface SceneBrief {
|
||||||
scene: string;
|
scene: string;
|
||||||
style?: string;
|
style?: string;
|
||||||
mood?: string;
|
mood?: string;
|
||||||
|
/** User persona (from their profile) to tailor the result. */
|
||||||
|
persona?: Persona;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refine the rough fields into a single vivid prompt (Gemini text). */
|
/** Refine the rough fields into a single vivid prompt (Gemini text). */
|
||||||
|
|||||||
@@ -39,6 +39,30 @@ export function Chips({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Multi-select chip row (sibling of Chips; same styling). */
|
||||||
|
export function MultiChips({
|
||||||
|
options,
|
||||||
|
values,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
options: string[];
|
||||||
|
values: string[];
|
||||||
|
onToggle: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.chips}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const on = values.includes(o);
|
||||||
|
return (
|
||||||
|
<Pressable key={o} onPress={() => onToggle(o)} style={[styles.chip, on && styles.chipOn]}>
|
||||||
|
<Text style={[styles.chipText, on && styles.chipTextOn]}>{o}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Wizard({
|
export function Wizard({
|
||||||
title,
|
title,
|
||||||
steps,
|
steps,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Pure profile logic + option lists (no MMKV import, so it's unit-testable under
|
||||||
|
// the node jest preset). The MMKV-bound store lives in profileStore.ts.
|
||||||
|
|
||||||
|
import type { CardLink } from "./localCardsStore";
|
||||||
|
|
||||||
|
export type Goal = "networking" | "sales" | "creator" | "recruiting";
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
displayName: string;
|
||||||
|
title: string;
|
||||||
|
industry: string;
|
||||||
|
vibes: string[];
|
||||||
|
goal: Goal | "";
|
||||||
|
brandColors: string[];
|
||||||
|
socialLinks: CardLink[];
|
||||||
|
inspirations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_PROFILE: Profile = {
|
||||||
|
displayName: "",
|
||||||
|
title: "",
|
||||||
|
industry: "",
|
||||||
|
vibes: [],
|
||||||
|
goal: "",
|
||||||
|
brandColors: [],
|
||||||
|
socialLinks: [],
|
||||||
|
inspirations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIBE_OPTIONS = [
|
||||||
|
"Cinematic",
|
||||||
|
"Minimal",
|
||||||
|
"Neon",
|
||||||
|
"Luxe",
|
||||||
|
"Studio",
|
||||||
|
"Nature",
|
||||||
|
"Abstract",
|
||||||
|
"Playful",
|
||||||
|
"Dark",
|
||||||
|
"Bold",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GOAL_OPTIONS: { value: Goal; label: string }[] = [
|
||||||
|
{ value: "networking", label: "Networking" },
|
||||||
|
{ value: "sales", label: "Sales" },
|
||||||
|
{ value: "creator", label: "Creator" },
|
||||||
|
{ value: "recruiting", label: "Recruiting" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INDUSTRY_OPTIONS = [
|
||||||
|
"Tech",
|
||||||
|
"Design",
|
||||||
|
"Finance",
|
||||||
|
"Healthcare",
|
||||||
|
"Real estate",
|
||||||
|
"Marketing",
|
||||||
|
"Music",
|
||||||
|
"Film",
|
||||||
|
"Education",
|
||||||
|
"Other",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BRAND_COLOR_SWATCHES = [
|
||||||
|
"#ff3b30",
|
||||||
|
"#ff9f0a",
|
||||||
|
"#ffd60a",
|
||||||
|
"#30d158",
|
||||||
|
"#0a84ff",
|
||||||
|
"#5e5ce6",
|
||||||
|
"#bf5af2",
|
||||||
|
"#ff2d55",
|
||||||
|
"#f5f5f7",
|
||||||
|
"#1c1c22",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INSPIRATION_OPTIONS = [
|
||||||
|
"Apple",
|
||||||
|
"Linear",
|
||||||
|
"Cyberpunk",
|
||||||
|
"Editorial",
|
||||||
|
"Brutalist",
|
||||||
|
"Vaporwave",
|
||||||
|
"Swiss",
|
||||||
|
"Y2K",
|
||||||
|
"Organic",
|
||||||
|
"Noir",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The persona context passed to the AI to tailor generated assets. */
|
||||||
|
export interface Persona {
|
||||||
|
role?: string;
|
||||||
|
vibe?: string;
|
||||||
|
colors?: string[];
|
||||||
|
goal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProfileComplete(p: Profile): boolean {
|
||||||
|
return p.displayName.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a profile to the AI persona, dropping empty fields; undefined if empty. */
|
||||||
|
export function buildPersona(p: Profile): Persona | undefined {
|
||||||
|
const persona: Persona = {};
|
||||||
|
if (p.title.trim()) persona.role = p.title.trim();
|
||||||
|
if (p.vibes.length) persona.vibe = p.vibes.join(", ");
|
||||||
|
if (p.brandColors.length) persona.colors = p.brandColors;
|
||||||
|
if (p.goal) persona.goal = p.goal;
|
||||||
|
return Object.keys(persona).length > 0 ? persona : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// The user profile (set during first-run onboarding) — MMKV-persisted, mirrors
|
||||||
|
// the localCardsStore pattern. `hydrated` flips once persist rehydrates so the
|
||||||
|
// first-launch gate doesn't flash before MMKV is read.
|
||||||
|
|
||||||
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { EMPTY_PROFILE, Profile } from "./profileLogic";
|
||||||
|
|
||||||
|
const storage = new MMKV({ id: "cardclaws-profile" });
|
||||||
|
|
||||||
|
interface ProfileState {
|
||||||
|
onboarded: boolean;
|
||||||
|
profile: Profile;
|
||||||
|
hydrated: boolean;
|
||||||
|
setProfile: (patch: Partial<Profile>) => void;
|
||||||
|
completeOnboarding: (p: Profile) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProfileStore = create<ProfileState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
onboarded: false,
|
||||||
|
profile: EMPTY_PROFILE,
|
||||||
|
hydrated: false,
|
||||||
|
setProfile: (patch) => set((s) => ({ profile: { ...s.profile, ...patch } })),
|
||||||
|
completeOnboarding: (p) => set({ profile: p, onboarded: true }),
|
||||||
|
reset: () => set({ profile: EMPTY_PROFILE, onboarded: false }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "profile",
|
||||||
|
storage: createJSONStorage(() => ({
|
||||||
|
getItem: (k) => storage.getString(k) ?? null,
|
||||||
|
setItem: (k, v) => storage.set(k, v),
|
||||||
|
removeItem: (k) => storage.delete(k),
|
||||||
|
})),
|
||||||
|
partialize: (s) => ({ onboarded: s.onboarded, profile: s.profile }),
|
||||||
|
onRehydrateStorage: () => () => {
|
||||||
|
useProfileStore.setState({ hydrated: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user