From c9823055fde58e77bba0bbd1b8b4564c60a8e2c8 Mon Sep 17 00:00:00 2001 From: filemon Date: Wed, 1 Apr 2026 16:33:57 -0300 Subject: [PATCH 01/16] Add first-hatch tour orchestration layer (state machine + activation) New src/blobbi/tour/ module with: - tour-types.ts: Generic TourStepDef/TourState/TourActions types, plus FirstHatchTourStepId enum and ordered FIRST_HATCH_TOUR_STEPS array - useFirstHatchTour: Step-based state machine with localStorage persistence, advance/goTo/complete/reset actions, and derived booleans (isStep, isAnyStep, currentStepDef) for UI consumption - useFirstHatchTourActivation: Precondition guard that auto-starts the tour when: exactly 1 Blobbi, egg stage, no baby/adult, not yet completed - Barrel index.ts exporting all types, hooks, and constants No visual/UI changes yet -- this is the orchestration foundation that rendering layers will plug into. --- src/blobbi/tour/hooks/useFirstHatchTour.ts | 226 ++++++++++++++++++ .../tour/hooks/useFirstHatchTourActivation.ts | 131 ++++++++++ src/blobbi/tour/index.ts | 43 ++++ src/blobbi/tour/lib/tour-types.ts | 137 +++++++++++ 4 files changed, 537 insertions(+) create mode 100644 src/blobbi/tour/hooks/useFirstHatchTour.ts create mode 100644 src/blobbi/tour/hooks/useFirstHatchTourActivation.ts create mode 100644 src/blobbi/tour/index.ts create mode 100644 src/blobbi/tour/lib/tour-types.ts diff --git a/src/blobbi/tour/hooks/useFirstHatchTour.ts b/src/blobbi/tour/hooks/useFirstHatchTour.ts new file mode 100644 index 00000000..5275eb70 --- /dev/null +++ b/src/blobbi/tour/hooks/useFirstHatchTour.ts @@ -0,0 +1,226 @@ +/** + * useFirstHatchTour - State machine for the first-egg hatch tutorial. + * + * Orchestration only -- no rendering, no animations. + * The hook manages: + * - Ordered step progression + * - Persisted state via localStorage (survives refresh / close) + * - Derived booleans for UI consumption + * - Safe advance / goTo / complete / reset actions + * + * Activation is handled separately by useFirstHatchTourActivation, + * which calls `start()` when all preconditions are met. + * + * ──────────────────────────────────────────────────────────────── + * Future integration points + * ──────────────────────────────────────────────────────────────── + * 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation + * to decide whether to start the tour. + * 2. UI components read `state.currentStepId` and render overlays, + * spotlights, modals, or animation cues accordingly. + * 3. Animation components call `actions.advance()` when their + * sequence finishes (for autoAdvance steps). + * 4. Interactive steps (e.g. "click the egg") call `actions.advance()` + * on the user interaction. + * 5. EggGraphic receives a visual-state prop derived from + * `state.currentStepId` -- it does NOT own the tour logic. + */ + +import { useMemo, useCallback, useRef } from 'react'; + +import { useLocalStorage } from '@/hooks/useLocalStorage'; + +import { + FIRST_HATCH_TOUR_STEPS, + FIRST_HATCH_TOUR_DEFAULT_STATE, + type FirstHatchTourStepId, + type FirstHatchTourPersistedState, + type TourState, + type TourActions, +} from '../lib/tour-types'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** + * localStorage key for the first hatch tour state. + * Not user-scoped because onboarding state is device-local and the tour + * is inherently tied to "first ever egg on this device". If multi-user + * support on the same device becomes a concern, scope by pubkey. + */ +const STORAGE_KEY = 'blobbi:tour:first-hatch'; + +/** Pre-computed lookup: stepId -> index */ +const STEP_INDEX_MAP = new Map( + FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]), +); + +/** Index of the last step that is NOT the terminal 'complete' pseudo-step */ +const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2; + +// ─── Result Type ────────────────────────────────────────────────────────────── + +export interface UseFirstHatchTourResult { + /** Reactive tour state for UI consumption */ + state: TourState; + /** Actions to drive the tour forward */ + actions: TourActions; + /** + * Convenience: check if the current step matches a given id. + * Useful for conditional rendering: `isStep('egg_crack_stage_1')`. + */ + isStep: (stepId: FirstHatchTourStepId) => boolean; + /** + * Convenience: check if the current step is one of the given ids. + * Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`. + */ + isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean; + /** + * The current step definition (with autoAdvance metadata), or null. + */ + currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export function useFirstHatchTour(): UseFirstHatchTourResult { + // ── Persisted state ── + const [persisted, setPersisted] = useLocalStorage( + STORAGE_KEY, + FIRST_HATCH_TOUR_DEFAULT_STATE, + ); + + // Stable ref to current persisted state so callbacks never go stale. + const persistedRef = useRef(persisted); + persistedRef.current = persisted; + + // ── Helpers ── + + const updatePersisted = useCallback( + (patch: Partial) => { + setPersisted((prev) => ({ + ...prev, + ...patch, + updatedAt: Date.now(), + })); + }, + [setPersisted], + ); + + // ── Actions ── + + const start = useCallback(() => { + const p = persistedRef.current; + // No-op if already active or completed + if (p.completed || p.currentStepId !== null) return; + + const firstStep = FIRST_HATCH_TOUR_STEPS[0]; + if (!firstStep) return; + + updatePersisted({ currentStepId: firstStep.id }); + }, [updatePersisted]); + + const advance = useCallback(() => { + const p = persistedRef.current; + if (p.completed || p.currentStepId === null) return; + + const currentIndex = STEP_INDEX_MAP.get(p.currentStepId); + if (currentIndex === undefined) return; + + const nextIndex = currentIndex + 1; + if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) { + // Past the end -- complete + updatePersisted({ currentStepId: null, completed: true }); + return; + } + + const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex]; + if (nextStep.id === 'complete') { + // Reaching the 'complete' terminal step means the tour is done + updatePersisted({ currentStepId: null, completed: true }); + } else { + updatePersisted({ currentStepId: nextStep.id }); + } + }, [updatePersisted]); + + const goTo = useCallback( + (stepId: FirstHatchTourStepId) => { + if (!STEP_INDEX_MAP.has(stepId)) { + throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`); + } + + if (stepId === 'complete') { + updatePersisted({ currentStepId: null, completed: true }); + } else { + updatePersisted({ currentStepId: stepId, completed: false }); + } + }, + [updatePersisted], + ); + + const complete = useCallback(() => { + updatePersisted({ currentStepId: null, completed: true }); + }, [updatePersisted]); + + const reset = useCallback(() => { + setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE); + }, [setPersisted]); + + // ── Derived state ── + + const currentStepIndex = persisted.currentStepId !== null + ? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1) + : -1; + + const state = useMemo((): TourState => { + const isActive = persisted.currentStepId !== null && !persisted.completed; + const totalSteps = FIRST_HATCH_TOUR_STEPS.length; + + return { + isActive, + currentStepId: persisted.currentStepId, + currentStepIndex, + totalSteps, + isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX, + isCompleted: persisted.completed, + progress: persisted.completed + ? 1 + : currentStepIndex >= 0 + ? currentStepIndex / LAST_REAL_STEP_INDEX + : 0, + }; + }, [persisted.currentStepId, persisted.completed, currentStepIndex]); + + const actions = useMemo((): TourActions => ({ + start, + advance, + goTo, + complete, + reset, + }), [start, advance, goTo, complete, reset]); + + // ── Convenience helpers ── + + const isStep = useCallback( + (stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId, + [persisted.currentStepId], + ); + + const isAnyStep = useCallback( + (...stepIds: FirstHatchTourStepId[]) => { + return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId); + }, + [persisted.currentStepId], + ); + + const currentStepDef = currentStepIndex >= 0 + ? FIRST_HATCH_TOUR_STEPS[currentStepIndex] + : null; + + return { + state, + actions, + isStep, + isAnyStep, + currentStepDef, + }; +} diff --git a/src/blobbi/tour/hooks/useFirstHatchTourActivation.ts b/src/blobbi/tour/hooks/useFirstHatchTourActivation.ts new file mode 100644 index 00000000..6350f9e8 --- /dev/null +++ b/src/blobbi/tour/hooks/useFirstHatchTourActivation.ts @@ -0,0 +1,131 @@ +/** + * useFirstHatchTourActivation - Activation guard for the first-egg hatch tour. + * + * This hook checks all preconditions and calls `tour.actions.start()` when + * the tour should activate. It is intentionally separated from the tour + * state machine so that: + * - The state machine stays generic and reusable. + * - Activation rules are centralized in one place. + * - The rules are easy to read and modify. + * + * ──────────────────────────────────────────────────────────────── + * Activation rules (ALL must be true): + * ──────────────────────────────────────────────────────────────── + * 1. The companions list is loaded (not loading / error). + * 2. The user has exactly 1 Blobbi. + * 3. That Blobbi is in the egg stage. + * 4. No Blobbi is in baby or adult stage. + * 5. The tour has not been completed yet. + * + * Rule 5 is checked inside the tour hook itself (start() is a no-op + * if completed), but we also read `state.isCompleted` here to avoid + * unnecessary re-renders and to surface a clean `shouldActivate` boolean. + * ──────────────────────────────────────────────────────────────── + */ + +import { useEffect, useMemo } from 'react'; + +import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; + +import type { UseFirstHatchTourResult } from './useFirstHatchTour'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface FirstHatchTourActivationInput { + /** The full list of the user's Blobbi companions */ + companions: BlobbiCompanion[]; + /** Whether the companions list is still loading */ + isLoading: boolean; + /** The tour hook result */ + tour: UseFirstHatchTourResult; +} + +export interface FirstHatchTourActivationResult { + /** + * Whether all preconditions for activating the tour are met right now. + * This is a derived boolean -- it does NOT mean the tour IS active, + * just that it SHOULD be activated. The tour may already be active + * from a previous render or a persisted state. + */ + shouldActivate: boolean; + /** + * Whether the tour is eligible (preconditions met and not yet completed). + * Useful for hiding UI that should only appear during the tour window. + */ + isEligible: boolean; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Evaluates activation preconditions and auto-starts the tour when met. + * + * Usage: + * ```ts + * const tour = useFirstHatchTour(); + * const activation = useFirstHatchTourActivation({ + * companions, + * isLoading: companionsLoading, + * tour, + * }); + * ``` + */ +export function useFirstHatchTourActivation({ + companions, + isLoading, + tour, +}: FirstHatchTourActivationInput): FirstHatchTourActivationResult { + // ── Precondition evaluation ── + + const { shouldActivate, isEligible } = useMemo(() => { + // Can't evaluate until data is loaded + if (isLoading) { + return { shouldActivate: false, isEligible: false }; + } + + // Already completed -- nothing to do + if (tour.state.isCompleted) { + return { shouldActivate: false, isEligible: false }; + } + + // Must have exactly 1 companion + if (companions.length !== 1) { + return { shouldActivate: false, isEligible: false }; + } + + const onlyBlobbi = companions[0]; + + // That companion must be an egg + if (onlyBlobbi.stage !== 'egg') { + return { shouldActivate: false, isEligible: false }; + } + + // No baby or adult companions (redundant given length === 1 + stage === 'egg', + // but kept explicit for clarity and future-proofing if rules change) + const hasBabyOrAdult = companions.some( + (c) => c.stage === 'baby' || c.stage === 'adult', + ); + if (hasBabyOrAdult) { + return { shouldActivate: false, isEligible: false }; + } + + // All preconditions met + const eligible = true; + // Only activate if the tour is not already running + const activate = !tour.state.isActive; + + return { shouldActivate: activate, isEligible: eligible }; + }, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]); + + // ── Auto-start effect ── + // When all preconditions are met and the tour hasn't started yet, + // start it. This fires once and then `shouldActivate` flips to false + // because `tour.state.isActive` becomes true. + useEffect(() => { + if (shouldActivate) { + tour.actions.start(); + } + }, [shouldActivate, tour.actions]); + + return { shouldActivate, isEligible }; +} diff --git a/src/blobbi/tour/index.ts b/src/blobbi/tour/index.ts new file mode 100644 index 00000000..1840217f --- /dev/null +++ b/src/blobbi/tour/index.ts @@ -0,0 +1,43 @@ +/** + * Blobbi Tour Module + * + * Provides the orchestration layer for guided tours / tutorials. + * Currently implements the first-egg hatch tour. + * + * Architecture: + * - tour-types.ts: Step definitions, persisted state shape, generic types + * - useFirstHatchTour: State machine (step progression, persistence, actions) + * - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible) + * + * UI components import from this barrel and read tour state to decide + * what to render. They call tour actions (advance, goTo, complete) in + * response to user interactions or animation completions. + */ + +// ── Types (generic tour infrastructure) ── +export type { + TourStepDef, + TourPersistedState, + TourState, + TourActions, +} from './lib/tour-types'; + +// ── First Hatch Tour - Types & Constants ── +export { + FIRST_HATCH_TOUR_STEPS, + FIRST_HATCH_TOUR_DEFAULT_STATE, +} from './lib/tour-types'; +export type { + FirstHatchTourStepId, + FirstHatchTourPersistedState, +} from './lib/tour-types'; + +// ── First Hatch Tour - Hooks ── +export { useFirstHatchTour } from './hooks/useFirstHatchTour'; +export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour'; + +export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation'; +export type { + FirstHatchTourActivationInput, + FirstHatchTourActivationResult, +} from './hooks/useFirstHatchTourActivation'; diff --git a/src/blobbi/tour/lib/tour-types.ts b/src/blobbi/tour/lib/tour-types.ts new file mode 100644 index 00000000..ca9be1c8 --- /dev/null +++ b/src/blobbi/tour/lib/tour-types.ts @@ -0,0 +1,137 @@ +/** + * Tour System - Core Types + * + * Generic, reusable types for step-based guided tours. + * The tour system is designed to be: + * - Easy to extend with new tours (define steps + config) + * - Easy to reorder steps (change the STEPS array) + * - Persistent across page refreshes (localStorage) + * - Decoupled from rendering (UI reads state, doesn't own it) + */ + +// ─── Generic Tour Infrastructure ────────────────────────────────────────────── + +/** + * A tour step definition. + * + * Each step has a unique id and optional metadata that future UI layers + * can use to decide what to render (spotlights, modals, animations, etc.). + */ +export interface TourStepDef { + /** Unique identifier for this step */ + id: StepId; + /** + * Whether this step auto-advances (e.g. animations) or waits for + * an explicit `advance()` / `goTo()` call from the UI. + * Default: false (manual). + */ + autoAdvance?: boolean; +} + +/** + * Persisted state for a tour. + * Stored in localStorage so tours survive refresh / close / return. + */ +export interface TourPersistedState { + /** Current step id, or null when the tour is not yet started */ + currentStepId: StepId | null; + /** Whether the tour has been completed */ + completed: boolean; + /** Unix ms timestamp of last state change (for debugging / analytics) */ + updatedAt: number; +} + +/** + * Full runtime state exposed by a tour hook. + */ +export interface TourState { + /** Whether the tour is currently active (started and not yet completed) */ + isActive: boolean; + /** Current step id, or null when idle / completed */ + currentStepId: StepId | null; + /** 0-based index of the current step in the steps array, or -1 */ + currentStepIndex: number; + /** Total number of steps */ + totalSteps: number; + /** Whether the current step is the last one before completion */ + isLastStep: boolean; + /** Whether the tour has been completed (persisted) */ + isCompleted: boolean; + /** Progress as a fraction 0..1 */ + progress: number; +} + +/** + * Actions exposed by a tour hook. + */ +export interface TourActions { + /** Start the tour from the first step (no-op if already active or completed) */ + start: () => void; + /** Advance to the next step. Completes the tour if on the last step. */ + advance: () => void; + /** Jump to a specific step by id. Throws if the step doesn't exist. */ + goTo: (stepId: StepId) => void; + /** Mark the tour as completed and reset to idle. */ + complete: () => void; + /** Reset the tour entirely (clears persisted state). For dev/testing. */ + reset: () => void; +} + +// ─── First Hatch Tour ───────────────────────────────────────────────────────── + +/** + * Step ids for the first-egg hatch tour. + * + * The order here matches the intended flow. To reorder steps, + * change FIRST_HATCH_TOUR_STEPS (the array), not this type. + */ +export type FirstHatchTourStepId = + | 'idle' + | 'egg_ready_hint' + | 'show_hatch_modal' + | 'await_create_post' + | 'egg_glowing_waiting_click' + | 'egg_crack_stage_1' + | 'egg_crack_stage_2' + | 'egg_crack_stage_3' + | 'egg_opening' + | 'egg_hatching' + | 'tour_rewards_reveal' + | 'tour_set_companion_hint' + | 'complete'; + +/** + * Ordered step definitions for the first hatch tour. + * + * To add / remove / reorder steps, edit this array. + * The tour state machine walks through these in order. + */ +export const FIRST_HATCH_TOUR_STEPS: TourStepDef[] = [ + { id: 'idle' }, + { id: 'egg_ready_hint' }, + { id: 'show_hatch_modal' }, + { id: 'await_create_post' }, + { id: 'egg_glowing_waiting_click' }, + { id: 'egg_crack_stage_1', autoAdvance: true }, + { id: 'egg_crack_stage_2', autoAdvance: true }, + { id: 'egg_crack_stage_3', autoAdvance: true }, + { id: 'egg_opening', autoAdvance: true }, + { id: 'egg_hatching', autoAdvance: true }, + { id: 'tour_rewards_reveal' }, + { id: 'tour_set_companion_hint' }, + { id: 'complete' }, +]; + +/** + * Persisted state shape for the first hatch tour. + */ +export type FirstHatchTourPersistedState = TourPersistedState; + +/** + * Default persisted state for a brand-new first hatch tour. + */ +export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = { + currentStepId: null, + completed: false, + updatedAt: 0, +}; From 2c737ca3226a089463ff2e00241421c63f9d85f7 Mon Sep 17 00:00:00 2001 From: filemon Date: Wed, 1 Apr 2026 17:42:12 -0300 Subject: [PATCH 02/16] Wire first-hatch tour into BlobbiPage with post phrase update and egg visuals Tour integration: - Call useFirstHatchTour + useFirstHatchTourActivation in BlobbiDashboard - Auto-advance: idle -> egg_ready_hint (immediate) -> show_hatch_modal (3s) - Poll for valid hatch post during show_hatch_modal/await_create_post - On post detected, advance to egg_glowing_waiting_click - Missions button opens tour modal instead of normal missions during tour - Hide incubation button during tour (tour handles the flow) - Badge shows tour-specific remaining count (1 post mission) Post phrase update: - New format: 'Posting to hatch {Name} #blobbi' (was: 'Hello Nostr! Posting to hatch #name #blobbi #ditto #nostr') - Update isValidHatchPost to check for phrase anywhere in content - Add buildHatchPhrase helper - Simplify BlobbiPostModal validation and tag extraction Egg visual layer: - Add EggTourVisualState type ('idle' | 'ready_hint' | 'glowing_waiting_click') - Thread tourVisualState prop: BlobbiStageVisual -> BlobbiEggVisual -> EggGraphic - ready_hint: auto-wiggle every 2.5s using existing egg-tap-wiggle animation - glowing_waiting_click: enlarged pulsing glow via new egg-tour-glow CSS animation - Add reduced-motion support for new animation FirstHatchTourModal component: - Shows during show_hatch_modal/await_create_post steps - Single mission: create a hatch post with the required phrase - Continue button appears when post is detected --- .../actions/components/BlobbiPostModal.tsx | 97 +++++--------- src/blobbi/actions/hooks/useHatchTasks.ts | 36 +++--- src/blobbi/actions/index.ts | 1 + src/blobbi/egg/components/EggGraphic.tsx | 49 +++++++- src/blobbi/egg/index.ts | 2 +- src/blobbi/egg/styles/egg-animations.css | 23 +++- .../tour/components/FirstHatchTourModal.tsx | 119 ++++++++++++++++++ src/blobbi/tour/index.ts | 3 + src/blobbi/ui/BlobbiEggVisual.tsx | 8 +- src/blobbi/ui/BlobbiStageVisual.tsx | 6 +- src/pages/BlobbiPage.tsx | 117 ++++++++++++++++- 11 files changed, 367 insertions(+), 94 deletions(-) create mode 100644 src/blobbi/tour/components/FirstHatchTourModal.tsx diff --git a/src/blobbi/actions/components/BlobbiPostModal.tsx b/src/blobbi/actions/components/BlobbiPostModal.tsx index f5ba5460..1366319d 100644 --- a/src/blobbi/actions/components/BlobbiPostModal.tsx +++ b/src/blobbi/actions/components/BlobbiPostModal.tsx @@ -30,6 +30,7 @@ import { toast } from '@/hooks/useToast'; import { BLOBBI_POST_REQUIRED_HASHTAGS, + buildHatchPhrase, } from '../hooks/useHatchTasks'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -49,33 +50,13 @@ interface BlobbiPostModalProps { // ─── Helpers ────────────────────────────────────────────────────────────────── -/** - * Sanitize a name into a valid hashtag format. - * - Removes special characters - * - Replaces spaces with nothing (camelCase-like) - * - Ensures lowercase - * - Handles edge cases - */ -function sanitizeToHashtag(name: string): string { - return name - .toLowerCase() - // Remove emojis and special characters, keep letters, numbers, underscores - .replace(/[^\p{L}\p{N}_]/gu, '') - // Ensure it starts with a letter (prepend 'blobbi' if it starts with number) - .replace(/^(\d)/, 'blobbi$1') - // Limit length - .slice(0, 30) - // Fallback if empty - || 'myblobbi'; -} - /** * Build the required prefix text based on process type. */ function buildPrefix(process: BlobbiPostProcess): string { return process === 'evolve' - ? 'Hello Nostr! Posting to evolve' - : 'Hello Nostr! Posting to hatch'; + ? 'Posting to evolve' + : 'Posting to hatch'; } // ─── Main Component ─────────────────────────────────────────────────────────── @@ -91,20 +72,19 @@ export function BlobbiPostModal({ const { mutateAsync: createEvent, isPending } = useNostrPublish(); // Compute the required elements based on props - const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]); const prefix = useMemo(() => buildPrefix(process), [process]); + const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]); - // All required hashtags including the Blobbi name (first) - const allRequiredHashtags = useMemo(() => - [blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS], - [blobbiHashtag] + // The required phrase that must appear in the post + const requiredPhrase = useMemo(() => + process === 'hatch' + ? buildHatchPhrase(blobbiName) + : `${prefix} ${capitalizedName} #blobbi`, + [process, blobbiName, prefix, capitalizedName] ); - // Build default content - const defaultContent = useMemo(() => - `${prefix} #${allRequiredHashtags.join(' #')}`, - [prefix, allRequiredHashtags] - ); + // Build default content (the phrase itself is enough) + const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]); const [content, setContent] = useState(defaultContent); const [validationError, setValidationError] = useState(null); @@ -118,24 +98,14 @@ export function BlobbiPostModal({ }, [open, defaultContent]); /** - * Validate that the content still contains the required prefix and hashtags. + * Validate that the content contains the required phrase. */ const validateContent = useCallback((text: string): string | null => { - // Check prefix - if (!text.startsWith(prefix)) { - return 'The post must start with the required text'; + if (!text.includes(requiredPhrase)) { + return `The post must contain: "${requiredPhrase}"`; } - - // Check all required hashtags are present (including Blobbi name) - const lowerText = text.toLowerCase(); - for (const tag of allRequiredHashtags) { - if (!lowerText.includes(`#${tag.toLowerCase()}`)) { - return `Missing required hashtag: #${tag}`; - } - } - return null; - }, [prefix, allRequiredHashtags]); + }, [requiredPhrase]); /** * Handle content change with validation. @@ -180,21 +150,26 @@ export function BlobbiPostModal({ } try { - // Build tags for the post + // Build tags for the post: extract all hashtags from content const tags: string[][] = []; + const seen = new Set(); - // Add all required hashtags as 't' tags - for (const hashtag of allRequiredHashtags) { - tags.push(['t', hashtag.toLowerCase()]); + // Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags + for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) { + const lower = hashtag.toLowerCase(); + if (!seen.has(lower)) { + tags.push(['t', lower]); + seen.add(lower); + } } - // Extract any additional hashtags the user added - const additionalHashtags = content.match(/#(\w+)/g) || []; - const requiredLower = allRequiredHashtags.map(t => t.toLowerCase()); - for (const tag of additionalHashtags) { + // Extract any additional hashtags from the content + const contentHashtags = content.match(/#(\w+)/g) || []; + for (const tag of contentHashtags) { const tagValue = tag.slice(1).toLowerCase(); - if (!requiredLower.includes(tagValue)) { + if (!seen.has(tagValue)) { tags.push(['t', tagValue]); + seen.add(tagValue); } } @@ -220,7 +195,7 @@ export function BlobbiPostModal({ variant: 'destructive', }); } - }, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]); + }, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]); const canPost = !validationError && content.trim().length > 0; @@ -282,13 +257,9 @@ export function BlobbiPostModal({ {/* Preview of required content */}
-

