Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 471916f2b5 | |||
| 560ceb6fa0 | |||
| a765b3a7c2 | |||
| d0a5ed2f1e | |||
| 50b0265462 | |||
| 60747de33c | |||
| f408f2424f | |||
| b132e03481 | |||
| 514dd82ad3 |
@@ -24,24 +24,12 @@ import {
|
||||
KIND_BLOBBI_STATE,
|
||||
STAT_MAX,
|
||||
updateBlobbiTags,
|
||||
generateBlobbiContent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
|
||||
// ─── Content Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate the content string for a Blobbi at a given stage.
|
||||
* Format: "{name} is a {stage} Blobbi."
|
||||
*
|
||||
* Uses correct grammar: "an egg" vs "a baby/adult"
|
||||
*/
|
||||
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
const article = stage === 'egg' ? 'an' : 'a';
|
||||
return `${name} is ${article} ${stage} Blobbi.`;
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -96,6 +96,21 @@ export function getDaysDifference(dayA: string, dayB: string): number {
|
||||
export type BlobbiStage = 'egg' | 'baby' | 'adult';
|
||||
export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving';
|
||||
|
||||
// ─── Content Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate the canonical `content` string for a Blobbi event at a given stage.
|
||||
*
|
||||
* Format: "{name} is an egg Blobbi." / "{name} is a baby Blobbi."
|
||||
*
|
||||
* Use this whenever publishing or republishing a Kind 31124 event so the
|
||||
* content field is never left empty or inconsistent.
|
||||
*/
|
||||
export function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
const article = stage === 'egg' ? 'an' : 'a';
|
||||
return `${name} is ${article} ${stage} Blobbi.`;
|
||||
}
|
||||
|
||||
export interface BlobbiStats {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
@@ -308,6 +323,10 @@ export interface BlobbonautProfile {
|
||||
currentCompanion: string | undefined;
|
||||
/** Whether onboarding/tutorial is complete */
|
||||
onboardingDone: boolean;
|
||||
/** Whether the first hatch tour has been completed */
|
||||
firstHatchTourDone: boolean;
|
||||
/** Whether the UI walkthrough tour has been completed */
|
||||
uiTourDone: boolean;
|
||||
/** Display name for the Blobbonaut */
|
||||
name: string | undefined;
|
||||
/** List of owned Blobbi d-tags */
|
||||
@@ -978,6 +997,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
|
||||
currentCompanion: getTagValue(tags, 'current_companion'),
|
||||
onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false)
|
||||
|| parseBooleanTag(tags, 'onboarding_done', false),
|
||||
firstHatchTourDone: parseBooleanTag(tags, 'blobbi_first_hatch_tour_done', false),
|
||||
uiTourDone: parseBooleanTag(tags, 'blobbi_ui_tour_done', false),
|
||||
name: getTagValue(tags, 'name'),
|
||||
has: getTagValues(tags, 'has'),
|
||||
coins: parseNumericTag(tags, 'coins') ?? 0,
|
||||
@@ -998,6 +1019,8 @@ export function buildBlobbonautTags(pubkey: string): string[][] {
|
||||
['d', getCanonicalBlobbonautD(pubkey)],
|
||||
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
|
||||
['blobbi_onboarding_done', 'false'],
|
||||
['blobbi_first_hatch_tour_done', 'false'],
|
||||
['blobbi_ui_tour_done', 'false'],
|
||||
['pettingLevel', '0'],
|
||||
];
|
||||
}
|
||||
@@ -1139,7 +1162,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
|
||||
* These tags are controlled by the application and may be overwritten.
|
||||
*/
|
||||
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'blobbi_first_hatch_tour_done', 'blobbi_ui_tour_done', 'onboarding_done', 'has', 'storage',
|
||||
// Legacy player progress tags (preserved for compatibility)
|
||||
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
|
||||
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
|
||||
|
||||
@@ -29,13 +29,13 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
|
||||
|
||||
/** Tour dev actions for the first-hatch tour */
|
||||
interface FirstHatchTourDevActions {
|
||||
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
|
||||
skipPostRequirement: () => void;
|
||||
/** Reset the entire first-hatch tour so it can be tested again from scratch */
|
||||
resetTour: () => void;
|
||||
/** Jump from idle directly to egg_glowing_waiting_click (skip the auto-advance) */
|
||||
skipToEggGlow: () => void;
|
||||
/** Reset in-memory tour state AND Kind 11125 profile tags so the tour can re-activate */
|
||||
resetTour: () => Promise<void>;
|
||||
/** Current tour step id, or null if not active */
|
||||
currentStepId: string | null;
|
||||
/** Whether the tour has been completed */
|
||||
/** Whether the tour has been completed (this session) */
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
@@ -561,50 +561,50 @@ export function BlobbiDevEditor({
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Test the first-hatch tour flow without needing to create a real post.
|
||||
Test the first-hatch tour. Profile tag <code>blobbi_first_hatch_tour_done</code> is the persisted signal.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* A. Skip Post Requirement */}
|
||||
{/* A. Skip idle → egg glow */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.skipPostRequirement();
|
||||
tourDevActions.skipToEggGlow();
|
||||
}}
|
||||
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
|
||||
disabled={tourDevActions.currentStepId !== 'idle'}
|
||||
className="gap-2 text-xs"
|
||||
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
|
||||
title="Jump from idle to egg_glowing_waiting_click"
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
Skip Post
|
||||
Skip to Glow
|
||||
</Button>
|
||||
|
||||
{/* B. Restart First-Hatch Tour */}
|
||||
{/* B. Restart tour (resets profile tags + in-memory state) */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.resetTour();
|
||||
void tourDevActions.resetTour();
|
||||
}}
|
||||
className="gap-2 text-xs"
|
||||
title="Reset the entire first-hatch tour state so it can be tested again"
|
||||
title="Reset profile tags + in-memory state so the tour can re-activate"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Restart Tour
|
||||
</Button>
|
||||
|
||||
{/* C. Reset Blobbi to Egg */}
|
||||
{/* C. Reset Blobbi to egg + restart tour */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStage('egg');
|
||||
setState('active');
|
||||
tourDevActions.resetTour();
|
||||
void tourDevActions.resetTour();
|
||||
}}
|
||||
disabled={companion.stage === 'egg'}
|
||||
className="gap-2 text-xs"
|
||||
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
|
||||
title="Set stage to egg + reset tour tags — click Apply to publish, then tour auto-starts"
|
||||
>
|
||||
<Egg className="size-3.5" />
|
||||
Reset to Egg + Tour
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbiStage } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString, generateBlobbiContent } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiDevUpdates } from './BlobbiDevEditor';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -34,17 +34,6 @@ interface DevUpdateResult {
|
||||
changedFields: string[];
|
||||
}
|
||||
|
||||
// ─── Content Helper ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate the content string for a Blobbi at a given stage.
|
||||
* Format: "{name} is a {stage} Blobbi."
|
||||
*/
|
||||
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
const article = stage === 'egg' ? 'an' : 'a';
|
||||
return `${name} is ${article} ${stage} Blobbi.`;
|
||||
}
|
||||
|
||||
// ─── Hook Implementation ──────────────────────────────────────────────────────
|
||||
|
||||
export function useBlobbiDevUpdate({
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface EggStatusEffects {
|
||||
* Driven by the tour orchestration layer, not by EggGraphic itself.
|
||||
*
|
||||
* - idle: no tour effects
|
||||
* - show_hatch_card: initial crack visible + auto-wiggle every 2.5s
|
||||
* - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap
|
||||
* - crack_stage_1: crack expands (click 1)
|
||||
* - crack_stage_2: crack expands more (click 2)
|
||||
@@ -40,7 +39,6 @@ export interface EggStatusEffects {
|
||||
*/
|
||||
export type EggTourVisualState =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'glowing_waiting_click'
|
||||
| 'crack_stage_1'
|
||||
| 'crack_stage_2'
|
||||
@@ -195,8 +193,8 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
setIsTapWiggling(false);
|
||||
}, []);
|
||||
|
||||
// Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states
|
||||
const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click';
|
||||
// Tour: auto-wiggle effect for glowing_waiting_click state
|
||||
const shouldAutoWiggle = tourVisualState === 'glowing_waiting_click';
|
||||
const autoWiggleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!shouldAutoWiggle) {
|
||||
@@ -228,7 +226,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching';
|
||||
|
||||
// Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding)
|
||||
// Level 0: small central crack (show_hatch_card, glowing_waiting_click)
|
||||
// Level 0: small central crack (glowing_waiting_click)
|
||||
// Level 1: crack expands left/right with small branches (crack_stage_1)
|
||||
// Level 2: crack expands further toward edges, more branches (crack_stage_2)
|
||||
// Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening)
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* BlobbiRevealOverlay - Full-screen celebration overlay shown after hatching.
|
||||
*
|
||||
* Features:
|
||||
* - Darkened backdrop
|
||||
* - Newly hatched Blobbi in the center with light rays + particles
|
||||
* - Naming input so the user can rename immediately
|
||||
* - Click outside or press Escape to dismiss
|
||||
*
|
||||
* This component is reusable for future reveals:
|
||||
* "Blobbi evolved", "Blobbi hatched", "special reward", etc.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Particle Layout ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-computed particle positions for the reveal effect.
|
||||
*
|
||||
* Particles are placed in polar coordinates (angle + radius from center)
|
||||
* and converted to percentage offsets within a 500px field. This produces
|
||||
* a radial distribution that complements the ray/glow composition instead
|
||||
* of a uniform square scatter.
|
||||
*
|
||||
* Layout strategy:
|
||||
* - 3 concentric rings at different radii (inner, mid, outer)
|
||||
* - Particles staggered across rings so they don't align with the 8 rays
|
||||
* - Varied sizes and animation timing per ring for depth
|
||||
* - Deterministic: no Math.random() in render, stable across re-renders
|
||||
*/
|
||||
interface ParticleDef {
|
||||
/** X offset as percentage of the 500px field (0–100) */
|
||||
x: number;
|
||||
/** Y offset as percentage of the 500px field (0–100) */
|
||||
y: number;
|
||||
/** Particle diameter in px */
|
||||
size: number;
|
||||
/** Animation delay in seconds */
|
||||
delay: number;
|
||||
/** Animation duration in seconds */
|
||||
duration: number;
|
||||
/** Opacity 0–1 */
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert polar coordinates (angle in degrees, radius as 0–50 percent of field)
|
||||
* to x/y percentages within a square field centered at 50%/50%.
|
||||
*/
|
||||
function polar(angleDeg: number, radiusPct: number): { x: number; y: number } {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
return {
|
||||
x: 50 + radiusPct * Math.cos(rad),
|
||||
y: 50 + radiusPct * Math.sin(rad),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The 8 rays are at 0°, 45°, 90°, … 315°. Particles are offset from
|
||||
* those angles so they sit *between* rays, not on top of them.
|
||||
*/
|
||||
const REVEAL_PARTICLES: ParticleDef[] = (() => {
|
||||
const particles: ParticleDef[] = [];
|
||||
|
||||
// Inner ring — 4 particles, small, fast, bright
|
||||
// Angles offset ~22° from rays so they sit in the gaps
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const angle = 22 + i * 90; // 22°, 112°, 202°, 292°
|
||||
const { x, y } = polar(angle, 18);
|
||||
particles.push({ x, y, size: 3, delay: i * 0.4, duration: 2.2, opacity: 0.7 });
|
||||
}
|
||||
|
||||
// Mid ring — 5 particles, medium, moderate speed
|
||||
// Staggered at ~36° increments starting at 10°
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const angle = 10 + i * 72; // 10°, 82°, 154°, 226°, 298°
|
||||
const { x, y } = polar(angle, 30);
|
||||
particles.push({ x, y, size: 4, delay: 0.2 + i * 0.35, duration: 2.8, opacity: 0.55 });
|
||||
}
|
||||
|
||||
// Outer ring — 6 particles, larger, slower, subtler
|
||||
// Distributed at 60° increments starting at 35°
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = 35 + i * 60; // 35°, 95°, 155°, 215°, 275°, 335°
|
||||
const { x, y } = polar(angle, 42);
|
||||
particles.push({ x, y, size: 5, delay: 0.1 + i * 0.3, duration: 3.4, opacity: 0.4 });
|
||||
}
|
||||
|
||||
return particles;
|
||||
})();
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiRevealOverlayProps {
|
||||
/** The companion to reveal (should be in baby stage after hatching) */
|
||||
companion: BlobbiCompanion;
|
||||
/** Whether the overlay is visible */
|
||||
open: boolean;
|
||||
/** Called when the overlay is dismissed (click outside or confirm) */
|
||||
onDismiss: () => void;
|
||||
/** Called when user confirms a name */
|
||||
onNameConfirm: (name: string) => Promise<void>;
|
||||
/** Whether the name update is in progress */
|
||||
isNaming?: boolean;
|
||||
/** Whether to respect reduced-motion preferences */
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiRevealOverlay({
|
||||
companion,
|
||||
open,
|
||||
onDismiss,
|
||||
onNameConfirm,
|
||||
isNaming = false,
|
||||
reducedMotion = false,
|
||||
}: BlobbiRevealOverlayProps) {
|
||||
const [name, setName] = useState(companion.name === 'Egg' ? '' : companion.name);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Animate in
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Small delay for the animation to feel intentional
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Focus the input when overlay opens
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (contentRef.current && !contentRef.current.contains(e.target as Node)) {
|
||||
onDismiss();
|
||||
}
|
||||
}, [onDismiss]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onDismiss();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [open, onDismiss]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length > 0) {
|
||||
await onNameConfirm(trimmed);
|
||||
} else {
|
||||
// If empty, just dismiss without renaming
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isNaming) {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 flex items-center justify-center',
|
||||
'transition-opacity duration-500',
|
||||
isVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Your Blobbi has hatched!"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
{/* Center content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center gap-6 z-10 px-6 max-w-sm w-full',
|
||||
'transition-all duration-700 ease-out',
|
||||
isVisible ? 'translate-y-0 scale-100' : 'translate-y-8 scale-95',
|
||||
)}
|
||||
>
|
||||
{/* Celebration header */}
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 text-amber-300',
|
||||
'transition-opacity duration-500 delay-300',
|
||||
isVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}>
|
||||
<Sparkles className="size-5" />
|
||||
<span className="text-lg font-semibold tracking-wide uppercase">
|
||||
Hatched!
|
||||
</span>
|
||||
<Sparkles className="size-5" />
|
||||
</div>
|
||||
|
||||
{/* Blobbi visual — rays/glow are positioned relative to this container
|
||||
so the composition is always centered on the Blobbi itself */}
|
||||
<div className={cn(
|
||||
'relative flex items-center justify-center',
|
||||
'transition-all duration-700 delay-200',
|
||||
isVisible ? 'translate-y-0 scale-100' : 'translate-y-4 scale-90',
|
||||
)}>
|
||||
{/* Light rays + glow + particles (centered on the Blobbi) */}
|
||||
{!reducedMotion && (
|
||||
<>
|
||||
{/* Radial glow — anchored to Blobbi center */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute size-[500px] rounded-full pointer-events-none',
|
||||
'transition-transform duration-1000 ease-out',
|
||||
isVisible ? 'scale-100' : 'scale-50',
|
||||
)}
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(251,191,36,0.3) 0%, rgba(251,191,36,0.1) 35%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Rotating rays — anchored to Blobbi center */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute size-[600px] pointer-events-none',
|
||||
isVisible ? 'animate-spin-slow' : '',
|
||||
)}
|
||||
style={{ animationDuration: '20s' }}
|
||||
>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-1/2 left-1/2 h-[300px] w-[2px] -translate-x-1/2 origin-top"
|
||||
style={{
|
||||
transform: `translateX(-50%) rotate(${i * 45}deg)`,
|
||||
background: 'linear-gradient(to bottom, rgba(251,191,36,0.4), transparent)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floating particles — radially distributed across a 500px field */}
|
||||
<div className="absolute size-[500px] pointer-events-none">
|
||||
{REVEAL_PARTICLES.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-amber-300 animate-float-particle"
|
||||
style={{
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
opacity: p.opacity,
|
||||
animationDelay: `${p.delay}s`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The actual Blobbi */}
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated
|
||||
className="relative z-10 size-48 sm:size-56 drop-shadow-[0_0_30px_rgba(251,191,36,0.3)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Naming section */}
|
||||
<div className={cn(
|
||||
'w-full space-y-4 transition-all duration-500 delay-500',
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4',
|
||||
)}>
|
||||
<div className="text-center">
|
||||
<p className="text-white/80 text-sm">
|
||||
Give your new Blobbi a name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter a name..."
|
||||
maxLength={32}
|
||||
disabled={isNaming}
|
||||
className="text-center font-medium text-lg bg-white/10 border-white/20 text-white placeholder:text-white/40 focus:border-amber-400/50 focus:ring-amber-400/20"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1 text-white/60 hover:text-white hover:bg-white/10"
|
||||
onClick={onDismiss}
|
||||
disabled={isNaming}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 bg-amber-500 hover:bg-amber-600 text-white shadow-lg shadow-amber-500/20"
|
||||
onClick={handleConfirm}
|
||||
disabled={isNaming || name.trim().length === 0}
|
||||
>
|
||||
{isNaming ? 'Saving...' : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +1,52 @@
|
||||
/**
|
||||
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
|
||||
* FirstHatchTourCard - DEPRECATED
|
||||
*
|
||||
* Rendered directly in the BlobbiPage layout so the experience feels
|
||||
* focused and guided. Adapts its messaging based on the current tour step.
|
||||
* This component was used for the post-mission step of the first hatch tour.
|
||||
* The simplified first-egg experience no longer requires a post mission.
|
||||
*
|
||||
* When the post mission is completed, the card stays visible with a
|
||||
* celebratory completed state for ~2s (the parent auto-advances after
|
||||
* that delay). This ensures the user sees the checkmark before the
|
||||
* flow progresses to the egg-tap phase.
|
||||
* Kept for backwards compatibility but no longer exported from the tour barrel.
|
||||
* The inline tap-hint messaging is now rendered directly in BlobbiPage.
|
||||
*
|
||||
* @deprecated Use inline tap hints in BlobbiPage instead
|
||||
*/
|
||||
|
||||
import { Send, Check, MousePointerClick } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
|
||||
import type { FirstHatchTourStepId } from '../lib/tour-types';
|
||||
|
||||
// ─── 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;
|
||||
/** Current tour step id for adaptive messaging */
|
||||
currentStep: FirstHatchTourStepId | null;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourCard({
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
currentStep,
|
||||
}: FirstHatchTourCardProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
// Determine which phase of the card to show
|
||||
const isPostStep = currentStep === 'show_hatch_card';
|
||||
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|
||||
|| currentStep === 'egg_crack_stage_1'
|
||||
|| currentStep === 'egg_crack_stage_2'
|
||||
|| currentStep === 'egg_crack_stage_3';
|
||||
|
||||
if (!isClickStep) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm mx-auto space-y-4">
|
||||
{/* Title + description */}
|
||||
<div className="text-center space-y-1.5">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{isClickStep
|
||||
? `Tap ${capitalizedName} to hatch!`
|
||||
: postCompleted && isPostStep
|
||||
? `${capitalizedName} heard you!`
|
||||
: `${capitalizedName} is ready to hatch!`}
|
||||
Tap {capitalizedName} to hatch!
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{isClickStep
|
||||
? `Tap the egg to help ${capitalizedName} break free.`
|
||||
: postCompleted && isPostStep
|
||||
? 'Your post was shared. Get ready to hatch...'
|
||||
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
|
||||
Tap the egg to help {capitalizedName} break free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card - only during post step */}
|
||||
{isPostStep && (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
{postCompleted ? (
|
||||
/* ── Completed state — celebratory, stays visible ── */
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
|
||||
<Check className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
Post shared!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Continuing in a moment...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Pending state — post mission ── */
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">Share a hatch post</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your post must include:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tap hint during click steps */}
|
||||
{isClickStep && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<MousePointerClick className="size-4" />
|
||||
<span>Tap the egg</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra hint for post step */}
|
||||
{isPostStep && !postCompleted && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<MousePointerClick className="size-4" />
|
||||
<span>Tap the egg</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* GuidedModal - Reusable tour explanation card.
|
||||
*
|
||||
* Renders a card with:
|
||||
* - Title and body text
|
||||
* - Previous / Next navigation buttons
|
||||
* - Optional close/skip action
|
||||
*
|
||||
* Used by the UI tour overlay for each step's explanation.
|
||||
* Does not position itself — the parent controls placement.
|
||||
*/
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GuidedModalProps {
|
||||
/** Title text */
|
||||
title: string;
|
||||
/** Body / explanation text */
|
||||
body: string;
|
||||
/** Whether a "Previous" button should be shown */
|
||||
showPrev: boolean;
|
||||
/** Whether a "Next" button should be shown */
|
||||
showNext: boolean;
|
||||
/** Label for the next button (defaults to "Next") */
|
||||
nextLabel?: string;
|
||||
/** Called when user clicks Previous */
|
||||
onPrev?: () => void;
|
||||
/** Called when user clicks Next */
|
||||
onNext?: () => void;
|
||||
/** Called when user clicks Skip/Close */
|
||||
onSkip?: () => void;
|
||||
/** Additional className for the outer container */
|
||||
className?: string;
|
||||
/** Whether the modal is visible (for enter/exit transitions) */
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function GuidedModal({
|
||||
title,
|
||||
body,
|
||||
showPrev,
|
||||
showNext,
|
||||
nextLabel = 'Next',
|
||||
onPrev,
|
||||
onNext,
|
||||
onSkip,
|
||||
className,
|
||||
visible = true,
|
||||
}: GuidedModalProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full max-w-sm rounded-2xl border border-border bg-card/95 backdrop-blur-md shadow-2xl',
|
||||
'transition-all duration-500 ease-out',
|
||||
visible
|
||||
? 'opacity-100 translate-y-0 scale-100'
|
||||
: 'opacity-0 translate-y-4 scale-95 pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-5 space-y-3">
|
||||
{title && (
|
||||
<h3 className="text-base font-semibold leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{body && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 pb-4">
|
||||
{/* Left: Previous or Skip */}
|
||||
<div>
|
||||
{showPrev ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onPrev}
|
||||
className="gap-1 text-muted-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
) : onSkip ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Next */}
|
||||
<div>
|
||||
{showNext && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
className="gap-1"
|
||||
>
|
||||
{nextLabel}
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* MiniBlobbiGuide - Choreography-driven miniature Blobbi tour guide.
|
||||
*
|
||||
* This is NOT a generic walker. It is a character actor that performs
|
||||
* staged sequences based on the GuideIntent from the orchestrator:
|
||||
*
|
||||
* emerge_onto_modal → peek from behind modal top → climb up → start pacing
|
||||
* pace_on_modal → walk left/right, edge-look-down at edges
|
||||
* fall_from_surface → fall off current surface downward
|
||||
* emerge_onto_bar → peek from behind bar top → climb up
|
||||
* walk_to_target → walk along bar to targetX, stop when aligned
|
||||
* inspect_target → idle above target, leaning forward / looking down
|
||||
*
|
||||
* Each intent drives internal animation phases. The component manages
|
||||
* its own sub-phase progression with deltaTime-based physics.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { calculateFloatAnimation } from '@/blobbi/companion/utils/animation';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { GuideIntent } from '../lib/ui-tour-types';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Size of the mini guide in pixels */
|
||||
export const GUIDE_SIZE = 48;
|
||||
|
||||
/** Walking speed in pixels per second (companion mid-energy range) */
|
||||
const WALK_SPEED = 45;
|
||||
|
||||
/** How close to edge before triggering edge-look (px from surface edge) */
|
||||
const EDGE_THRESHOLD = 12;
|
||||
|
||||
// Timing constants (ms)
|
||||
const EMERGE_PEEK_DURATION = 600;
|
||||
const EMERGE_LOOK_DURATION = 800;
|
||||
const EMERGE_CLIMB_DURATION = 500;
|
||||
const EDGE_LOOK_PAUSE = 900;
|
||||
const FALL_DURATION = 500;
|
||||
|
||||
// ─── Internal Sub-Phases ──────────────────────────────────────────────────────
|
||||
|
||||
type SubPhase =
|
||||
| 'hidden'
|
||||
| 'emerge_peek' // Partially visible behind surface, rising slowly
|
||||
| 'emerge_look' // Paused at peek, looking around
|
||||
| 'emerge_climb' // Climbing up onto the surface
|
||||
| 'pacing' // Walking freely on surface
|
||||
| 'edge_looking' // Stopped at edge, leaning forward
|
||||
| 'walking_to_target' // Walking toward a specific X
|
||||
| 'at_target' // Stopped above target, inspecting
|
||||
| 'falling'; // Falling off surface
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MiniBlobbiGuideProps {
|
||||
companion: BlobbiCompanion;
|
||||
/** The high-level choreography intent from the orchestrator */
|
||||
intent: GuideIntent;
|
||||
/** Left edge of the walking surface (viewport px) */
|
||||
surfaceLeft: number;
|
||||
/** Right edge of the walking surface (viewport px) */
|
||||
surfaceRight: number;
|
||||
/** Y position of the surface top edge (viewport px) */
|
||||
surfaceY: number;
|
||||
/** Target X position to walk to (viewport px, center of target item) */
|
||||
targetX?: number;
|
||||
/** Called when an emerge sequence completes (guide is on surface) */
|
||||
onEmergeComplete?: () => void;
|
||||
/** Called when a fall animation completes (guide is off screen) */
|
||||
onFallComplete?: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function MiniBlobbiGuide({
|
||||
companion,
|
||||
intent,
|
||||
surfaceLeft,
|
||||
surfaceRight,
|
||||
surfaceY,
|
||||
targetX,
|
||||
onEmergeComplete,
|
||||
onFallComplete,
|
||||
}: MiniBlobbiGuideProps) {
|
||||
// ── Imperative animation state ──
|
||||
const subPhaseRef = useRef<SubPhase>('hidden');
|
||||
const xRef = useRef(0); // Local X offset from surfaceLeft
|
||||
const yOffsetRef = useRef(GUIDE_SIZE); // Y offset below surface (positive = hidden below)
|
||||
const facingRightRef = useRef(true);
|
||||
const phaseStartRef = useRef(0); // Timestamp when current subPhase began
|
||||
const lastTimeRef = useRef(0);
|
||||
const animRef = useRef(0);
|
||||
|
||||
// React render state (synced from animation loop)
|
||||
const [render, setRender] = useState({
|
||||
x: 0, yOffset: GUIDE_SIZE, facingRight: true,
|
||||
floatX: 0, floatY: 0, floatRotation: 0,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
// ── Intent → SubPhase transitions ──
|
||||
|
||||
const prevIntentRef = useRef<GuideIntent>('hidden');
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevIntentRef.current;
|
||||
prevIntentRef.current = intent;
|
||||
|
||||
const now = performance.now();
|
||||
phaseStartRef.current = now;
|
||||
|
||||
switch (intent) {
|
||||
case 'hidden':
|
||||
subPhaseRef.current = 'hidden';
|
||||
yOffsetRef.current = GUIDE_SIZE;
|
||||
break;
|
||||
|
||||
case 'emerge_onto_modal':
|
||||
case 'emerge_onto_bar': {
|
||||
// Start hidden below surface, center of surface
|
||||
const surfaceWidth = surfaceRight - surfaceLeft;
|
||||
xRef.current = (surfaceWidth - GUIDE_SIZE) / 2;
|
||||
yOffsetRef.current = GUIDE_SIZE; // Fully hidden
|
||||
subPhaseRef.current = 'emerge_peek';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pace_on_modal':
|
||||
// If already on surface from emerge, just start pacing
|
||||
if (prev === 'emerge_onto_modal' || prev === 'pace_on_modal') {
|
||||
subPhaseRef.current = 'pacing';
|
||||
} else {
|
||||
subPhaseRef.current = 'pacing';
|
||||
const surfaceWidth = surfaceRight - surfaceLeft;
|
||||
xRef.current = (surfaceWidth - GUIDE_SIZE) / 2;
|
||||
yOffsetRef.current = 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fall_from_surface':
|
||||
subPhaseRef.current = 'falling';
|
||||
break;
|
||||
|
||||
case 'walk_to_target':
|
||||
// Keep current X position, start walking toward target
|
||||
subPhaseRef.current = 'walking_to_target';
|
||||
yOffsetRef.current = 0;
|
||||
break;
|
||||
|
||||
case 'inspect_target':
|
||||
subPhaseRef.current = 'at_target';
|
||||
yOffsetRef.current = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// Reset animation timing
|
||||
lastTimeRef.current = 0;
|
||||
}, [intent, surfaceLeft, surfaceRight]);
|
||||
|
||||
// ── Animation loop ──
|
||||
|
||||
const loop = useCallback((timestamp: number) => {
|
||||
if (lastTimeRef.current === 0) lastTimeRef.current = timestamp;
|
||||
const deltaMs = Math.min(timestamp - lastTimeRef.current, 50);
|
||||
lastTimeRef.current = timestamp;
|
||||
const dt = deltaMs / 1000;
|
||||
const elapsed = timestamp - phaseStartRef.current;
|
||||
|
||||
const subPhase = subPhaseRef.current;
|
||||
const surfaceWidth = surfaceRight - surfaceLeft - GUIDE_SIZE;
|
||||
|
||||
let isMoving = false;
|
||||
|
||||
switch (subPhase) {
|
||||
// ── Emerge sequence ──
|
||||
case 'emerge_peek': {
|
||||
// Rise from fully hidden (yOffset = GUIDE_SIZE) to peek (yOffset ≈ GUIDE_SIZE * 0.5)
|
||||
const progress = Math.min(elapsed / EMERGE_PEEK_DURATION, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
||||
yOffsetRef.current = GUIDE_SIZE * (1 - eased * 0.5);
|
||||
if (progress >= 1) {
|
||||
subPhaseRef.current = 'emerge_look';
|
||||
phaseStartRef.current = timestamp;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'emerge_look': {
|
||||
// Stay at peek position, companion visual does the looking
|
||||
if (elapsed >= EMERGE_LOOK_DURATION) {
|
||||
subPhaseRef.current = 'emerge_climb';
|
||||
phaseStartRef.current = timestamp;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'emerge_climb': {
|
||||
// Rise from peek to fully on surface (yOffset = 0)
|
||||
const progress = Math.min(elapsed / EMERGE_CLIMB_DURATION, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
yOffsetRef.current = GUIDE_SIZE * 0.5 * (1 - eased);
|
||||
if (progress >= 1) {
|
||||
yOffsetRef.current = 0;
|
||||
subPhaseRef.current = 'pacing';
|
||||
phaseStartRef.current = timestamp;
|
||||
onEmergeComplete?.();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Pacing with edge-look ──
|
||||
case 'pacing': {
|
||||
isMoving = true;
|
||||
let x = xRef.current;
|
||||
const move = WALK_SPEED * dt;
|
||||
|
||||
if (facingRightRef.current) {
|
||||
x += move;
|
||||
if (x >= surfaceWidth - EDGE_THRESHOLD) {
|
||||
x = surfaceWidth - EDGE_THRESHOLD;
|
||||
subPhaseRef.current = 'edge_looking';
|
||||
phaseStartRef.current = timestamp;
|
||||
}
|
||||
} else {
|
||||
x -= move;
|
||||
if (x <= EDGE_THRESHOLD) {
|
||||
x = EDGE_THRESHOLD;
|
||||
subPhaseRef.current = 'edge_looking';
|
||||
phaseStartRef.current = timestamp;
|
||||
}
|
||||
}
|
||||
xRef.current = x;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'edge_looking': {
|
||||
// Pause at edge, lean forward (floatRotation handled below)
|
||||
if (elapsed >= EDGE_LOOK_PAUSE) {
|
||||
// Turn around and resume pacing
|
||||
facingRightRef.current = !facingRightRef.current;
|
||||
subPhaseRef.current = 'pacing';
|
||||
phaseStartRef.current = timestamp;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Walk to specific target ──
|
||||
case 'walking_to_target': {
|
||||
if (targetX === undefined) break;
|
||||
const targetLocal = targetX - surfaceLeft - GUIDE_SIZE / 2;
|
||||
const distToTarget = targetLocal - xRef.current;
|
||||
|
||||
if (Math.abs(distToTarget) < 2) {
|
||||
// Arrived at target
|
||||
xRef.current = targetLocal;
|
||||
subPhaseRef.current = 'at_target';
|
||||
phaseStartRef.current = timestamp;
|
||||
facingRightRef.current = true; // Face right (default rest)
|
||||
} else {
|
||||
isMoving = true;
|
||||
const direction = distToTarget > 0 ? 1 : -1;
|
||||
facingRightRef.current = direction > 0;
|
||||
const step = Math.min(WALK_SPEED * dt, Math.abs(distToTarget));
|
||||
xRef.current += direction * step;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Inspecting target ──
|
||||
case 'at_target': {
|
||||
// Idle — organic breathing applied below
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Falling ──
|
||||
case 'falling': {
|
||||
const progress = Math.min(elapsed / FALL_DURATION, 1);
|
||||
const eased = progress * progress; // easeInQuad (gravity)
|
||||
yOffsetRef.current = -eased * window.innerHeight * 0.5;
|
||||
// yOffset goes negative = moves downward from surface
|
||||
if (progress >= 1) {
|
||||
subPhaseRef.current = 'hidden';
|
||||
onFallComplete?.();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hidden':
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Apply float animation ──
|
||||
const float = calculateFloatAnimation(timestamp, isMoving);
|
||||
const floatScale = isMoving ? 0.4 : 0.5;
|
||||
|
||||
// Edge-look: override rotation to lean forward
|
||||
let rotation = float.rotation * floatScale;
|
||||
if (subPhase === 'edge_looking') {
|
||||
const leanProgress = Math.min(elapsed / 300, 1);
|
||||
rotation = leanProgress * 12; // Lean forward 12°
|
||||
}
|
||||
if (subPhase === 'at_target') {
|
||||
const leanProgress = Math.min(elapsed / 400, 1);
|
||||
rotation = leanProgress * 8; // Gentle lean 8°
|
||||
}
|
||||
|
||||
setRender({
|
||||
x: xRef.current,
|
||||
yOffset: yOffsetRef.current,
|
||||
facingRight: facingRightRef.current,
|
||||
floatX: float.x * floatScale,
|
||||
floatY: float.y * floatScale,
|
||||
floatRotation: rotation,
|
||||
visible: subPhase !== 'hidden',
|
||||
});
|
||||
|
||||
animRef.current = requestAnimationFrame(loop);
|
||||
}, [surfaceLeft, surfaceRight, targetX, onEmergeComplete, onFallComplete]);
|
||||
|
||||
// Start/stop loop
|
||||
useEffect(() => {
|
||||
lastTimeRef.current = 0;
|
||||
animRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [loop]);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
if (!render.visible) return null;
|
||||
|
||||
// Position: surfaceLeft + local X, surfaceY - GUIDE_SIZE + yOffset
|
||||
// yOffset > 0 means hidden below surface (emerge)
|
||||
// yOffset < 0 means fallen below surface (fall)
|
||||
const left = surfaceLeft + render.x + render.floatX;
|
||||
const top = surfaceY - GUIDE_SIZE + render.yOffset + render.floatY;
|
||||
|
||||
// Clip: during emerge, the guide should be clipped by the surface edge.
|
||||
// We use clip-path to hide the portion below surfaceY.
|
||||
const isEmerging = subPhaseRef.current === 'emerge_peek'
|
||||
|| subPhaseRef.current === 'emerge_look'
|
||||
|| subPhaseRef.current === 'emerge_climb';
|
||||
|
||||
// How much of the guide is above the surface (in px)
|
||||
const visibleHeight = GUIDE_SIZE - Math.max(render.yOffset, 0);
|
||||
const clipTop = isEmerging ? Math.max(GUIDE_SIZE - visibleHeight, 0) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed pointer-events-none z-[60]"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: GUIDE_SIZE,
|
||||
height: GUIDE_SIZE,
|
||||
transform: `scaleX(${render.facingRight ? 1 : -1}) rotate(${render.floatRotation}deg)`,
|
||||
clipPath: clipTop > 0 ? `inset(${clipTop}px 0 0 0)` : undefined,
|
||||
}}
|
||||
>
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="sm"
|
||||
animated
|
||||
reaction={subPhaseRef.current === 'pacing' || subPhaseRef.current === 'walking_to_target' ? 'happy' : 'idle'}
|
||||
className="size-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* UITourOverlay - Orchestrator for the Blobbi UI walkthrough tour.
|
||||
*
|
||||
* This component translates the current tour step into choreography
|
||||
* intents for the MiniBlobbiGuide and positions all UI elements.
|
||||
*
|
||||
* Step → Intent mapping:
|
||||
* welcome (first render) → emerge_onto_modal → pace_on_modal
|
||||
* welcome → Next click → fall_from_surface → emerge_onto_bar → walk_to_target → inspect_target
|
||||
* bar_item_N → Next → walk_to_target (next item) → inspect_target
|
||||
* bar_item_N → Back → walk_to_target (prev item) → inspect_target
|
||||
* bar_item_N → Back to welcome → emerge_onto_modal → pace_on_modal
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTourAnchors } from '../lib/TourAnchorContext';
|
||||
import { GuidedModal } from './GuidedModal';
|
||||
import { MiniBlobbiGuide } from './MiniBlobbiGuide';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { GuideIntent } from '../lib/ui-tour-types';
|
||||
import type { UITourState, UITourActions } from '../hooks/useUITour';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UITourOverlayProps {
|
||||
tourState: UITourState;
|
||||
tourActions: UITourActions;
|
||||
companion: BlobbiCompanion;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition phases between steps.
|
||||
* 'none' = stable on current step, 'falling' = guide falling off surface,
|
||||
* 'emerging_bar' = guide emerging onto bar after fall.
|
||||
*/
|
||||
type TransitionPhase =
|
||||
| 'none'
|
||||
| 'falling'
|
||||
| 'emerging_bar';
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function UITourOverlay({
|
||||
tourState,
|
||||
tourActions,
|
||||
companion,
|
||||
onComplete,
|
||||
}: UITourOverlayProps) {
|
||||
const { getAnchorRect } = useTourAnchors();
|
||||
|
||||
const [transition, setTransition] = useState<TransitionPhase>('none');
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [highlightRect, setHighlightRect] = useState<DOMRect | null>(null);
|
||||
const [modalRect, setModalRect] = useState<DOMRect | null>(null);
|
||||
const [emergeComplete, setEmergeComplete] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const step = tourState.currentStep;
|
||||
const isWelcome = step?.id === 'welcome';
|
||||
const isBarStep = step?.id.startsWith('bar_item_') ?? false;
|
||||
|
||||
// ─── Fade in ──────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (tourState.isActive) {
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [tourState.isActive]);
|
||||
|
||||
// ─── Measure modal rect ───────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalRef.current) { setModalRect(null); return; }
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (modalRef.current) setModalRect(modalRef.current.getBoundingClientRect());
|
||||
});
|
||||
observer.observe(modalRef.current);
|
||||
setModalRect(modalRef.current.getBoundingClientRect());
|
||||
return () => observer.disconnect();
|
||||
}, [step]);
|
||||
|
||||
// ─── Highlight rect ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!step?.highlightAnchor) { setHighlightRect(null); return; }
|
||||
const update = () => setHighlightRect(getAnchorRect(step.highlightAnchor!) ?? null);
|
||||
update();
|
||||
window.addEventListener('scroll', update, true);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update, true);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [step, getAnchorRect]);
|
||||
|
||||
// Reset emergeComplete when step changes
|
||||
useEffect(() => {
|
||||
setEmergeComplete(false);
|
||||
}, [step?.id]);
|
||||
|
||||
// ─── Guide intent derivation ──────────────────────────────────────────
|
||||
|
||||
const guideIntent: GuideIntent = useMemo(() => {
|
||||
// Transition overrides
|
||||
if (transition === 'falling') return 'fall_from_surface';
|
||||
if (transition === 'emerging_bar') return 'emerge_onto_bar';
|
||||
|
||||
if (!step) return 'hidden';
|
||||
if (step.guideTarget.type === 'offscreen') return 'hidden';
|
||||
|
||||
// Welcome step: emerge then pace
|
||||
if (isWelcome) {
|
||||
return emergeComplete ? 'pace_on_modal' : 'emerge_onto_modal';
|
||||
}
|
||||
|
||||
// Bar item steps: after emerging, walk to target then inspect
|
||||
if (isBarStep) {
|
||||
return emergeComplete ? 'walk_to_target' : 'walk_to_target';
|
||||
}
|
||||
|
||||
return 'hidden';
|
||||
}, [step, isWelcome, isBarStep, transition, emergeComplete]);
|
||||
|
||||
// ─── Surface calculation ──────────────────────────────────────────────
|
||||
|
||||
// Modal surface: guide walks on modal top edge
|
||||
const modalSurface = useMemo(() => {
|
||||
if (!modalRect) return { left: 0, right: 0, y: 0 };
|
||||
return {
|
||||
left: modalRect.left + 12,
|
||||
right: modalRect.right - 12,
|
||||
y: modalRect.top,
|
||||
};
|
||||
}, [modalRect]);
|
||||
|
||||
// Bar surface: full width of the bottom bar items
|
||||
const barSurface = useMemo(() => {
|
||||
const firstRect = getAnchorRect('bar-item-0');
|
||||
if (!firstRect) return { left: 0, right: 0, y: 0 };
|
||||
let rightEdge = firstRect.right;
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const r = getAnchorRect(`bar-item-${i}`);
|
||||
if (r) rightEdge = Math.max(rightEdge, r.right);
|
||||
else break;
|
||||
}
|
||||
return { left: firstRect.left, right: rightEdge, y: firstRect.top };
|
||||
}, [getAnchorRect, step]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Active surface depends on current context
|
||||
const activeSurface = useMemo(() => {
|
||||
if (transition === 'falling') return modalSurface; // Falling from modal
|
||||
if (transition === 'emerging_bar') return barSurface;
|
||||
if (isWelcome) return modalSurface;
|
||||
if (isBarStep) return barSurface;
|
||||
return modalSurface;
|
||||
}, [isWelcome, isBarStep, transition, modalSurface, barSurface]);
|
||||
|
||||
// Target X for bar steps: center of the highlighted anchor item
|
||||
const barTargetX = useMemo((): number | undefined => {
|
||||
if (!step?.highlightAnchor) return undefined;
|
||||
const rect = getAnchorRect(step.highlightAnchor);
|
||||
if (!rect) return undefined;
|
||||
return rect.left + rect.width / 2;
|
||||
}, [step, getAnchorRect]);
|
||||
|
||||
// ─── Guide callbacks ──────────────────────────────────────────────────
|
||||
|
||||
const handleEmergeComplete = useCallback(() => {
|
||||
setEmergeComplete(true);
|
||||
}, []);
|
||||
|
||||
const handleFallComplete = useCallback(() => {
|
||||
// After falling from modal, start emerging onto bar and advance step
|
||||
setTransition('emerging_bar');
|
||||
tourActions.next();
|
||||
}, [tourActions]);
|
||||
|
||||
const handleBarEmergeComplete = useCallback(() => {
|
||||
setTransition('none');
|
||||
setEmergeComplete(true);
|
||||
}, []);
|
||||
|
||||
// ─── Navigation ───────────────────────────────────────────────────────
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (!step) return;
|
||||
|
||||
if (step.modalPlacement === 'center') {
|
||||
// Welcome → first bar item: fall off modal
|
||||
setTransition('falling');
|
||||
setEmergeComplete(false);
|
||||
} else {
|
||||
// Bar item → next bar item: just advance, guide will walk to new target
|
||||
tourActions.next();
|
||||
}
|
||||
}, [step, tourActions]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
setTransition('none');
|
||||
tourActions.prev();
|
||||
}, [tourActions]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────
|
||||
|
||||
if (!tourState.isActive || !step) return null;
|
||||
|
||||
const isCentered = step.modalPlacement === 'center';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dark backdrop — welcome step only */}
|
||||
{isWelcome && transition === 'none' && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50',
|
||||
'transition-opacity duration-400',
|
||||
isVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlight pulse on active anchor */}
|
||||
{highlightRect && (
|
||||
<div
|
||||
className="fixed rounded-xl animate-tour-highlight-pulse pointer-events-none z-[51]"
|
||||
style={{
|
||||
left: highlightRect.left - 4,
|
||||
top: highlightRect.top - 4,
|
||||
width: highlightRect.width + 8,
|
||||
height: highlightRect.height + 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Guided modal */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-[52] flex justify-center px-4 pointer-events-none',
|
||||
isCentered ? 'inset-0 items-center' : 'bottom-28 left-0 right-0',
|
||||
)}
|
||||
>
|
||||
<div ref={modalRef} className="pointer-events-auto">
|
||||
<GuidedModal
|
||||
title={step.title}
|
||||
body={step.body}
|
||||
showPrev={!tourState.isFirstStep}
|
||||
showNext={true}
|
||||
nextLabel={tourState.isLastContentStep ? 'Got it' : 'Next'}
|
||||
onPrev={handlePrev}
|
||||
onNext={tourState.isLastContentStep ? onComplete : handleNext}
|
||||
onSkip={handleSkip}
|
||||
visible={isVisible && transition !== 'falling'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Blobbi guide */}
|
||||
<MiniBlobbiGuide
|
||||
companion={companion}
|
||||
intent={guideIntent}
|
||||
surfaceLeft={activeSurface.left}
|
||||
surfaceRight={activeSurface.right}
|
||||
surfaceY={activeSurface.y}
|
||||
targetX={barTargetX}
|
||||
onEmergeComplete={
|
||||
transition === 'emerging_bar' ? handleBarEmergeComplete : handleEmergeComplete
|
||||
}
|
||||
onFallComplete={handleFallComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* useFirstEggExperience - State machine for the first-time Blobbi experience.
|
||||
*
|
||||
* This hook orchestrates the full first-egg journey:
|
||||
* 1. Auto-create Blobbonaut profile (if missing)
|
||||
* 2. Auto-generate & publish the first egg (no confirmation, no cost)
|
||||
* 3. Immediately enter the hatch interaction flow
|
||||
* 4. Show a reveal overlay after hatching for naming
|
||||
*
|
||||
* The hook is designed to be consumed by a single orchestrating component.
|
||||
* It manages state transitions but does NOT render anything.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* This flow is ONLY for the user's first Blobbi.
|
||||
* Subsequent Blobbis use the standard adoption flow.
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
INITIAL_BLOBBONAUT_COINS,
|
||||
buildBlobbonautTags,
|
||||
type BlobbonautProfile,
|
||||
type BlobbiCompanion,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
generateEggPreview,
|
||||
previewToEventTags,
|
||||
} from '@/blobbi/onboarding/lib/blobbi-preview';
|
||||
|
||||
import { updateBlobbonautTags } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Steps of the first egg experience:
|
||||
*
|
||||
* - 'idle': Not yet active (preconditions not met or already completed)
|
||||
* - 'creating_profile': Auto-creating the Blobbonaut profile
|
||||
* - 'creating_egg': Auto-generating and publishing the first egg
|
||||
* - 'ready': Egg created, waiting for hatch tour to run (handed off to tour system)
|
||||
* - 'done': Experience is fully complete
|
||||
*/
|
||||
export type FirstEggStep =
|
||||
| 'idle'
|
||||
| 'creating_profile'
|
||||
| 'creating_egg'
|
||||
| 'ready'
|
||||
| 'done';
|
||||
|
||||
export interface FirstEggExperienceState {
|
||||
/** Current step */
|
||||
step: FirstEggStep;
|
||||
/** Whether auto-creation is in progress */
|
||||
isProcessing: boolean;
|
||||
/** Whether the experience is active (not idle or done) */
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface UseFirstEggExperienceOptions {
|
||||
/** Current profile (null if doesn't exist yet) */
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Whether the profile is still loading */
|
||||
profileLoading: boolean;
|
||||
/** All resolved companions */
|
||||
companions: BlobbiCompanion[];
|
||||
/** Whether companions are still loading */
|
||||
companionsLoading: boolean;
|
||||
/** Cache update callbacks */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
invalidateProfile: () => void;
|
||||
invalidateCompanion: () => void;
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
}
|
||||
|
||||
export interface UseFirstEggExperienceResult {
|
||||
state: FirstEggExperienceState;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFirstEggExperience({
|
||||
profile,
|
||||
profileLoading,
|
||||
companions,
|
||||
companionsLoading,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
}: UseFirstEggExperienceOptions): UseFirstEggExperienceResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { data: authorData } = useAuthor(user?.pubkey);
|
||||
|
||||
const [step, setStep] = useState<FirstEggStep>('idle');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Prevent double-execution with refs
|
||||
const profileCreationAttempted = useRef(false);
|
||||
const eggCreationAttempted = useRef(false);
|
||||
|
||||
// Suggested name from kind 0 metadata
|
||||
const suggestedName = useMemo(() => {
|
||||
if (!authorData?.metadata) return undefined;
|
||||
return authorData.metadata.display_name || authorData.metadata.name || undefined;
|
||||
}, [authorData?.metadata]);
|
||||
|
||||
// ─── Determine if the experience should be active ──────────────────────────
|
||||
|
||||
// Case A: No profile → need to create profile + egg
|
||||
// Case B: Profile exists, no companions loaded yet, but profile.has is empty → need to create egg
|
||||
// Case C: Profile + companions loaded, exactly 1 egg → hatch tour handles it, we're 'ready'
|
||||
// Otherwise: idle (user already has Blobbis, or has baby/adult)
|
||||
|
||||
useEffect(() => {
|
||||
// Still loading? Don't decide yet.
|
||||
if (profileLoading) return;
|
||||
|
||||
// If we're already processing, don't re-evaluate
|
||||
if (isProcessing) return;
|
||||
|
||||
// If already done or in an active step, don't re-evaluate
|
||||
if (step === 'done') return;
|
||||
if (step === 'creating_profile' || step === 'creating_egg') return;
|
||||
|
||||
// Case A: No profile at all
|
||||
if (!profile && !profileCreationAttempted.current) {
|
||||
setStep('creating_profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Profile exists, check companion state
|
||||
if (profile) {
|
||||
// If profile has no companions listed at all → create egg
|
||||
if (profile.has.length === 0 && !profile.currentCompanion && !eggCreationAttempted.current) {
|
||||
setStep('creating_egg');
|
||||
return;
|
||||
}
|
||||
|
||||
// If companions are still loading, wait
|
||||
if (companionsLoading) return;
|
||||
|
||||
// If companions loaded but none found and profile.has is empty → create egg
|
||||
if (companions.length === 0 && profile.has.length === 0 && !eggCreationAttempted.current) {
|
||||
setStep('creating_egg');
|
||||
return;
|
||||
}
|
||||
|
||||
// If exactly 1 companion that is an egg → flow is ready (tour takes over)
|
||||
if (companions.length === 1 && companions[0].stage === 'egg') {
|
||||
const noBabyOrAdult = !companions.some(c => c.stage === 'baby' || c.stage === 'adult');
|
||||
if (noBabyOrAdult && !profile.firstHatchTourDone) {
|
||||
setStep('ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise: user has existing Blobbis, not a first-egg scenario
|
||||
setStep('idle');
|
||||
}
|
||||
}, [profileLoading, profile, companionsLoading, companions, isProcessing, step]);
|
||||
|
||||
// ─── Auto Profile Creation ────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'creating_profile') {
|
||||
profileCreationAttempted.current = false;
|
||||
return;
|
||||
}
|
||||
if (profileCreationAttempted.current || !user?.pubkey || profile) return;
|
||||
if (isProcessing) return;
|
||||
|
||||
profileCreationAttempted.current = true;
|
||||
|
||||
const createProfile = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const name = suggestedName || 'Blobbonaut';
|
||||
const baseTags = buildBlobbonautTags(user.pubkey);
|
||||
const tagsWithName = [
|
||||
...baseTags,
|
||||
['name', name],
|
||||
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
|
||||
];
|
||||
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: tagsWithName,
|
||||
});
|
||||
|
||||
updateProfileEvent(event);
|
||||
invalidateProfile();
|
||||
|
||||
// Profile created → next step is egg creation
|
||||
setStep('creating_egg');
|
||||
} catch (error) {
|
||||
console.error('[FirstEggExperience] Failed to create profile:', error);
|
||||
toast({
|
||||
title: 'Failed to create profile',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
profileCreationAttempted.current = false;
|
||||
setStep('idle');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
createProfile();
|
||||
}, [step, user?.pubkey, profile, isProcessing, suggestedName, publishEvent, updateProfileEvent, invalidateProfile]);
|
||||
|
||||
// ─── Auto Egg Creation ────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'creating_egg') {
|
||||
eggCreationAttempted.current = false;
|
||||
return;
|
||||
}
|
||||
if (eggCreationAttempted.current || !user?.pubkey) return;
|
||||
if (isProcessing) return;
|
||||
|
||||
// We need a profile to exist before creating an egg.
|
||||
// It may not be in the cache yet if we just created it.
|
||||
// Wait for next render cycle when profile is available.
|
||||
// If profileLoading, also wait.
|
||||
if (profileLoading) return;
|
||||
|
||||
eggCreationAttempted.current = true;
|
||||
|
||||
const createFirstEgg = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Generate the egg
|
||||
const preview = generateEggPreview(user.pubkey, 'Egg');
|
||||
const eggTags = previewToEventTags(preview);
|
||||
|
||||
// Publish the egg event
|
||||
const eggEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: 'A new Blobbi egg!',
|
||||
tags: eggTags,
|
||||
created_at: preview.createdAt,
|
||||
});
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// Update profile: add to has[] + set current_companion, NO coin deduction for first egg
|
||||
// Use profile from latest state or build minimal tags
|
||||
const currentProfile = profile;
|
||||
if (currentProfile) {
|
||||
const newHas = [...currentProfile.has, preview.d];
|
||||
const updatedProfileTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
has: newHas,
|
||||
current_companion: preview.d,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedProfileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
} else {
|
||||
// Profile was just created but not yet in cache: build fresh tags
|
||||
const baseTags = buildBlobbonautTags(user.pubkey);
|
||||
const name = suggestedName || 'Blobbonaut';
|
||||
const freshTags = [
|
||||
...baseTags,
|
||||
['name', name],
|
||||
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
|
||||
['has', preview.d],
|
||||
['current_companion', preview.d],
|
||||
];
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: freshTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
// Set localStorage selection
|
||||
setStoredSelectedD(preview.d);
|
||||
|
||||
// Invalidate queries
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
|
||||
// Egg created, flow is ready for hatch tour
|
||||
setStep('ready');
|
||||
} catch (error) {
|
||||
console.error('[FirstEggExperience] Failed to create first egg:', error);
|
||||
toast({
|
||||
title: 'Failed to create egg',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
eggCreationAttempted.current = false;
|
||||
setStep('idle');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
createFirstEgg();
|
||||
}, [step, user?.pubkey, profile, profileLoading, isProcessing, suggestedName, publishEvent, updateProfileEvent, updateCompanionEvent, invalidateProfile, invalidateCompanion, setStoredSelectedD]);
|
||||
|
||||
// ─── Derived State ──────────────────────────────────────────────────────────
|
||||
|
||||
const isActive = step !== 'idle' && step !== 'done';
|
||||
|
||||
const state: FirstEggExperienceState = useMemo(() => ({
|
||||
step,
|
||||
isProcessing,
|
||||
isActive,
|
||||
}), [step, isProcessing, isActive]);
|
||||
|
||||
return { state };
|
||||
}
|
||||
@@ -4,51 +4,32 @@
|
||||
* Orchestration only -- no rendering, no animations.
|
||||
* The hook manages:
|
||||
* - Ordered step progression
|
||||
* - Persisted state via localStorage (survives refresh / close)
|
||||
* - In-memory session state (React useState)
|
||||
* - Derived booleans for UI consumption
|
||||
* - Safe advance / goTo / complete / reset actions
|
||||
*
|
||||
* Persistence strategy:
|
||||
* - The tour does NOT persist to localStorage.
|
||||
* - The Kind 11125 profile tag `blobbi_first_hatch_tour_done` is the
|
||||
* sole authoritative persisted signal.
|
||||
* - If the user refreshes mid-tour, the tour re-enters from the
|
||||
* beginning when activation conditions are still met.
|
||||
*
|
||||
* 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 { useState, useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
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<FirstHatchTourStepId, number>(
|
||||
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
|
||||
@@ -57,6 +38,20 @@ const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
|
||||
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
|
||||
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
|
||||
|
||||
// ─── In-Memory State Shape ────────────────────────────────────────────────────
|
||||
|
||||
interface TourSessionState {
|
||||
/** Current step id, or null when not started */
|
||||
currentStepId: FirstHatchTourStepId | null;
|
||||
/** Whether the tour was completed this session */
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_SESSION_STATE: TourSessionState = {
|
||||
currentStepId: null,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
// ─── Result Type ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseFirstHatchTourResult {
|
||||
@@ -83,112 +78,91 @@ export interface UseFirstHatchTourResult {
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFirstHatchTour(): UseFirstHatchTourResult {
|
||||
// ── Persisted state ──
|
||||
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
|
||||
STORAGE_KEY,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
);
|
||||
// ── In-memory session state ──
|
||||
const [session, setSession] = useState<TourSessionState>(INITIAL_SESSION_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<FirstHatchTourPersistedState>) => {
|
||||
setPersisted((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
},
|
||||
[setPersisted],
|
||||
);
|
||||
// Stable ref so callbacks never go stale.
|
||||
const sessionRef = useRef(session);
|
||||
sessionRef.current = session;
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const start = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
// No-op if already active or completed
|
||||
if (p.completed || p.currentStepId !== null) return;
|
||||
const s = sessionRef.current;
|
||||
// No-op if already active or completed this session
|
||||
if (s.completed || s.currentStepId !== null) return;
|
||||
|
||||
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
|
||||
if (!firstStep) return;
|
||||
|
||||
updatePersisted({ currentStepId: firstStep.id });
|
||||
}, [updatePersisted]);
|
||||
setSession({ currentStepId: firstStep.id, completed: false });
|
||||
}, []);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
if (p.completed || p.currentStepId === null) return;
|
||||
const s = sessionRef.current;
|
||||
if (s.completed || s.currentStepId === null) return;
|
||||
|
||||
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
|
||||
const currentIndex = STEP_INDEX_MAP.get(s.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 });
|
||||
setSession({ 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 });
|
||||
setSession({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: nextStep.id });
|
||||
setSession((prev) => ({ ...prev, currentStepId: nextStep.id }));
|
||||
}
|
||||
}, [updatePersisted]);
|
||||
}, []);
|
||||
|
||||
const goTo = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => {
|
||||
if (!STEP_INDEX_MAP.has(stepId)) {
|
||||
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
|
||||
}
|
||||
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],
|
||||
);
|
||||
if (stepId === 'complete') {
|
||||
setSession({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
setSession({ currentStepId: stepId, completed: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
}, [updatePersisted]);
|
||||
setSession({ currentStepId: null, completed: true });
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
|
||||
}, [setPersisted]);
|
||||
setSession(INITIAL_SESSION_STATE);
|
||||
}, []);
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
const currentStepIndex = persisted.currentStepId !== null
|
||||
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
|
||||
const currentStepIndex = session.currentStepId !== null
|
||||
? (STEP_INDEX_MAP.get(session.currentStepId) ?? -1)
|
||||
: -1;
|
||||
|
||||
const state = useMemo((): TourState<FirstHatchTourStepId> => {
|
||||
const isActive = persisted.currentStepId !== null && !persisted.completed;
|
||||
const isActive = session.currentStepId !== null && !session.completed;
|
||||
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
|
||||
|
||||
return {
|
||||
isActive,
|
||||
currentStepId: persisted.currentStepId,
|
||||
currentStepId: session.currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
|
||||
isCompleted: persisted.completed,
|
||||
progress: persisted.completed
|
||||
isCompleted: session.completed,
|
||||
progress: session.completed
|
||||
? 1
|
||||
: currentStepIndex >= 0
|
||||
? currentStepIndex / LAST_REAL_STEP_INDEX
|
||||
: 0,
|
||||
};
|
||||
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
|
||||
}, [session.currentStepId, session.completed, currentStepIndex]);
|
||||
|
||||
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
|
||||
start,
|
||||
@@ -201,15 +175,15 @@ export function useFirstHatchTour(): UseFirstHatchTourResult {
|
||||
// ── Convenience helpers ──
|
||||
|
||||
const isStep = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
|
||||
[persisted.currentStepId],
|
||||
(stepId: FirstHatchTourStepId) => session.currentStepId === stepId,
|
||||
[session.currentStepId],
|
||||
);
|
||||
|
||||
const isAnyStep = useCallback(
|
||||
(...stepIds: FirstHatchTourStepId[]) => {
|
||||
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
|
||||
return session.currentStepId !== null && stepIds.includes(session.currentStepId);
|
||||
},
|
||||
[persisted.currentStepId],
|
||||
[session.currentStepId],
|
||||
);
|
||||
|
||||
const currentStepDef = currentStepIndex >= 0
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
* 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 (checked via profile tag
|
||||
* AND localStorage fallback).
|
||||
* 5. The `blobbi_first_hatch_tour_done` profile tag is NOT true.
|
||||
* 6. The tour is not already active or completed this session.
|
||||
*
|
||||
* Completion is authoritative from the Blobbonaut profile event
|
||||
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
|
||||
* is a secondary signal for in-progress UI state and as a fallback
|
||||
* when the profile hasn't been updated yet.
|
||||
* Persistence: The Kind 11125 profile tag `blobbi_first_hatch_tour_done`
|
||||
* is the sole authoritative persisted signal. No localStorage is used.
|
||||
*
|
||||
* MIGRATION NOTE: `blobbi_onboarding_done` is intentionally ignored
|
||||
* when the user is in the single-egg state. This ensures old accounts
|
||||
* that were migrated before the hatch tour existed can still experience
|
||||
* it. The `blobbi_first_hatch_tour_done` tag is the dedicated signal.
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
@@ -36,16 +39,21 @@ import type { UseFirstHatchTourResult } from './useFirstHatchTour';
|
||||
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 (localStorage-based state machine) */
|
||||
/**
|
||||
* Whether the companion data has been resolved at least once and is
|
||||
* ready for activation evaluation. When false, the hook defers all
|
||||
* evaluation. This should reflect initial data readiness, not
|
||||
* background refetch activity.
|
||||
*/
|
||||
companionsReady: boolean;
|
||||
/** The tour hook result (in-memory state machine) */
|
||||
tour: UseFirstHatchTourResult;
|
||||
/**
|
||||
* Whether onboarding is already marked complete in the Blobbonaut profile
|
||||
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
|
||||
* When true, the tour will not activate regardless of localStorage state.
|
||||
* Whether the first hatch tour is already marked complete in the
|
||||
* Blobbonaut profile event (`blobbi_first_hatch_tour_done` tag).
|
||||
* This is the sole authoritative persisted signal.
|
||||
*/
|
||||
profileOnboardingDone?: boolean;
|
||||
profileFirstHatchTourDone?: boolean;
|
||||
}
|
||||
|
||||
export interface FirstHatchTourActivationResult {
|
||||
@@ -53,7 +61,7 @@ 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.
|
||||
* from a previous render.
|
||||
*/
|
||||
shouldActivate: boolean;
|
||||
/**
|
||||
@@ -73,28 +81,33 @@ export interface FirstHatchTourActivationResult {
|
||||
* const tour = useFirstHatchTour();
|
||||
* const activation = useFirstHatchTourActivation({
|
||||
* companions,
|
||||
* isLoading: companionsLoading,
|
||||
* companionsReady: !companionsLoading,
|
||||
* tour,
|
||||
* profileOnboardingDone: profile?.onboardingDone,
|
||||
* profileFirstHatchTourDone: profile?.firstHatchTourDone,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useFirstHatchTourActivation({
|
||||
companions,
|
||||
isLoading,
|
||||
companionsReady,
|
||||
tour,
|
||||
profileOnboardingDone: _profileOnboardingDone = false,
|
||||
profileFirstHatchTourDone = false,
|
||||
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
|
||||
// ── Precondition evaluation ──
|
||||
|
||||
const { shouldActivate, isEligible } = useMemo(() => {
|
||||
// Can't evaluate until data is loaded
|
||||
if (isLoading) {
|
||||
// Defer until companion data has been resolved at least once
|
||||
if (!companionsReady) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// localStorage tour already completed — this is always authoritative
|
||||
if (tour.state.isCompleted) {
|
||||
// Profile tag is the sole persisted completion signal
|
||||
if (profileFirstHatchTourDone) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// Tour already completed or active this session — don't re-activate
|
||||
if (tour.state.isCompleted || tour.state.isActive) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
@@ -111,7 +124,7 @@ export function useFirstHatchTourActivation({
|
||||
}
|
||||
|
||||
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
|
||||
// but kept explicit for clarity and future-proofing if rules change)
|
||||
// but kept explicit for clarity and future-proofing)
|
||||
const hasBabyOrAdult = companions.some(
|
||||
(c) => c.stage === 'baby' || c.stage === 'adult',
|
||||
);
|
||||
@@ -119,41 +132,11 @@ export function useFirstHatchTourActivation({
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
|
||||
// Some older accounts had `onboarding_done` migrated to
|
||||
// `blobbi_onboarding_done=true` before the first-hatch tour
|
||||
// existed, so they never experienced it. When the user is in the
|
||||
// exact single-egg/no-evolved-companions state (all checks above
|
||||
// passed), we intentionally ignore `profileOnboardingDone` so
|
||||
// those accounts can still enter the tour.
|
||||
//
|
||||
// This is safe because:
|
||||
// - The localStorage `tour.state.isCompleted` check above
|
||||
// already prevents re-triggering for users who HAVE finished
|
||||
// the tour.
|
||||
// - The egg-stage + single-companion guard means this only
|
||||
// fires for users who genuinely haven't hatched yet.
|
||||
//
|
||||
// TODO: Replace `blobbi_onboarding_done` with a dedicated
|
||||
// `blobbi_first_hatch_tour_done` tag so onboarding completion
|
||||
// and tour completion are tracked independently. Once that tag
|
||||
// is in place, remove this safeguard and gate activation on the
|
||||
// new tag instead.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// (profileOnboardingDone is intentionally NOT checked here)
|
||||
|
||||
// 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]);
|
||||
// All preconditions met — activate
|
||||
return { shouldActivate: true, isEligible: true };
|
||||
}, [companionsReady, companions, tour.state.isCompleted, tour.state.isActive, profileFirstHatchTourDone]);
|
||||
|
||||
// ── 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();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* useUITour - State machine for the Blobbi UI walkthrough tour.
|
||||
*
|
||||
* In-memory only. The Kind 11125 profile tag `blobbi_ui_tour_done`
|
||||
* is the sole persisted completion signal (set externally by the caller
|
||||
* when the tour completes). This hook never writes to localStorage or
|
||||
* Nostr — it only manages the step progression.
|
||||
*
|
||||
* Supports forward and backward navigation so the user can revisit
|
||||
* previous steps.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
import type { UITourStepDef } from '../lib/ui-tour-types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface UITourSessionState {
|
||||
currentStepIndex: number | null;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: UITourSessionState = {
|
||||
currentStepIndex: null,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
export interface UITourState {
|
||||
isActive: boolean;
|
||||
currentStep: UITourStepDef | null;
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
isFirstStep: boolean;
|
||||
isLastContentStep: boolean;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
export interface UITourActions {
|
||||
/** Start the tour from the first step */
|
||||
start: () => void;
|
||||
/** Advance to the next step. Completes if on the last content step. */
|
||||
next: () => void;
|
||||
/** Go back to the previous step. No-op if on the first step. */
|
||||
prev: () => void;
|
||||
/** Mark the tour as completed */
|
||||
complete: () => void;
|
||||
/** Reset the tour (dev only) */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export interface UseUITourResult {
|
||||
state: UITourState;
|
||||
actions: UITourActions;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useUITour(steps: UITourStepDef[]): UseUITourResult {
|
||||
const [session, setSession] = useState<UITourSessionState>(INITIAL_STATE);
|
||||
const sessionRef = useRef(session);
|
||||
sessionRef.current = session;
|
||||
|
||||
// Last content step is the one before 'complete'
|
||||
const lastContentIndex = useMemo(() => {
|
||||
const completeIdx = steps.findIndex(s => s.id === 'complete');
|
||||
return completeIdx > 0 ? completeIdx - 1 : steps.length - 1;
|
||||
}, [steps]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
const s = sessionRef.current;
|
||||
if (s.completed || s.currentStepIndex !== null) return;
|
||||
if (steps.length === 0) return;
|
||||
setSession({ currentStepIndex: 0, completed: false });
|
||||
}, [steps.length]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
const s = sessionRef.current;
|
||||
if (s.completed || s.currentStepIndex === null) return;
|
||||
|
||||
const nextIdx = s.currentStepIndex + 1;
|
||||
// If next step is 'complete' or past the end, mark completed
|
||||
if (nextIdx >= steps.length || steps[nextIdx].id === 'complete') {
|
||||
setSession({ currentStepIndex: null, completed: true });
|
||||
} else {
|
||||
setSession(prev => ({ ...prev, currentStepIndex: nextIdx }));
|
||||
}
|
||||
}, [steps]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
const s = sessionRef.current;
|
||||
if (s.completed || s.currentStepIndex === null || s.currentStepIndex === 0) return;
|
||||
setSession(prev => ({ ...prev, currentStepIndex: prev.currentStepIndex! - 1 }));
|
||||
}, []);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
setSession({ currentStepIndex: null, completed: true });
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSession(INITIAL_STATE);
|
||||
}, []);
|
||||
|
||||
// Derived state
|
||||
const currentStep = session.currentStepIndex !== null
|
||||
? (steps[session.currentStepIndex] ?? null)
|
||||
: null;
|
||||
|
||||
const state = useMemo((): UITourState => ({
|
||||
isActive: session.currentStepIndex !== null && !session.completed,
|
||||
currentStep,
|
||||
currentStepIndex: session.currentStepIndex ?? -1,
|
||||
totalSteps: steps.length,
|
||||
isFirstStep: session.currentStepIndex === 0,
|
||||
isLastContentStep: session.currentStepIndex === lastContentIndex,
|
||||
isCompleted: session.completed,
|
||||
}), [session.currentStepIndex, session.completed, currentStep, steps.length, lastContentIndex]);
|
||||
|
||||
const actions = useMemo((): UITourActions => ({
|
||||
start, next, prev, complete, reset,
|
||||
}), [start, next, prev, complete, reset]);
|
||||
|
||||
return { state, actions };
|
||||
}
|
||||
+47
-13
@@ -2,22 +2,21 @@
|
||||
* 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)
|
||||
* Currently implements:
|
||||
* - First-egg hatch tour (tap crack animation + reveal)
|
||||
* - First-egg experience (auto profile + egg creation)
|
||||
* - UI walkthrough tour (bottom bar, guide actor)
|
||||
*
|
||||
* 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.
|
||||
* Persistence:
|
||||
* - `blobbi_first_hatch_tour_done` — sole signal for hatch tour
|
||||
* - `blobbi_ui_tour_done` — sole signal for UI tour (not persisted yet)
|
||||
* - No localStorage is used for any tour.
|
||||
*/
|
||||
|
||||
// ── Types (generic tour infrastructure) ──
|
||||
// ── Types (hatch tour infrastructure) ──
|
||||
export type {
|
||||
TourStepDef,
|
||||
TourPersistedState,
|
||||
TourState,
|
||||
TourActions,
|
||||
} from './lib/tour-types';
|
||||
@@ -25,11 +24,9 @@ export type {
|
||||
// ── 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 ──
|
||||
@@ -42,5 +39,42 @@ export type {
|
||||
FirstHatchTourActivationResult,
|
||||
} from './hooks/useFirstHatchTourActivation';
|
||||
|
||||
// ── First Egg Experience - Hook ──
|
||||
export { useFirstEggExperience } from './hooks/useFirstEggExperience';
|
||||
export type {
|
||||
FirstEggStep,
|
||||
FirstEggExperienceState,
|
||||
UseFirstEggExperienceOptions,
|
||||
UseFirstEggExperienceResult,
|
||||
} from './hooks/useFirstEggExperience';
|
||||
|
||||
// ── First Hatch Tour - Components ──
|
||||
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
|
||||
export { BlobbiRevealOverlay } from './components/BlobbiRevealOverlay';
|
||||
|
||||
// ── UI Tour - Types & Constants ──
|
||||
export type {
|
||||
GuideIntent,
|
||||
GuideAnchorTarget,
|
||||
UITourStepDef,
|
||||
UITourStepId,
|
||||
} from './lib/ui-tour-types';
|
||||
export {
|
||||
buildUITourSteps,
|
||||
BAR_ITEM_TOUR_DESCRIPTIONS,
|
||||
} from './lib/ui-tour-types';
|
||||
|
||||
// ── UI Tour - Hooks ──
|
||||
export { useUITour } from './hooks/useUITour';
|
||||
export type {
|
||||
UITourState,
|
||||
UITourActions,
|
||||
UseUITourResult,
|
||||
} from './hooks/useUITour';
|
||||
|
||||
// ── UI Tour - Components ──
|
||||
export { UITourOverlay } from './components/UITourOverlay';
|
||||
export { GuidedModal } from './components/GuidedModal';
|
||||
export { MiniBlobbiGuide, GUIDE_SIZE } from './components/MiniBlobbiGuide';
|
||||
|
||||
// ── Tour Anchor System ──
|
||||
export { TourAnchorProvider, useTourAnchors } from './lib/TourAnchorContext';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* TourAnchorContext - Registry for UI anchor elements.
|
||||
*
|
||||
* UI components register their DOM elements by anchor ID. The tour
|
||||
* overlay reads these to position the guide, highlights, and modals
|
||||
* relative to real page elements.
|
||||
*
|
||||
* Usage in an anchor component:
|
||||
* ```tsx
|
||||
* const { registerAnchor } = useTourAnchors();
|
||||
* <div ref={(el) => registerAnchor('bar-item-0', el)}>...</div>
|
||||
* ```
|
||||
*
|
||||
* Usage in the tour overlay:
|
||||
* ```tsx
|
||||
* const { getAnchorRect } = useTourAnchors();
|
||||
* const rect = getAnchorRect('bar-item-0'); // DOMRect | null
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TourAnchorContextValue {
|
||||
/** Register or update an anchor element. Pass null to unregister. */
|
||||
registerAnchor: (id: string, element: HTMLElement | null) => void;
|
||||
/** Get the current bounding rect for an anchor. Returns null if not registered. */
|
||||
getAnchorRect: (id: string) => DOMRect | null;
|
||||
/** Get the raw element for an anchor. Returns null if not registered. */
|
||||
getAnchorElement: (id: string) => HTMLElement | null;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TourAnchorCtx = createContext<TourAnchorContextValue | null>(null);
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TourAnchorProvider({ children }: { children: ReactNode }) {
|
||||
const anchorsRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
const registerAnchor = useCallback((id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
anchorsRef.current.set(id, element);
|
||||
} else {
|
||||
anchorsRef.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getAnchorRect = useCallback((id: string): DOMRect | null => {
|
||||
const el = anchorsRef.current.get(id);
|
||||
return el ? el.getBoundingClientRect() : null;
|
||||
}, []);
|
||||
|
||||
const getAnchorElement = useCallback((id: string): HTMLElement | null => {
|
||||
return anchorsRef.current.get(id) ?? null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TourAnchorCtx.Provider value={{ registerAnchor, getAnchorRect, getAnchorElement }}>
|
||||
{children}
|
||||
</TourAnchorCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useTourAnchors(): TourAnchorContextValue {
|
||||
const ctx = useContext(TourAnchorCtx);
|
||||
if (!ctx) {
|
||||
throw new Error('useTourAnchors must be used within a TourAnchorProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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)
|
||||
* - In-memory session state (profile tags are the persisted truth)
|
||||
* - Decoupled from rendering (UI reads state, doesn't own it)
|
||||
*/
|
||||
|
||||
@@ -28,19 +28,6 @@ export interface TourStepDef<StepId extends string = string> {
|
||||
autoAdvance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted state for a tour.
|
||||
* Stored in localStorage so tours survive refresh / close / return.
|
||||
*/
|
||||
export interface TourPersistedState<StepId extends string = string> {
|
||||
/** 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.
|
||||
*/
|
||||
@@ -55,7 +42,7 @@ export interface TourState<StepId extends string = string> {
|
||||
totalSteps: number;
|
||||
/** Whether the current step is the last one before completion */
|
||||
isLastStep: boolean;
|
||||
/** Whether the tour has been completed (persisted) */
|
||||
/** Whether the tour has been completed (this session) */
|
||||
isCompleted: boolean;
|
||||
/** Progress as a fraction 0..1 */
|
||||
progress: number;
|
||||
@@ -73,7 +60,7 @@ export interface TourActions<StepId extends string = string> {
|
||||
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 the tour entirely (clears in-memory state). For dev/testing. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -82,15 +69,15 @@ export interface TourActions<StepId extends string = string> {
|
||||
/**
|
||||
* Step ids for the first-egg hatch tour.
|
||||
*
|
||||
* Flow:
|
||||
* Simplified flow (no post mission required):
|
||||
* 1. idle — initial state (auto-advances immediately)
|
||||
* 2. show_hatch_card — egg with initial crack + wiggle + inline card
|
||||
* 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click
|
||||
* 4. egg_crack_stage_1 — click 1: crack expands
|
||||
* 5. egg_crack_stage_2 — click 2: crack expands further
|
||||
* 6. egg_crack_stage_3 — click 3: crack reaches edges
|
||||
* 7. egg_opening — shell opens (auto-advance after animation)
|
||||
* 8. egg_hatching — bright light + baby reveal (auto-advance)
|
||||
* 2. egg_glowing_waiting_click — egg glows, waiting for user click
|
||||
* 3. egg_crack_stage_1 — click 1: crack appears
|
||||
* 4. egg_crack_stage_2 — click 2: crack expands
|
||||
* 5. egg_crack_stage_3 — click 3: crack reaches edges
|
||||
* 6. egg_opening — shell opens (auto-advance after animation)
|
||||
* 7. egg_hatching — bright light + baby emerges (auto-advance)
|
||||
* 8. reveal — show reveal overlay with naming
|
||||
* 9. complete — terminal, marks tour done
|
||||
*
|
||||
* The order here matches the intended flow. To reorder steps,
|
||||
@@ -98,13 +85,13 @@ export interface TourActions<StepId extends string = string> {
|
||||
*/
|
||||
export type FirstHatchTourStepId =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'egg_glowing_waiting_click'
|
||||
| 'egg_crack_stage_1'
|
||||
| 'egg_crack_stage_2'
|
||||
| 'egg_crack_stage_3'
|
||||
| 'egg_opening'
|
||||
| 'egg_hatching'
|
||||
| 'reveal'
|
||||
| 'complete';
|
||||
|
||||
/**
|
||||
@@ -115,26 +102,12 @@ export type FirstHatchTourStepId =
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
|
||||
{ id: 'idle' },
|
||||
{ id: 'show_hatch_card' },
|
||||
{ id: 'egg_glowing_waiting_click' },
|
||||
{ id: 'egg_crack_stage_1' },
|
||||
{ id: 'egg_crack_stage_2' },
|
||||
{ id: 'egg_crack_stage_3' },
|
||||
{ id: 'egg_opening', autoAdvance: true },
|
||||
{ id: 'egg_hatching', autoAdvance: true },
|
||||
{ id: 'reveal' },
|
||||
{ id: 'complete' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Persisted state shape for the first hatch tour.
|
||||
*/
|
||||
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
|
||||
|
||||
/**
|
||||
* Default persisted state for a brand-new first hatch tour.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
|
||||
currentStepId: null,
|
||||
completed: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* UI Tour - Types and Step Definitions
|
||||
*
|
||||
* The UI tour teaches new users the Blobbi interface after their first
|
||||
* hatch. It is separate from the first-hatch tour and uses its own
|
||||
* completion tag: `blobbi_ui_tour_done`.
|
||||
*
|
||||
* Architecture:
|
||||
* - Steps are identified by string IDs and ordered in an array.
|
||||
* - Each step declares what UI anchor it targets, what modal to show,
|
||||
* and where the mini Blobbi guide should be.
|
||||
* - The orchestrator reads the current step and positions everything.
|
||||
* - Future phases add new steps to the array without changing the
|
||||
* state machine or rendering infrastructure.
|
||||
*/
|
||||
|
||||
// ─── Guide Choreography ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* High-level choreography intents for the MiniBlobbiGuide.
|
||||
*
|
||||
* These are NOT low-level animation states. They describe the guide's
|
||||
* role in the current moment of the walkthrough. The guide component
|
||||
* internally sequences sub-phases (peek, climb, walk, edge-look, etc.)
|
||||
* based on the active intent.
|
||||
*/
|
||||
export type GuideIntent =
|
||||
| 'hidden' // Not rendered
|
||||
| 'emerge_onto_modal' // Rise from behind modal top edge → peek → climb up
|
||||
| 'pace_on_modal' // Walk back and forth on modal top with edge-look behavior
|
||||
| 'fall_from_surface' // Fall off current surface downward
|
||||
| 'emerge_onto_bar' // Rise from behind bar top edge → peek → climb up
|
||||
| 'walk_to_target' // Walk along bar to a specific targetX, then stop
|
||||
| 'inspect_target'; // Stopped centered above target, looking down
|
||||
|
||||
/**
|
||||
* Where the guide is positioned relative to.
|
||||
*/
|
||||
export type GuideAnchorTarget =
|
||||
| { type: 'modal' }
|
||||
| { type: 'element'; anchorId: string }
|
||||
| { type: 'offscreen' };
|
||||
|
||||
// ─── Step Definitions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single UI tour step definition.
|
||||
*/
|
||||
export interface UITourStepDef {
|
||||
/** Unique step identifier */
|
||||
id: UITourStepId;
|
||||
/** Where the mini Blobbi guide should be during this step */
|
||||
guideTarget: GuideAnchorTarget;
|
||||
/**
|
||||
* Modal placement:
|
||||
* - 'center': centered overlay modal (e.g. welcome screen)
|
||||
* - 'bottom': anchored near the bottom bar area
|
||||
*/
|
||||
modalPlacement: 'center' | 'bottom';
|
||||
/** If set, this anchor element receives a highlight glow */
|
||||
highlightAnchor?: string;
|
||||
/** Title text for the guided modal */
|
||||
title: string;
|
||||
/** Body text for the guided modal */
|
||||
body: string;
|
||||
}
|
||||
|
||||
// ─── Step IDs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type UITourStepId =
|
||||
| 'welcome'
|
||||
| 'bar_item_0'
|
||||
| 'bar_item_1'
|
||||
| 'bar_item_2'
|
||||
| 'bar_center'
|
||||
| 'bar_more'
|
||||
| 'complete';
|
||||
|
||||
/**
|
||||
* Build the step definitions for the UI tour.
|
||||
*
|
||||
* Dynamic because bar item descriptions depend on which items are
|
||||
* visible in the current bar preferences.
|
||||
*/
|
||||
export function buildUITourSteps(
|
||||
barItemLabels: string[],
|
||||
barItemDescriptions: string[],
|
||||
): UITourStepDef[] {
|
||||
const steps: UITourStepDef[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
guideTarget: { type: 'modal' },
|
||||
modalPlacement: 'center',
|
||||
title: 'Welcome to the world of Blobbi!',
|
||||
body: 'Congratulations on hatching your first Blobbi! These little creatures are here to keep you company while you explore this social world. Let me show you around.',
|
||||
},
|
||||
];
|
||||
|
||||
for (let i = 0; i < Math.min(barItemLabels.length, 3); i++) {
|
||||
const stepId = `bar_item_${i}` as UITourStepId;
|
||||
steps.push({
|
||||
id: stepId,
|
||||
guideTarget: { type: 'element', anchorId: `bar-item-${i}` },
|
||||
modalPlacement: 'bottom',
|
||||
highlightAnchor: `bar-item-${i}`,
|
||||
title: barItemLabels[i],
|
||||
body: barItemDescriptions[i],
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: 'complete',
|
||||
guideTarget: { type: 'offscreen' },
|
||||
modalPlacement: 'center',
|
||||
title: '',
|
||||
body: '',
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Descriptions for each BarItemId used in the tour steps */
|
||||
export const BAR_ITEM_TOUR_DESCRIPTIONS: Record<string, string> = {
|
||||
blobbies: 'Check on all your Blobbies here. You can see their health, switch between them, or adopt new ones later.',
|
||||
missions: 'Complete missions to help your Blobbi grow. Missions refresh regularly and keep things interesting.',
|
||||
items: 'Browse the shop for food, toys, and other goodies. Use items to keep your Blobbi happy and healthy.',
|
||||
take_photo: 'Snap a photo of your Blobbi to share with friends. Capture their best moments!',
|
||||
set_companion: 'Set this Blobbi as your floating companion. They\'ll follow you around while you browse.',
|
||||
};
|
||||
+277
-125
@@ -36,6 +36,7 @@ import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
generateBlobbiContent,
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
@@ -94,6 +95,7 @@ import { ActionBarEditor } from '@/blobbi/ui/components/ActionBarEditor';
|
||||
import {
|
||||
type ActionBarPreferences,
|
||||
type BarItemId,
|
||||
BAR_ITEM_LABELS,
|
||||
getVisibleSlots,
|
||||
getVisibleBarIds,
|
||||
loadPreferences,
|
||||
@@ -101,10 +103,11 @@ import {
|
||||
loadMissionCardVisible,
|
||||
saveMissionCardVisible,
|
||||
} from '@/blobbi/ui/lib/action-bar-preferences';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourCard } from '@/blobbi/tour';
|
||||
import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions';
|
||||
import {
|
||||
useFirstHatchTour, useFirstHatchTourActivation, useFirstEggExperience, BlobbiRevealOverlay,
|
||||
useUITour, UITourOverlay, TourAnchorProvider, useTourAnchors,
|
||||
buildUITourSteps, BAR_ITEM_TOUR_DESCRIPTIONS,
|
||||
} from '@/blobbi/tour';
|
||||
import type { EggTourVisualState } from '@/blobbi/egg';
|
||||
|
||||
/**
|
||||
@@ -250,18 +253,30 @@ function BlobbiContent() {
|
||||
// State for showing the adoption flow (for "Adopt another Blobbi")
|
||||
const [showAdoptionFlow, setShowAdoptionFlow] = useState(false);
|
||||
|
||||
// Combine loading/fetching states
|
||||
const companionLoading = collectionLoading;
|
||||
const companionFetching = collectionFetching;
|
||||
const invalidateCompanion = invalidateCollection;
|
||||
|
||||
// ─── First Egg Experience ────────────────────────────────────────────────────
|
||||
// Handles auto profile + egg creation for brand-new users.
|
||||
// This replaces the old BlobbiOnboardingFlow for first-time users.
|
||||
const firstEggExperience = useFirstEggExperience({
|
||||
profile,
|
||||
profileLoading,
|
||||
companions,
|
||||
companionsLoading: companionLoading || (companionFetching && companions.length === 0),
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion: invalidateCollection,
|
||||
setStoredSelectedD,
|
||||
});
|
||||
|
||||
// STEP 6: Selection Priority
|
||||
// 1) localStorage selection (if valid and exists in collection) - USER SELECTION ALWAYS WINS
|
||||
// 2) first item from profile.has that exists in companionsByD - DEFAULT ONLY, never persisted
|
||||
// 3) undefined (show selector)
|
||||
//
|
||||
// CRITICAL: Default selection must NEVER overwrite localStorage.
|
||||
// User selection persists only via handleSelectBlobbi, not via this computed value.
|
||||
const selectedD = useMemo(() => {
|
||||
if (!profile) return undefined;
|
||||
|
||||
// Priority 1: localStorage selection (if it exists in loaded collection)
|
||||
// USER SELECTION ALWAYS WINS - this is the authoritative source
|
||||
if (storedSelectedD && companionsByD[storedSelectedD]) {
|
||||
if (DEBUG_BLOBBI) {
|
||||
console.log('[BlobbiPage] selectedD: using localStorage selection:', storedSelectedD);
|
||||
@@ -269,34 +284,21 @@ function BlobbiContent() {
|
||||
return storedSelectedD;
|
||||
}
|
||||
|
||||
// Priority 2: First item from profile.has that exists in companionsByD
|
||||
// This is a DEFAULT - it should NOT be persisted to localStorage
|
||||
for (const d of profile.has) {
|
||||
if (companionsByD[d]) {
|
||||
if (DEBUG_BLOBBI) {
|
||||
console.log('[BlobbiPage] selectedD: using default from profile.has:', d,
|
||||
'(storedSelectedD was:', storedSelectedD,
|
||||
storedSelectedD ? (companionsByD[storedSelectedD] ? 'exists' : 'NOT in companionsByD') : 'null', ')');
|
||||
console.log('[BlobbiPage] selectedD: using default from profile.has:', d);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: No valid selection
|
||||
if (DEBUG_BLOBBI) {
|
||||
console.log('[BlobbiPage] selectedD: no valid selection available');
|
||||
}
|
||||
return undefined;
|
||||
}, [profile, storedSelectedD, companionsByD]);
|
||||
|
||||
// NOTE: We intentionally do NOT auto-save the computed selectedD to localStorage.
|
||||
// This prevents the default selection from overwriting user selections during:
|
||||
// - WebSocket updates
|
||||
// - Query refetches
|
||||
// - Race conditions where storedSelectedD is not yet in companionsByD
|
||||
//
|
||||
// User selections are only persisted via handleSelectBlobbi (line ~232).
|
||||
|
||||
// Get the selected companion from the collection
|
||||
const companion = selectedD ? companionsByD[selectedD] ?? null : null;
|
||||
|
||||
@@ -313,11 +315,6 @@ function BlobbiContent() {
|
||||
}
|
||||
}, [selectedD, companion]);
|
||||
|
||||
// Combine loading/fetching states
|
||||
const companionLoading = collectionLoading;
|
||||
const companionFetching = collectionFetching;
|
||||
const invalidateCompanion = invalidateCollection;
|
||||
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
// Handler for selecting a Blobbi from the selector
|
||||
@@ -564,10 +561,37 @@ function BlobbiContent() {
|
||||
return <DashboardLoadingState />;
|
||||
}
|
||||
|
||||
// ─── CASE B: No profile exists ───
|
||||
// Show profile creation onboarding
|
||||
// ─── CASE B & C: First egg experience is active (creating profile / egg) ───
|
||||
// The useFirstEggExperience hook handles auto-creating profile + first egg.
|
||||
// While it's working, show a loading state.
|
||||
if (firstEggExperience.state.isActive && firstEggExperience.state.step !== 'ready') {
|
||||
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: first egg experience -', firstEggExperience.state.step);
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-6 p-8">
|
||||
<div className="relative">
|
||||
<div className="size-28 rounded-3xl bg-gradient-to-br from-amber-500/20 via-orange-500/10 to-yellow-500/5 flex items-center justify-center shadow-lg">
|
||||
<Egg className="size-14 text-amber-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-2 max-w-sm">
|
||||
<h2 className="text-xl font-bold">
|
||||
{firstEggExperience.state.step === 'creating_profile'
|
||||
? 'Setting up your profile...'
|
||||
: 'Preparing your first egg...'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will only take a moment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CASE B2: No profile and first egg experience is idle (error case) ───
|
||||
if (!profile) {
|
||||
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: profile creation onboarding');
|
||||
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: profile creation onboarding (fallback)');
|
||||
return (
|
||||
<DashboardShell>
|
||||
<BlobbiOnboardingFlow
|
||||
@@ -582,10 +606,10 @@ function BlobbiContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CASE C: Profile exists but has no pets (empty has[] and no current_companion) ───
|
||||
// Show adoption onboarding
|
||||
// ─── CASE C2: Profile exists but has no pets and experience is idle ───
|
||||
// This only happens when the auto-create fails or for subsequent adopts
|
||||
if (!dList || dList.length === 0) {
|
||||
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: adoption onboarding (profile exists, no pets)');
|
||||
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: adoption onboarding (fallback)');
|
||||
return (
|
||||
<DashboardShell>
|
||||
<BlobbiOnboardingFlow
|
||||
@@ -728,6 +752,7 @@ function BlobbiContent() {
|
||||
// Note: Item use registration is handled by useBlobbiActionsRegistration hook above
|
||||
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: dashboard');
|
||||
return (
|
||||
<TourAnchorProvider>
|
||||
<BlobbiDashboard
|
||||
companion={companion}
|
||||
companions={companions}
|
||||
@@ -754,12 +779,14 @@ function BlobbiContent() {
|
||||
invalidateCompanion={invalidateCompanion}
|
||||
setStoredSelectedD={setStoredSelectedD}
|
||||
ensureCanonicalBeforeAction={ensureCanonicalBeforeAction}
|
||||
companionsReady={!companionLoading}
|
||||
// DEV ONLY: State editor props
|
||||
showDevEditor={showDevEditor}
|
||||
setShowDevEditor={setShowDevEditor}
|
||||
onDevEditorApply={handleDevEditorApply}
|
||||
isDevUpdating={isDevUpdating}
|
||||
/>
|
||||
</TourAnchorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -818,6 +845,8 @@ interface BlobbiDashboardProps {
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Whether the initial companion data load has completed (not background refetches) */
|
||||
companionsReady: boolean;
|
||||
// DEV ONLY: State editor props
|
||||
showDevEditor: boolean;
|
||||
setShowDevEditor: (show: boolean) => void;
|
||||
@@ -851,6 +880,7 @@ function BlobbiDashboard({
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
ensureCanonicalBeforeAction,
|
||||
companionsReady,
|
||||
// DEV ONLY
|
||||
showDevEditor,
|
||||
setShowDevEditor,
|
||||
@@ -974,62 +1004,20 @@ function BlobbiDashboard({
|
||||
const firstHatchTour = useFirstHatchTour();
|
||||
useFirstHatchTourActivation({
|
||||
companions,
|
||||
isLoading: false, // companions are already loaded at this point
|
||||
companionsReady,
|
||||
tour: firstHatchTour,
|
||||
profileOnboardingDone: profile?.onboardingDone,
|
||||
profileFirstHatchTourDone: profile?.firstHatchTourDone,
|
||||
});
|
||||
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 -> show_hatch_card (immediately)
|
||||
// Auto-advance from idle -> egg_glowing_waiting_click (immediately)
|
||||
useEffect(() => {
|
||||
if (!isFirstHatchTourActive) return;
|
||||
if (firstHatchTour.isStep('idle')) {
|
||||
firstHatchTour.actions.advance(); // -> show_hatch_card
|
||||
firstHatchTour.actions.advance(); // -> egg_glowing_waiting_click
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
// Show the inline first-hatch card for all pre-hatch steps
|
||||
const showFirstHatchCard = isFirstHatchTourActive && firstHatchTour.isAnyStep(
|
||||
'show_hatch_card', 'egg_glowing_waiting_click',
|
||||
'egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3',
|
||||
);
|
||||
|
||||
// Detect hatch post completion for the first-hatch tour
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const tourAwaitingPost = isFirstHatchTourActive && firstHatchTour.isStep('show_hatch_card');
|
||||
|
||||
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 during show_hatch_card, show the completed state
|
||||
// for 2 seconds so the user sees the checkmark, then auto-advance to glowing.
|
||||
useEffect(() => {
|
||||
if (!tourPostFound || !isFirstHatchTourActive) return;
|
||||
if (firstHatchTour.isStep('show_hatch_card')) {
|
||||
const timer = setTimeout(() => {
|
||||
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [tourPostFound, isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
// Fake pointer hint: after 10s on glowing_waiting_click, show hint; repeat every 5s
|
||||
const [showClickHint, setShowClickHint] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -1057,7 +1045,7 @@ function BlobbiDashboard({
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
// Auto-advance for opening -> hatching -> complete (with hatch mutation)
|
||||
// Auto-advance for opening -> hatching (with hatch mutation + reveal)
|
||||
useEffect(() => {
|
||||
if (!isFirstHatchTourActive) return;
|
||||
if (firstHatchTour.isStep('egg_opening')) {
|
||||
@@ -1068,46 +1056,102 @@ function BlobbiDashboard({
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
// When we reach egg_hatching, execute the actual hatch mutation then
|
||||
// advance to the reveal step (NOT complete — reveal overlay handles completion)
|
||||
useEffect(() => {
|
||||
if (!isFirstHatchTourActive) return;
|
||||
if (firstHatchTour.isStep('egg_hatching')) {
|
||||
// Execute the actual hatch mutation, mark onboarding complete on the
|
||||
// profile event, then complete the tour's local state.
|
||||
const doHatch = async () => {
|
||||
try {
|
||||
await onHatch();
|
||||
|
||||
// Persist blobbi_onboarding_done to the Blobbonaut profile (authoritative)
|
||||
if (profile) {
|
||||
try {
|
||||
const updatedTags = updateBlobbonautTags(profile.allTags, {
|
||||
blobbi_onboarding_done: 'true',
|
||||
});
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(event);
|
||||
} catch (e) {
|
||||
console.error('[FirstHatchTour] Failed to persist onboarding completion to profile:', e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
firstHatchTour.actions.complete();
|
||||
} catch (e) {
|
||||
console.error('[FirstHatchTour] Hatch mutation failed:', e);
|
||||
}
|
||||
// Advance to reveal step regardless of success/failure
|
||||
// (the user should still see the naming overlay)
|
||||
firstHatchTour.actions.advance(); // -> reveal
|
||||
};
|
||||
const timer = setTimeout(doHatch, 1200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour, onHatch, profile, publishEvent, updateProfileEvent]);
|
||||
}, [isFirstHatchTourActive, firstHatchTour, onHatch]);
|
||||
|
||||
// Show reveal overlay when tour reaches the 'reveal' step AND the companion
|
||||
// data has been updated to reflect the hatched baby. This avoids rendering
|
||||
// the overlay with stale egg data while the cache is still updating.
|
||||
const showRevealOverlay = isFirstHatchTourActive
|
||||
&& firstHatchTour.isStep('reveal')
|
||||
&& companion.stage !== 'egg';
|
||||
|
||||
// State for reveal naming
|
||||
const [isNamingBlobbi, setIsNamingBlobbi] = useState(false);
|
||||
|
||||
// Complete the tour: persist to profile + complete in-memory session state
|
||||
const completeFirstHatchTour = useCallback(async () => {
|
||||
// Persist both blobbi_onboarding_done and blobbi_first_hatch_tour_done
|
||||
if (profile) {
|
||||
try {
|
||||
const updatedTags = updateBlobbonautTags(profile.allTags, {
|
||||
blobbi_onboarding_done: 'true',
|
||||
blobbi_first_hatch_tour_done: 'true',
|
||||
});
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(event);
|
||||
} catch (e) {
|
||||
console.error('[FirstHatchTour] Failed to persist completion to profile:', e);
|
||||
}
|
||||
}
|
||||
|
||||
firstHatchTour.actions.complete();
|
||||
}, [profile, publishEvent, updateProfileEvent, firstHatchTour]);
|
||||
|
||||
// Handle naming from the reveal overlay
|
||||
const handleRevealNameConfirm = useCallback(async (newName: string) => {
|
||||
setIsNamingBlobbi(true);
|
||||
try {
|
||||
// Update the Blobbi event with the new name and correct content
|
||||
if (companion) {
|
||||
const updatedTags = updateBlobbiTags(companion.allTags, {
|
||||
name: newName,
|
||||
});
|
||||
const content = generateBlobbiContent(newName, companion.stage);
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
}
|
||||
|
||||
// Complete the tour and mark on profile
|
||||
await completeFirstHatchTour();
|
||||
} catch (e) {
|
||||
console.error('[FirstHatchTour] Failed to rename:', e);
|
||||
toast({
|
||||
title: 'Failed to save name',
|
||||
description: e instanceof Error ? e.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsNamingBlobbi(false);
|
||||
}
|
||||
}, [companion, publishEvent, updateCompanionEvent, invalidateCompanion, completeFirstHatchTour]);
|
||||
|
||||
// Handle dismiss (skip naming) from reveal overlay
|
||||
const handleRevealDismiss = useCallback(async () => {
|
||||
await completeFirstHatchTour();
|
||||
}, [completeFirstHatchTour]);
|
||||
|
||||
// Derive tourVisualState for the egg visual
|
||||
const tourVisualState = useMemo((): EggTourVisualState => {
|
||||
if (!isFirstHatchTourActive) return 'idle';
|
||||
const step = firstHatchTour.state.currentStepId;
|
||||
switch (step) {
|
||||
case 'show_hatch_card': return 'show_hatch_card';
|
||||
case 'egg_glowing_waiting_click': return 'glowing_waiting_click';
|
||||
case 'egg_crack_stage_1': return 'crack_stage_1';
|
||||
case 'egg_crack_stage_2': return 'crack_stage_2';
|
||||
@@ -1119,18 +1163,88 @@ function BlobbiDashboard({
|
||||
}, [isFirstHatchTourActive, firstHatchTour.state.currentStepId]);
|
||||
|
||||
// DEV ONLY: Build tour dev actions for the state editor
|
||||
// (uiTour is declared below but referenced here — the callback only runs on click,
|
||||
// by which time the ref is populated. We use a ref to avoid a circular dependency.)
|
||||
const uiTourResetRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const resetTourWithProfile = useCallback(async () => {
|
||||
// Reset in-memory state for both tours
|
||||
firstHatchTour.actions.reset();
|
||||
uiTourResetRef.current?.();
|
||||
|
||||
// Reset all tour-related profile tags
|
||||
if (profile) {
|
||||
try {
|
||||
const updatedTags = updateBlobbonautTags(profile.allTags, {
|
||||
blobbi_first_hatch_tour_done: 'false',
|
||||
blobbi_ui_tour_done: 'false',
|
||||
blobbi_onboarding_done: 'false',
|
||||
});
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(event);
|
||||
invalidateProfile();
|
||||
} catch (e) {
|
||||
console.error('[DevReset] Failed to reset tour profile tags:', e);
|
||||
}
|
||||
}
|
||||
}, [firstHatchTour, profile, publishEvent, updateProfileEvent, invalidateProfile]);
|
||||
|
||||
const tourDevActions = useMemo(() => ({
|
||||
skipPostRequirement: () => {
|
||||
if (firstHatchTour.isStep('show_hatch_card')) {
|
||||
skipToEggGlow: () => {
|
||||
if (firstHatchTour.isStep('idle')) {
|
||||
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
|
||||
}
|
||||
},
|
||||
resetTour: () => {
|
||||
firstHatchTour.actions.reset();
|
||||
},
|
||||
resetTour: resetTourWithProfile,
|
||||
currentStepId: firstHatchTour.state.currentStepId,
|
||||
isCompleted: firstHatchTour.state.isCompleted,
|
||||
}), [firstHatchTour]);
|
||||
}), [firstHatchTour, resetTourWithProfile]);
|
||||
|
||||
// ─── UI Walkthrough Tour ───
|
||||
const { registerAnchor } = useTourAnchors();
|
||||
|
||||
// Build step definitions based on the current visible bar items
|
||||
const uiTourSteps = useMemo(() => {
|
||||
const slots = getVisibleSlots(barPrefs);
|
||||
const labels = slots.map(s => BAR_ITEM_LABELS[s.id]);
|
||||
const descriptions = slots.map(s =>
|
||||
BAR_ITEM_TOUR_DESCRIPTIONS[s.id] ?? `Tap here to access ${BAR_ITEM_LABELS[s.id]}.`,
|
||||
);
|
||||
return buildUITourSteps(labels, descriptions);
|
||||
}, [barPrefs]);
|
||||
|
||||
const uiTour = useUITour(uiTourSteps);
|
||||
|
||||
// Wire the ref so dev reset can reach the UI tour
|
||||
uiTourResetRef.current = uiTour.actions.reset;
|
||||
|
||||
// Auto-start the UI tour when:
|
||||
// - First hatch tour just completed this session (isCompleted && not active)
|
||||
// - UI tour hasn't been done yet (profile tag)
|
||||
// - UI tour is not yet active or completed this session
|
||||
useEffect(() => {
|
||||
if (
|
||||
firstHatchTour.state.isCompleted
|
||||
&& !isFirstHatchTourActive
|
||||
&& !profile?.uiTourDone
|
||||
&& !uiTour.state.isActive
|
||||
&& !uiTour.state.isCompleted
|
||||
) {
|
||||
// Small delay so the reveal overlay fully closes first
|
||||
const timer = setTimeout(() => uiTour.actions.start(), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [firstHatchTour.state.isCompleted, isFirstHatchTourActive, profile?.uiTourDone, uiTour]);
|
||||
|
||||
const handleUITourComplete = useCallback(() => {
|
||||
uiTour.actions.complete();
|
||||
// Note: blobbi_ui_tour_done is NOT persisted yet.
|
||||
// When ready, add profile tag publish here.
|
||||
}, [uiTour]);
|
||||
|
||||
// State detection for tasks
|
||||
// Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state
|
||||
@@ -1590,19 +1704,34 @@ function BlobbiDashboard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* First Hatch Tour: inline card directly below egg (above stats) */}
|
||||
{showFirstHatchCard && (
|
||||
{/* First Hatch Tour: tap hint below egg during click steps */}
|
||||
{isFirstHatchTourActive && firstHatchTour.isAnyStep(
|
||||
'egg_glowing_waiting_click',
|
||||
'egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3',
|
||||
) && (
|
||||
<div className="px-4 sm:px-6 mt-2">
|
||||
<FirstHatchTourCard
|
||||
blobbiName={companion.name}
|
||||
requiredPhrase={firstHatchPhrase}
|
||||
postCompleted={!!tourPostFound || !firstHatchTour.isStep('show_hatch_card')}
|
||||
onCreatePost={() => setShowPostModal(true)}
|
||||
currentStep={firstHatchTour.state.currentStepId}
|
||||
/>
|
||||
<div className="w-full max-w-sm mx-auto text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Tap {companion.name} to hatch!
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tap the egg to help {companion.name} break free.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reveal Overlay: shown after hatching for naming */}
|
||||
{showRevealOverlay && (
|
||||
<BlobbiRevealOverlay
|
||||
companion={companion}
|
||||
open={showRevealOverlay}
|
||||
onDismiss={handleRevealDismiss}
|
||||
onNameConfirm={handleRevealNameConfirm}
|
||||
isNaming={isNamingBlobbi}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats Section - hidden during first-hatch tour */}
|
||||
{!isFirstHatchTourActive && (
|
||||
<div className="px-4 sm:px-6">
|
||||
@@ -1712,6 +1841,17 @@ function BlobbiDashboard({
|
||||
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
|
||||
barPreferences={barPrefs}
|
||||
onEditBar={() => setShowBarEditor(true)}
|
||||
registerTourAnchor={registerAnchor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* UI Tour Overlay */}
|
||||
{uiTour.state.isActive && (
|
||||
<UITourOverlay
|
||||
tourState={uiTour.state}
|
||||
tourActions={uiTour.actions}
|
||||
companion={companion}
|
||||
onComplete={handleUITourComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2265,6 +2405,8 @@ interface BlobbiBottomBarProps {
|
||||
// ── Action bar preferences ──
|
||||
barPreferences: ActionBarPreferences;
|
||||
onEditBar: () => void;
|
||||
/** Optional: register anchor elements for the UI tour */
|
||||
registerTourAnchor?: (id: string, el: HTMLElement | null) => void;
|
||||
// ── Dev-only actions ──
|
||||
onDevInstantTransition?: () => void;
|
||||
onDevOpenEditor?: () => void;
|
||||
@@ -2298,6 +2440,7 @@ function BlobbiBottomBar({
|
||||
// Bar preferences
|
||||
barPreferences,
|
||||
onEditBar,
|
||||
registerTourAnchor,
|
||||
// Dev-only props
|
||||
onDevInstantTransition,
|
||||
onDevOpenEditor,
|
||||
@@ -2370,7 +2513,7 @@ function BlobbiBottomBar({
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-0.5 sm:gap-2">
|
||||
{/* Left Group - aligned to end (closer to center) */}
|
||||
<div className="flex items-center justify-end gap-0 sm:gap-1 overflow-hidden">
|
||||
{leftSlots.map((slot) => (
|
||||
{leftSlots.map((slot, idx) => (
|
||||
<BottomBarButton
|
||||
key={slot.id}
|
||||
onClick={handlers[slot.id]}
|
||||
@@ -2379,6 +2522,9 @@ function BlobbiBottomBar({
|
||||
badge={badgeMap[slot.id].badge}
|
||||
badgeVariant={badgeMap[slot.id].variant}
|
||||
highlighted={slot.highlighted}
|
||||
anchorRef={registerTourAnchor
|
||||
? (el) => registerTourAnchor(`bar-item-${idx}`, el)
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -2393,7 +2539,7 @@ function BlobbiBottomBar({
|
||||
|
||||
{/* Right Group - aligned to start (closer to center) */}
|
||||
<div className="flex items-center justify-start gap-0 sm:gap-1 overflow-hidden">
|
||||
{rightSlots.map((slot) => (
|
||||
{rightSlots.map((slot, idx) => (
|
||||
<BottomBarButton
|
||||
key={slot.id}
|
||||
onClick={handlers[slot.id]}
|
||||
@@ -2402,6 +2548,9 @@ function BlobbiBottomBar({
|
||||
badge={badgeMap[slot.id].badge}
|
||||
badgeVariant={badgeMap[slot.id].variant}
|
||||
highlighted={slot.highlighted}
|
||||
anchorRef={registerTourAnchor
|
||||
? (el) => registerTourAnchor(`bar-item-${halfIdx + idx}`, el)
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2509,9 +2658,11 @@ interface BottomBarButtonProps {
|
||||
badgeVariant?: 'default' | 'warning' | 'success';
|
||||
/** Show subtle highlight ring around this button */
|
||||
highlighted?: boolean;
|
||||
/** Optional ref callback for tour anchor registration */
|
||||
anchorRef?: (el: HTMLButtonElement | null) => void;
|
||||
}
|
||||
|
||||
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default', highlighted }: BottomBarButtonProps) {
|
||||
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default', highlighted, anchorRef }: BottomBarButtonProps) {
|
||||
// Determine if badge should show
|
||||
const showBadge = badge !== undefined && (typeof badge === 'string' || badge > 0);
|
||||
|
||||
@@ -2524,6 +2675,7 @@ function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default'
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={anchorRef}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]",
|
||||
|
||||
+43
-1
@@ -117,6 +117,40 @@ export default {
|
||||
'collapsible-up': {
|
||||
from: { height: 'var(--radix-collapsible-content-height)' },
|
||||
to: { height: '0' }
|
||||
},
|
||||
'spin-slow': {
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(360deg)' }
|
||||
},
|
||||
'float-particle': {
|
||||
'0%, 100%': { transform: 'translateY(0) scale(1)', opacity: '0.6' },
|
||||
'50%': { transform: 'translateY(-20px) scale(1.2)', opacity: '1' }
|
||||
},
|
||||
'bounce-gentle': {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-4px)' }
|
||||
},
|
||||
'walk-bob': {
|
||||
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
|
||||
'25%': { transform: 'translateY(-3px) rotate(-2deg)' },
|
||||
'75%': { transform: 'translateY(-3px) rotate(2deg)' }
|
||||
},
|
||||
'look-down': {
|
||||
'0%': { transform: 'translateY(0) rotate(0deg)' },
|
||||
'100%': { transform: 'translateY(2px) rotate(8deg)' }
|
||||
},
|
||||
'guide-fall': {
|
||||
'0%': { transform: 'translateY(0)', opacity: '1' },
|
||||
'100%': { transform: 'translateY(120vh)', opacity: '0' }
|
||||
},
|
||||
'guide-rise': {
|
||||
'0%': { transform: 'translateY(120vh)', opacity: '0' },
|
||||
'40%': { opacity: '1' },
|
||||
'100%': { transform: 'translateY(0)' }
|
||||
},
|
||||
'tour-highlight-pulse': {
|
||||
'0%, 100%': { boxShadow: '0 0 0 0 rgba(251,191,36,0.4)' },
|
||||
'50%': { boxShadow: '0 0 0 6px rgba(251,191,36,0.15)' }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
@@ -126,7 +160,15 @@ export default {
|
||||
'badge-spotlight': 'badge-spotlight 8s linear infinite',
|
||||
'highlight-fade': 'highlight-fade 1.5s ease-out forwards',
|
||||
'collapsible-down': 'collapsible-down 0.2s ease-out',
|
||||
'collapsible-up': 'collapsible-up 0.2s ease-out'
|
||||
'collapsible-up': 'collapsible-up 0.2s ease-out',
|
||||
'spin-slow': 'spin-slow 20s linear infinite',
|
||||
'float-particle': 'float-particle 3s ease-in-out infinite',
|
||||
'bounce-gentle': 'bounce-gentle 2s ease-in-out infinite',
|
||||
'walk-bob': 'walk-bob 0.4s ease-in-out infinite',
|
||||
'look-down': 'look-down 0.4s ease-out forwards',
|
||||
'guide-fall': 'guide-fall 0.6s ease-in forwards',
|
||||
'guide-rise': 'guide-rise 0.7s ease-out forwards',
|
||||
'tour-highlight-pulse': 'tour-highlight-pulse 2s ease-in-out infinite'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user