Required content:

-

- {prefix} - {' '} - {allRequiredHashtags.map(tag => ( - #{tag} - ))} +

Required phrase:

+

+ {requiredPhrase}

diff --git a/src/blobbi/actions/hooks/useHatchTasks.ts b/src/blobbi/actions/hooks/useHatchTasks.ts index a710e00e..85fcafe2 100644 --- a/src/blobbi/actions/hooks/useHatchTasks.ts +++ b/src/blobbi/actions/hooks/useHatchTasks.ts @@ -34,10 +34,10 @@ export const KIND_SHORT_TEXT_NOTE = 1; export const HATCH_REQUIRED_INTERACTIONS = 7; /** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */ -export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr']; +export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi']; -/** Prefix text for Blobbi hatch post */ -export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch'; +/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */ +export const BLOBBI_POST_PREFIX = 'Posting to hatch'; // Legacy export for backwards compatibility export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS; @@ -110,16 +110,28 @@ export interface HatchTasksResult { // ─── Helper Functions ───────────────────────────────────────────────────────── +/** + * Build the required phrase for a hatch post. + * Format: "Posting to hatch {CapitalizedName} #blobbi" + */ +export function buildHatchPhrase(blobbiName: string): string { + const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1); + return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`; +} + /** * Check if a post is a valid Blobbi hatch post. - * Must contain the required prefix and all required hashtags including the Blobbi name. + * The post must contain the required phrase: "Posting to hatch {Name} #blobbi" + * The user may add extra text before or after it. * * @param event - The Nostr event to validate - * @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag) + * @param blobbiName - The Blobbi's name */ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean { - // Check content starts with prefix - if (!event.content.startsWith(BLOBBI_POST_PREFIX)) { + const phrase = buildHatchPhrase(blobbiName); + + // The phrase must appear somewhere in the content + if (!event.content.includes(phrase)) { return false; } @@ -128,18 +140,12 @@ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean .filter(tag => tag[0] === 't') .map(tag => tag[1]?.toLowerCase()); - // All required hashtags must be present + // All required hashtags must be present as t tags const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required => hashtags.includes(required.toLowerCase()) ); - if (!hasRequiredHashtags) { - return false; - } - - // Blobbi name hashtag must also be present - const blobbiHashtag = sanitizeToHashtag(blobbiName); - return hashtags.includes(blobbiHashtag); + return hasRequiredHashtags; } // Legacy function name for backwards compatibility diff --git a/src/blobbi/actions/index.ts b/src/blobbi/actions/index.ts index 7c8da7f7..b51ad0f0 100644 --- a/src/blobbi/actions/index.ts +++ b/src/blobbi/actions/index.ts @@ -57,6 +57,7 @@ export { sanitizeToHashtag, isValidHatchPost, isValidBlobbiPost, // Legacy export + buildHatchPhrase, KIND_THEME_DEFINITION, KIND_COLOR_MOMENT, HATCH_REQUIRED_INTERACTIONS, diff --git a/src/blobbi/egg/components/EggGraphic.tsx b/src/blobbi/egg/components/EggGraphic.tsx index 6ce21d09..552015e3 100644 --- a/src/blobbi/egg/components/EggGraphic.tsx +++ b/src/blobbi/egg/components/EggGraphic.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import type { EggVisualBlobbi } from '../types/egg.types'; import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation'; import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer'; @@ -25,6 +25,12 @@ export interface EggStatusEffects { happy?: boolean; } +/** + * Tour visual states that the egg can display. + * Driven by the tour orchestration layer, not by EggGraphic itself. + */ +export type EggTourVisualState = 'idle' | 'ready_hint' | 'glowing_waiting_click'; + interface EggGraphicProps { blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size @@ -36,6 +42,8 @@ interface EggGraphicProps { forceInlineSvg?: boolean; // New prop to guarantee inline SVG /** Status effects for egg-stage visual feedback */ statusEffects?: EggStatusEffects; + /** Tour visual state - driven externally by the tour orchestration layer */ + tourVisualState?: EggTourVisualState; } /** @@ -114,6 +122,7 @@ export const EggGraphic: React.FC = ({ warmth = 50, forceInlineSvg: _forceInlineSvg = false, statusEffects, + tourVisualState = 'idle', }) => { // sizeVariant controls ONLY internal scaling/details, NOT layout dimensions // Parent container controls actual rendered width/height via slot @@ -160,6 +169,33 @@ export const EggGraphic: React.FC = ({ setIsTapWiggling(false); }, []); + // Tour: auto-wiggle effect for ready_hint state + const autoWiggleTimerRef = useRef | null>(null); + useEffect(() => { + if (tourVisualState !== 'ready_hint') { + if (autoWiggleTimerRef.current) { + clearInterval(autoWiggleTimerRef.current); + autoWiggleTimerRef.current = null; + } + return; + } + // Trigger an immediate wiggle, then repeat every 2.5s + setIsTapWiggling(true); + autoWiggleTimerRef.current = setInterval(() => { + setIsTapWiggling((prev) => { + // Only trigger if not already wiggling + if (!prev) return true; + return prev; + }); + }, 2500); + return () => { + if (autoWiggleTimerRef.current) { + clearInterval(autoWiggleTimerRef.current); + autoWiggleTimerRef.current = null; + } + }; + }, [tourVisualState]); + // Divine color constants const DIVINE_PRIMARY_GREEN = '#55C4A2'; const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9'; @@ -443,12 +479,15 @@ export const EggGraphic: React.FC = ({
diff --git a/src/blobbi/egg/index.ts b/src/blobbi/egg/index.ts index 1bfe3b44..74a3941b 100644 --- a/src/blobbi/egg/index.ts +++ b/src/blobbi/egg/index.ts @@ -12,7 +12,7 @@ import './styles/egg-animations.css'; // Components -export { EggGraphic, type EggReactionState, type EggStatusEffects } from './components/EggGraphic'; +export { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from './components/EggGraphic'; export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer'; // Hooks diff --git a/src/blobbi/egg/styles/egg-animations.css b/src/blobbi/egg/styles/egg-animations.css index 448275d7..eceef216 100644 --- a/src/blobbi/egg/styles/egg-animations.css +++ b/src/blobbi/egg/styles/egg-animations.css @@ -320,6 +320,26 @@ transform: translateZ(0); } +/* ========================================== + Tour Visual State Animations + ========================================== */ + +/* Pulsing glow for the "waiting for click" tour state */ +@keyframes egg-tour-glow { + 0%, 100% { + opacity: 0.5; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.08); + } +} + +.animate-egg-tour-glow { + animation: egg-tour-glow 2s ease-in-out infinite; +} + /* ========================================== Responsive adjustments ========================================== */ @@ -351,7 +371,8 @@ .animate-egg-sweat-drop, .animate-egg-dust-particle, .animate-egg-spiral, - .animate-egg-sparkle { + .animate-egg-sparkle, + .animate-egg-tour-glow { animation: none !important; } } diff --git a/src/blobbi/tour/components/FirstHatchTourModal.tsx b/src/blobbi/tour/components/FirstHatchTourModal.tsx new file mode 100644 index 00000000..02ea8f9d --- /dev/null +++ b/src/blobbi/tour/components/FirstHatchTourModal.tsx @@ -0,0 +1,119 @@ +/** + * FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step. + * + * Tells the user their egg is about to hatch and guides them to create a post. + * Contains a single mission: create the hatch post. + */ + +import { Egg, Send, Check } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface FirstHatchTourModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The Blobbi's display name */ + blobbiName: string; + /** The exact phrase the user needs to include in their post */ + requiredPhrase: string; + /** Whether the post mission has been completed */ + postCompleted: boolean; + /** Open the post composer */ + onCreatePost: () => void; + /** Advance the tour (called after post is confirmed complete) */ + onContinue: () => void; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function FirstHatchTourModal({ + open, + onOpenChange, + blobbiName, + requiredPhrase, + postCompleted, + onCreatePost, + onContinue, +}: FirstHatchTourModalProps) { + const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1); + + return ( + + + {/* Header with egg accent */} +
+
+ +
+ + + {capitalizedName} is ready to hatch! + + +

+ Share a post to the Nostr network and help {capitalizedName} break free. +

+
+ + {/* Mission card */} +
+
+
+ {/* Status indicator */} +
+ {postCompleted && } +
+ +
+

+ {postCompleted ? 'Post shared!' : 'Share a hatch post'} +

+

+ Post must include the phrase: +

+

+ {requiredPhrase} +

+
+
+ + {!postCompleted && ( + + )} +
+
+ + {/* Footer */} +
+ {postCompleted ? ( + + ) : ( +

+ You can add extra text before or after the required phrase. +

+ )} +
+
+
+ ); +} diff --git a/src/blobbi/tour/index.ts b/src/blobbi/tour/index.ts index 1840217f..262e8d4b 100644 --- a/src/blobbi/tour/index.ts +++ b/src/blobbi/tour/index.ts @@ -41,3 +41,6 @@ export type { FirstHatchTourActivationInput, FirstHatchTourActivationResult, } from './hooks/useFirstHatchTourActivation'; + +// ── First Hatch Tour - Components ── +export { FirstHatchTourModal } from './components/FirstHatchTourModal'; diff --git a/src/blobbi/ui/BlobbiEggVisual.tsx b/src/blobbi/ui/BlobbiEggVisual.tsx index 0d8297ce..25f043d5 100644 --- a/src/blobbi/ui/BlobbiEggVisual.tsx +++ b/src/blobbi/ui/BlobbiEggVisual.tsx @@ -13,7 +13,7 @@ import { useMemo } from 'react'; -import { EggGraphic, type EggReactionState, type EggStatusEffects } from '@/blobbi/egg'; +import { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from '@/blobbi/egg'; import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter'; import { cn } from '@/lib/utils'; import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; @@ -23,7 +23,7 @@ import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; export type BlobbiEggSize = 'sm' | 'md' | 'lg'; // Re-export for convenience -export type { EggReactionState, EggStatusEffects } from '@/blobbi/egg'; +export type { EggReactionState, EggStatusEffects, EggTourVisualState } from '@/blobbi/egg'; export interface BlobbiEggVisualProps { /** The Blobbi companion data from parseBlobbiEvent */ @@ -36,6 +36,8 @@ export interface BlobbiEggVisualProps { reaction?: EggReactionState; /** Status effects for egg visual feedback (dirty, sick, happy) */ statusEffects?: EggStatusEffects; + /** Tour visual state - driven externally by the tour orchestration layer */ + tourVisualState?: EggTourVisualState; /** Additional CSS classes for the container */ className?: string; } @@ -70,6 +72,7 @@ export function BlobbiEggVisual({ animated = false, reaction = 'idle', statusEffects, + tourVisualState, className, }: BlobbiEggVisualProps) { // Memoize adapter output to avoid unnecessary re-renders @@ -103,6 +106,7 @@ export function BlobbiEggVisual({ animated={animated && !isSleeping} reaction={effectiveReaction} statusEffects={isSleeping ? undefined : statusEffects} + tourVisualState={tourVisualState} />
); diff --git a/src/blobbi/ui/BlobbiStageVisual.tsx b/src/blobbi/ui/BlobbiStageVisual.tsx index 7b8ee177..6e0b9bbc 100644 --- a/src/blobbi/ui/BlobbiStageVisual.tsx +++ b/src/blobbi/ui/BlobbiStageVisual.tsx @@ -12,7 +12,7 @@ import { useMemo } from 'react'; -import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects } from './BlobbiEggVisual'; +import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects, type EggTourVisualState } from './BlobbiEggVisual'; import { BlobbiBabyVisual } from './BlobbiBabyVisual'; import { BlobbiAdultVisual } from './BlobbiAdultVisual'; import { FloatingMusicNotes } from './FloatingMusicNotes'; @@ -50,6 +50,8 @@ export interface BlobbiStageVisualProps { * Status-reaction body effects are already in the recipe. */ bodyEffects?: BodyEffectsSpec; + /** Tour visual state for egg stage - driven by the tour orchestration layer */ + tourVisualState?: EggTourVisualState; className?: string; } @@ -74,6 +76,7 @@ export function BlobbiStageVisual({ recipeLabel, emotion = 'neutral', bodyEffects, + tourVisualState, className, }: BlobbiStageVisualProps) { const { stage } = companion; @@ -109,6 +112,7 @@ export function BlobbiStageVisual({ animated={animated} reaction={effectiveReaction} statusEffects={eggStatusEffects} + tourVisualState={tourVisualState} className="size-full" /> diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 7a8fc40a..8be277ff 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -88,6 +88,11 @@ import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction'; import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe'; import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions'; import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourModal } from '@/blobbi/tour'; +import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions'; +import type { EggTourVisualState } from '@/blobbi/egg'; /** * Get the localStorage key for the selected Blobbi. @@ -929,6 +934,77 @@ function BlobbiDashboard({ const [showIncubationDialog, setShowIncubationDialog] = useState(false); const [showEvolutionDialog, setShowEvolutionDialog] = useState(false); + // ─── First Hatch Tour ─── + const firstHatchTour = useFirstHatchTour(); + const { isEligible: _isFirstHatchTourEligible } = useFirstHatchTourActivation({ + companions, + isLoading: false, // companions are already loaded at this point + tour: firstHatchTour, + }); + const isFirstHatchTourActive = firstHatchTour.state.isActive; + + // The required phrase for the first-hatch post + const firstHatchPhrase = useMemo(() => buildHatchPhrase(companion.name), [companion.name]); + + // Auto-advance from idle to egg_ready_hint, then to show_hatch_modal + useEffect(() => { + if (!isFirstHatchTourActive) return; + if (firstHatchTour.isStep('idle')) { + // Advance immediately to egg_ready_hint + firstHatchTour.actions.advance(); + } + }, [isFirstHatchTourActive, firstHatchTour]); + + useEffect(() => { + if (!isFirstHatchTourActive) return; + if (firstHatchTour.isStep('egg_ready_hint')) { + // Show the ready hint briefly, then move to the modal + const timer = setTimeout(() => { + firstHatchTour.actions.advance(); + }, 3000); + return () => clearTimeout(timer); + } + }, [isFirstHatchTourActive, firstHatchTour]); + + // Detect hatch post completion for the first-hatch tour + const { user } = useCurrentUser(); + const { nostr } = useNostr(); + const tourAwaitingPost = isFirstHatchTourActive && ( + firstHatchTour.isStep('show_hatch_modal') || firstHatchTour.isStep('await_create_post') + ); + + const { data: tourPostFound } = useQuery({ + queryKey: ['first-hatch-tour-post', user?.pubkey, companion.name], + queryFn: async () => { + if (!user?.pubkey) return false; + const events = await nostr.query([{ + kinds: [1], + authors: [user.pubkey], + limit: 20, + }]); + return events.some(e => isValidHatchPost(e, companion.name)); + }, + enabled: tourAwaitingPost && !!user?.pubkey, + refetchInterval: 5000, + staleTime: 3000, + }); + + // When the post is found, advance past the post-awaiting steps + useEffect(() => { + if (!tourPostFound || !isFirstHatchTourActive) return; + if (firstHatchTour.isStep('show_hatch_modal') || firstHatchTour.isStep('await_create_post')) { + firstHatchTour.actions.goTo('egg_glowing_waiting_click'); + } + }, [tourPostFound, isFirstHatchTourActive, firstHatchTour]); + + // Derive tourVisualState for the egg visual + const tourVisualState = useMemo((): EggTourVisualState => { + if (!isFirstHatchTourActive) return 'idle'; + if (firstHatchTour.isStep('egg_ready_hint')) return 'ready_hint'; + if (firstHatchTour.isStep('egg_glowing_waiting_click')) return 'glowing_waiting_click'; + return 'idle'; + }, [isFirstHatchTourActive, firstHatchTour]); + // State detection for tasks // Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state const isIncubating = companion.state === 'incubating'; @@ -1366,8 +1442,8 @@ function BlobbiDashboard({ } isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution} onInfo={() => setShowInfoModal(true)} - // Hide button when actively incubating or evolving (actions are in MissionsModal instead) - hideEvolveButton={isIncubating || isEvolvingState} + // Hide button when actively incubating, evolving, or during first-hatch tour + hideEvolveButton={isIncubating || isEvolvingState || isFirstHatchTourActive} // When canStartIncubation or canStartEvolution is true, the button triggers the respective dialog isIncubationAction={canStartIncubation} isEvolutionAction={canStartEvolution} @@ -1411,6 +1487,7 @@ function BlobbiDashboard({ recipe={hasDevOverride ? undefined : statusRecipe} recipeLabel={hasDevOverride ? undefined : statusRecipeLabel} emotion={effectiveEmotion} + tourVisualState={tourVisualState} className="size-48 sm:size-56" /> @@ -1482,14 +1559,23 @@ function BlobbiDashboard({ {/* Bottom Action Bar */} setShowSelector(true)} - onMissionsClick={() => setShowMissionsModal(true)} + onMissionsClick={() => { + if (isFirstHatchTourActive) { + // During the first-hatch tour, open the tour modal instead + if (!firstHatchTour.isStep('show_hatch_modal') && !firstHatchTour.isStep('await_create_post')) { + firstHatchTour.actions.goTo('show_hatch_modal'); + } + } else { + setShowMissionsModal(true); + } + }} onActionsClick={() => setShowActionsModal(true)} onShopClick={() => setShowShopModal(true)} onInventoryClick={() => setShowInventoryModal(true)} needyBlobbiesCount={companions.filter(companionNeedsCare).length} - isInTaskProcess={isInTaskProcess} - remainingTasksCount={remainingTasksCount} - allTasksComplete={allTasksComplete} + isInTaskProcess={isFirstHatchTourActive || isInTaskProcess} + remainingTasksCount={isFirstHatchTourActive ? (tourPostFound ? 0 : 1) : remainingTasksCount} + allTasksComplete={isFirstHatchTourActive ? !!tourPostFound : allTasksComplete} /> {/* Blobbi Selector Modal */} @@ -1638,6 +1724,25 @@ function BlobbiDashboard({ onSuccess={refetchCurrentTasks} /> + {/* First Hatch Tour Modal */} + { + if (!open && isFirstHatchTourActive && firstHatchTour.isStep('show_hatch_modal')) { + // User dismissed the modal -- move to await_create_post so they can + // come back to it (the modal stays open for await_create_post too) + firstHatchTour.actions.advance(); + } + }} + blobbiName={companion.name} + requiredPhrase={firstHatchPhrase} + postCompleted={!!tourPostFound} + onCreatePost={() => setShowPostModal(true)} + onContinue={() => { + firstHatchTour.actions.goTo('egg_glowing_waiting_click'); + }} + /> + {/* Blobbi Photo Modal - polaroid-style photo capture */} Date: Wed, 1 Apr 2026 18:11:48 -0300 Subject: [PATCH 03/16] Replace first-hatch modal with inline card, hide dashboard controls during tour UX change: the first-hatch experience is now a focused onboarding screen instead of a modal interruption. Layout during first-hatch tour: - Egg visual (top, with tour animations) - Stats (if any visible) - FirstHatchTourCard inline below stats (mission + post CTA) - No floating hero controls (camera, info, companion, incubation) - No bottom action bar (blobbies, missions, actions, shop, inventory) - No inline activity area (music, sing) The page feels like a dedicated guided flow rather than a dashboard with overlays. Normal dashboard controls return after tour completion. Architecture: clean branch in BlobbiDashboard render -- isFirstHatchTourActive gates visibility of controls/bar/activities. The inline card lives at the same level as other content sections. The first egg is treated as already in the hatch onboarding path without requiring the normal 'start incubation' entry point. --- .../tour/components/FirstHatchTourCard.tsx | 99 +++++++++++++++++++ src/blobbi/tour/index.ts | 2 +- src/pages/BlobbiPage.tsx | 96 +++++++++--------- 3 files changed, 145 insertions(+), 52 deletions(-) create mode 100644 src/blobbi/tour/components/FirstHatchTourCard.tsx diff --git a/src/blobbi/tour/components/FirstHatchTourCard.tsx b/src/blobbi/tour/components/FirstHatchTourCard.tsx new file mode 100644 index 00000000..b16ce310 --- /dev/null +++ b/src/blobbi/tour/components/FirstHatchTourCard.tsx @@ -0,0 +1,99 @@ +/** + * FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour. + * + * Replaces the modal. Rendered directly in the BlobbiPage layout so the + * experience feels focused and guided rather than interrupted. + */ + +import { Send, Check } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface FirstHatchTourCardProps { + /** The Blobbi's display name */ + blobbiName: string; + /** The exact phrase the user needs to include in their post */ + requiredPhrase: string; + /** Whether the post mission has been completed */ + postCompleted: boolean; + /** Open the post composer */ + onCreatePost: () => void; + /** Advance the tour (called after post is confirmed complete) */ + onContinue: () => void; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function FirstHatchTourCard({ + blobbiName, + requiredPhrase, + postCompleted, + onCreatePost, + onContinue, +}: FirstHatchTourCardProps) { + const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1); + + return ( +
+ {/* Title + description */} +
+

+ {capitalizedName} is ready to hatch! +

+

+ Share a post to the Nostr network and help {capitalizedName} break free. +

+
+ + {/* Mission card */} +
+
+ {/* Status indicator */} +
+ {postCompleted && } +
+ +
+

+ {postCompleted ? 'Post shared!' : 'Share a hatch post'} +

+

+ Your post must include: +

+

+ {requiredPhrase} +

+
+
+ + {!postCompleted && ( + + )} +
+ + {/* Continue or hint */} + {postCompleted ? ( + + ) : ( +

+ You can add extra text before or after the required phrase. +

+ )} +
+ ); +} diff --git a/src/blobbi/tour/index.ts b/src/blobbi/tour/index.ts index 262e8d4b..564b3240 100644 --- a/src/blobbi/tour/index.ts +++ b/src/blobbi/tour/index.ts @@ -43,4 +43,4 @@ export type { } from './hooks/useFirstHatchTourActivation'; // ── First Hatch Tour - Components ── -export { FirstHatchTourModal } from './components/FirstHatchTourModal'; +export { FirstHatchTourCard } from './components/FirstHatchTourCard'; diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 8be277ff..ac9de9ca 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -90,7 +90,7 @@ import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reacti import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; -import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourModal } from '@/blobbi/tour'; +import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourCard } from '@/blobbi/tour'; import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions'; import type { EggTourVisualState } from '@/blobbi/egg'; @@ -946,26 +946,30 @@ function BlobbiDashboard({ // The required phrase for the first-hatch post const firstHatchPhrase = useMemo(() => buildHatchPhrase(companion.name), [companion.name]); - // Auto-advance from idle to egg_ready_hint, then to show_hatch_modal + // Auto-advance from idle -> egg_ready_hint -> show_hatch_modal (inline card) useEffect(() => { if (!isFirstHatchTourActive) return; if (firstHatchTour.isStep('idle')) { - // Advance immediately to egg_ready_hint - firstHatchTour.actions.advance(); + firstHatchTour.actions.advance(); // -> egg_ready_hint } }, [isFirstHatchTourActive, firstHatchTour]); useEffect(() => { if (!isFirstHatchTourActive) return; if (firstHatchTour.isStep('egg_ready_hint')) { - // Show the ready hint briefly, then move to the modal + // Show the ready hint wiggle briefly, then show the inline card const timer = setTimeout(() => { - firstHatchTour.actions.advance(); + firstHatchTour.actions.advance(); // -> show_hatch_modal (inline card step) }, 3000); return () => clearTimeout(timer); } }, [isFirstHatchTourActive, firstHatchTour]); + // Whether the inline first-hatch card should be shown + const showFirstHatchCard = isFirstHatchTourActive && ( + firstHatchTour.isStep('show_hatch_modal') || firstHatchTour.isStep('await_create_post') + ); + // Detect hatch post completion for the first-hatch tour const { user } = useCurrentUser(); const { nostr } = useNostr(); @@ -1419,8 +1423,8 @@ function BlobbiDashboard({ {/* Hero Section */}
- {/* Floating Dashboard Controls */} -