Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b11baad2a | |||
| 6ecd3303db | |||
| b02111d1dd | |||
| a2ab53a72c | |||
| 0d262587f0 | |||
| c810670df5 | |||
| 6a43933525 | |||
| 6d27f76310 | |||
| 191085bd7f | |||
| 770d6206d2 | |||
| fc1ee21b4c | |||
| 904b159630 | |||
| d8454394a1 | |||
| 0a72771c24 | |||
| 283feb04e9 | |||
| 3312e02afe | |||
| 7fd3e8d5d6 | |||
| c290368c53 | |||
| 756f42d5e6 | |||
| 67f6c7a4f3 | |||
| 75bb585b69 | |||
| d062ec7b12 | |||
| 949f9fe256 | |||
| 76d15010e1 | |||
| 6ac5b6a767 | |||
| a44e5fb90a | |||
| 6652b6cfb3 | |||
| 36e7cb675e | |||
| 287f70070d | |||
| 50a394c5d1 | |||
| 479e4d87b7 | |||
| 0cbd518096 | |||
| d8e2cf842b | |||
| e3f27cefdf | |||
| d84a575d3a | |||
| 494a630879 | |||
| 06593048e1 | |||
| 4f82b6d3ca | |||
| 74c0f2c39d | |||
| f5606dfb50 |
@@ -145,15 +145,6 @@ export function useBlobbiUseInventoryItem({
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -407,23 +398,25 @@ export function useBlobbiUseInventoryItem({
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
|
||||
// instead of profile.storage/profile.allTags to avoid restoring
|
||||
// stale/legacy values after migration
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
// Only decrement storage if the item actually exists in inventory.
|
||||
// Items are free to use regardless of inventory state.
|
||||
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
|
||||
if (hasItemInStorage) {
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
|
||||
@@ -55,9 +55,6 @@ export {
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
buildHatchPhrase,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useMemo, memo, type RefObject } from 'react';
|
||||
|
||||
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
@@ -248,7 +249,15 @@ export function BlobbiCompanionVisual({
|
||||
)}
|
||||
style={{ transformOrigin: 'center bottom' }}
|
||||
>
|
||||
{(companion.stage === 'baby' || companion.stage === 'adult') && (
|
||||
{companion.stage === 'egg' ? (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<BlobbiStageVisual
|
||||
companion={companion as any}
|
||||
size="sm"
|
||||
animated={false}
|
||||
className="size-full"
|
||||
/>
|
||||
) : (
|
||||
<MemoizedBlobbiVisual
|
||||
stage={companion.stage}
|
||||
blobbi={blobbi}
|
||||
|
||||
@@ -233,17 +233,18 @@ export function updateDragPosition(motion: CompanionMotion, position: Position):
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging - let gravity take over.
|
||||
* End dragging - hold position where dropped.
|
||||
*/
|
||||
export function endDrag(motion: CompanionMotion, groundY: number): CompanionMotion {
|
||||
return {
|
||||
...motion,
|
||||
isDragging: false,
|
||||
// If already at or below ground, snap to ground
|
||||
isGrounded: motion.position.y >= groundY,
|
||||
// Always treat as grounded so companion holds position where dropped
|
||||
isGrounded: true,
|
||||
position: {
|
||||
...motion.position,
|
||||
y: motion.position.y >= groundY ? groundY : motion.position.y,
|
||||
// Clamp to ground if below it
|
||||
y: Math.min(motion.position.y, groundY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
// Track if first entry has completed (for position initialization)
|
||||
const [hasEnteredOnce, setHasEnteredOnce] = useState(false);
|
||||
|
||||
// Track viewport size
|
||||
// Track viewport size — listen to both window resize and visualViewport
|
||||
// (mobile browsers fire visualViewport resize when URL bar shows/hides)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setViewport({
|
||||
@@ -114,7 +115,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate bounds and positions
|
||||
|
||||
@@ -80,9 +80,6 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
|
||||
|
||||
if (!blobbi) return null;
|
||||
|
||||
// Only baby and adult can be companions
|
||||
if (blobbi.stage === 'egg') return null;
|
||||
|
||||
// Use projected stats if available, otherwise fall back to base stats
|
||||
const stats = projectedState?.stats ?? blobbi.stats;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
@@ -27,18 +27,6 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
|
||||
|
||||
// ─── 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;
|
||||
/** Current tour step id, or null if not active */
|
||||
currentStepId: string | null;
|
||||
/** Whether the tour has been completed */
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
interface BlobbiDevEditorProps {
|
||||
/** Whether the editor modal is open */
|
||||
isOpen: boolean;
|
||||
@@ -50,8 +38,6 @@ interface BlobbiDevEditorProps {
|
||||
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
|
||||
/** Whether an update is in progress */
|
||||
isUpdating?: boolean;
|
||||
/** Optional: first-hatch tour dev actions (only passed when tour system is available) */
|
||||
tourDevActions?: FirstHatchTourDevActions;
|
||||
}
|
||||
|
||||
/** Updates that can be applied to a Blobbi */
|
||||
@@ -184,7 +170,6 @@ export function BlobbiDevEditor({
|
||||
companion,
|
||||
onApply,
|
||||
isUpdating = false,
|
||||
tourDevActions,
|
||||
}: BlobbiDevEditorProps) {
|
||||
// ─── Local State ───
|
||||
// Initialize from companion values
|
||||
@@ -545,79 +530,7 @@ export function BlobbiDevEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── First-Hatch Tour Controls ─── */}
|
||||
{tourDevActions && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">First-Hatch Tour</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{tourDevActions.isCompleted
|
||||
? 'Completed'
|
||||
: tourDevActions.currentStepId
|
||||
? tourDevActions.currentStepId
|
||||
: 'Not started'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Test the first-hatch tour flow without needing to create a real post.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* A. Skip Post Requirement */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.skipPostRequirement();
|
||||
}}
|
||||
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
|
||||
className="gap-2 text-xs"
|
||||
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
Skip Post
|
||||
</Button>
|
||||
|
||||
{/* B. Restart First-Hatch Tour */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
className="gap-2 text-xs"
|
||||
title="Reset the entire first-hatch tour state so it can be tested again"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Restart Tour
|
||||
</Button>
|
||||
|
||||
{/* C. Reset Blobbi to Egg */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStage('egg');
|
||||
setState('active');
|
||||
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"
|
||||
>
|
||||
<Egg className="size-3.5" />
|
||||
Reset to Egg + Tour
|
||||
</Button>
|
||||
</div>
|
||||
{companion.stage !== 'egg' && stage === 'egg' && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -438,3 +438,354 @@
|
||||
filter: grayscale(1) contrast(1.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Onboarding Hatching Ceremony Animations
|
||||
========================================== */
|
||||
|
||||
/* Soft breathing pulse for the egg before interaction */
|
||||
@keyframes egg-onboard-breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
filter: brightness(1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.015);
|
||||
filter: brightness(1.03) drop-shadow(0 0 30px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-breathe {
|
||||
animation: egg-onboard-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Screen-filling radial glow that expands from center on hatch */
|
||||
@keyframes onboard-glow-expand {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-expand {
|
||||
animation: onboard-glow-expand 1.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle lingering glow fade after hatch - holds then fades */
|
||||
@keyframes onboard-glow-linger {
|
||||
0% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-linger {
|
||||
animation: onboard-glow-linger 7s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Sentimental text fade in - very slow, dreamlike */
|
||||
@keyframes onboard-text-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-text-reveal {
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Delayed text reveal for secondary text */
|
||||
.animate-onboard-text-reveal-delay {
|
||||
opacity: 0;
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.6s forwards;
|
||||
}
|
||||
|
||||
/* Soft fade out for transition between phases */
|
||||
@keyframes onboard-soft-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-out {
|
||||
animation: onboard-soft-fade-out 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Soft fade in */
|
||||
@keyframes onboard-soft-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-in {
|
||||
animation: onboard-soft-fade-in 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Floating particles that drift upward from the egg */
|
||||
@keyframes onboard-particle-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.5);
|
||||
}
|
||||
20% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-120px) scale(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle twinkle - stays in place, pulses brightness */
|
||||
@keyframes onboard-sparkle-twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
85% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle drift - gentle floating motion */
|
||||
@keyframes onboard-sparkle-drift {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.3);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-8px) scale(1);
|
||||
}
|
||||
80% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-25px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px) scale(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Egg entrance - subtle float up from darkness */
|
||||
@keyframes egg-onboard-entrance {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-entrance {
|
||||
animation: egg-onboard-entrance 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Egg shake intensifying - for crack stages */
|
||||
@keyframes egg-onboard-shake-light {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
25% { transform: translateX(-3px) rotate(-2deg); }
|
||||
75% { transform: translateX(3px) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-medium {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
20% { transform: translateX(-5px) rotate(-3deg); }
|
||||
40% { transform: translateX(4px) rotate(2deg); }
|
||||
60% { transform: translateX(-4px) rotate(-2deg); }
|
||||
80% { transform: translateX(5px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-heavy {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
10% { transform: translateX(-6px) rotate(-4deg); }
|
||||
20% { transform: translateX(5px) rotate(3deg); }
|
||||
30% { transform: translateX(-7px) rotate(-3deg); }
|
||||
40% { transform: translateX(6px) rotate(4deg); }
|
||||
50% { transform: translateX(-5px) rotate(-2deg); }
|
||||
60% { transform: translateX(7px) rotate(3deg); }
|
||||
70% { transform: translateX(-6px) rotate(-4deg); }
|
||||
80% { transform: translateX(5px) rotate(2deg); }
|
||||
90% { transform: translateX(-4px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-light {
|
||||
animation: egg-onboard-shake-light 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-medium {
|
||||
animation: egg-onboard-shake-medium 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-heavy {
|
||||
animation: egg-onboard-shake-heavy 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* Final burst - egg explodes into light */
|
||||
@keyframes egg-onboard-burst {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.8;
|
||||
filter: brightness(2.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0;
|
||||
filter: brightness(4) blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-burst {
|
||||
animation: egg-onboard-burst 1.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Screen flash on hatch */
|
||||
@keyframes onboard-screen-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-screen-flash {
|
||||
animation: onboard-screen-flash 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle continue prompt pulse */
|
||||
@keyframes onboard-continue-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-continue-pulse {
|
||||
animation: onboard-continue-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Slow rotating golden incandescence behind hatched blobbi */
|
||||
@keyframes onboard-golden-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(90deg) scale(1.06);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(180deg) scale(1);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(270deg) scale(1.06);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-rotate {
|
||||
animation: onboard-golden-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Golden glow fade-in */
|
||||
@keyframes onboard-golden-fadein {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: onboard-golden-fadein 2.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion overrides for onboarding */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-egg-onboard-breathe,
|
||||
.animate-onboard-glow-expand,
|
||||
.animate-onboard-glow-linger,
|
||||
.animate-onboard-text-reveal,
|
||||
.animate-onboard-text-reveal-delay,
|
||||
.animate-onboard-soft-fade-out,
|
||||
.animate-onboard-soft-fade-in,
|
||||
.animate-egg-onboard-entrance,
|
||||
.animate-egg-onboard-shake-light,
|
||||
.animate-egg-onboard-shake-medium,
|
||||
.animate-egg-onboard-shake-heavy,
|
||||
.animate-egg-onboard-burst,
|
||||
.animate-onboard-screen-flash,
|
||||
.animate-onboard-continue-pulse,
|
||||
.animate-onboard-golden-rotate,
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
/**
|
||||
* BlobbiHatchingCeremony - Immersive hatching experience for every new egg
|
||||
*
|
||||
* Flow:
|
||||
* 1. Dark screen, egg silently created in background
|
||||
* 2. Huge breathing egg appears. No text. No UI.
|
||||
* 3. Click egg 4 times through crack stages with intensifying shakes
|
||||
* 4. Final click -> egg bursts into light, actual hatch mutation fires
|
||||
* 5. Flash clears -> hatched baby blobbi revealed center screen with glow/sparkles
|
||||
* 6. Typewriter dialog appears below blobbi (click to complete line / advance)
|
||||
* 7. Naming prompt, then ceremony complete
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } 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 { cn } from '@/lib/utils';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
INITIAL_BLOBBONAUT_COINS,
|
||||
STAT_MAX,
|
||||
buildBlobbonautTags,
|
||||
updateBlobbonautTags,
|
||||
updateBlobbiTags,
|
||||
type BlobbonautProfile,
|
||||
type BlobbiCompanion,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
generateEggPreview,
|
||||
previewToEventTags,
|
||||
previewToBlobbiCompanion,
|
||||
type BlobbiEggPreview,
|
||||
} from '../lib/blobbi-preview';
|
||||
|
||||
// ─── Dialog Lines ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BIRTH_DIALOG: string[] = [
|
||||
'Something stirs...',
|
||||
'A tiny life has chosen you. It knows only warmth, and your presence.',
|
||||
];
|
||||
|
||||
const NAMING_DIALOG = 'Every life deserves a name.\nWhat will you call this one?';
|
||||
|
||||
// ─── Phase Machine ────────────────────────────────────────────────────────────
|
||||
|
||||
type CeremonyPhase =
|
||||
| 'loading'
|
||||
| 'egg'
|
||||
| 'crack_1'
|
||||
| 'crack_2'
|
||||
| 'crack_3'
|
||||
| 'hatching' // egg burst + hatch mutation
|
||||
| 'reveal' // flash clearing, baby blobbi fading in with glow
|
||||
| 'dialog' // typewriter dialog lines
|
||||
| 'naming'
|
||||
| 'complete';
|
||||
|
||||
// ─── Typewriter Hook ──────────────────────────────────────────────────────────
|
||||
|
||||
function useTypewriter(fullText: string, active: boolean, speed = 35) {
|
||||
const [displayed, setDisplayed] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const indexRef = useRef(0);
|
||||
|
||||
// Reset when text changes
|
||||
useEffect(() => {
|
||||
setDisplayed('');
|
||||
setDone(false);
|
||||
indexRef.current = 0;
|
||||
}, [fullText]);
|
||||
|
||||
// Run typewriter
|
||||
useEffect(() => {
|
||||
if (!active || done) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
indexRef.current++;
|
||||
const next = fullText.slice(0, indexRef.current);
|
||||
setDisplayed(next);
|
||||
if (indexRef.current >= fullText.length) {
|
||||
setDone(true);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
}
|
||||
}, speed);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [active, done, fullText, speed]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
setDisplayed(fullText);
|
||||
setDone(true);
|
||||
}, [fullText]);
|
||||
|
||||
return { displayed, done, complete };
|
||||
}
|
||||
|
||||
// Module-level guard: prevents duplicate egg creation if the component remounts
|
||||
// (e.g. React strict mode, parent re-render causing unmount/remount).
|
||||
// Tracks pubkeys that have already started setup in this browser session.
|
||||
const setupInFlightFor = new Set<string>();
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiHatchingCeremonyProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
invalidateProfile: () => void;
|
||||
invalidateCompanion: () => void;
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
onComplete?: () => void;
|
||||
/** If provided, skip egg creation and start from the cracking phase with this existing egg. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/** If true, only create the egg and skip the hatching ceremony. The egg stays an egg. */
|
||||
eggOnly?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiHatchingCeremony({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
existingCompanion,
|
||||
eggOnly = false,
|
||||
}: BlobbiHatchingCeremonyProps) {
|
||||
const isExistingEgg = !!existingCompanion;
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { data: authorData } = useAuthor(user?.pubkey);
|
||||
|
||||
// ── Core state ──
|
||||
const [phase, setPhase] = useState<CeremonyPhase>('loading');
|
||||
const [preview, setPreview] = useState<BlobbiEggPreview | null>(null);
|
||||
const [name, setName] = useState(existingCompanion?.name ?? '');
|
||||
const [isNaming, setIsNaming] = useState(false);
|
||||
const [eggVisible, setEggVisible] = useState(false);
|
||||
|
||||
// Reveal phase state
|
||||
const [blobbiVisible, setBlobbiVisible] = useState(false);
|
||||
const [showFlash, setShowFlash] = useState(false);
|
||||
const [showRevealGlow, setShowRevealGlow] = useState(false);
|
||||
const [fadeOut, setFadeOut] = useState(false);
|
||||
|
||||
// Dialog state
|
||||
const [dialogLineIndex, setDialogLineIndex] = useState(0);
|
||||
const [dialogActive, setDialogActive] = useState(false);
|
||||
const [namingVisible, setNamingVisible] = useState(false);
|
||||
|
||||
// Refs
|
||||
const setupAttempted = useRef(false);
|
||||
const profileRef = useRef(profile);
|
||||
profileRef.current = profile;
|
||||
const previewRef = useRef(preview);
|
||||
previewRef.current = preview;
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const eggContainerRef = useRef<HTMLDivElement>(null);
|
||||
const entrancePlayed = useRef(false);
|
||||
const eggTagsRef = useRef<string[][] | null>(null);
|
||||
|
||||
// ── Companion visuals ──
|
||||
const eggCompanion = useMemo(
|
||||
() => preview ? previewToBlobbiCompanion(preview) : null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[preview?.d],
|
||||
);
|
||||
|
||||
// Baby companion (same visual data but stage=baby)
|
||||
const babyCompanion = useMemo((): BlobbiCompanion | null => {
|
||||
if (!eggCompanion) return null;
|
||||
return { ...eggCompanion, stage: 'baby', state: 'active' };
|
||||
}, [eggCompanion]);
|
||||
|
||||
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
|
||||
|
||||
// ── Typewriter for current dialog line ──
|
||||
const currentDialogText = phase === 'dialog' ? (BIRTH_DIALOG[dialogLineIndex] ?? '') : '';
|
||||
const dialogTypewriter = useTypewriter(currentDialogText, dialogActive);
|
||||
|
||||
const namingTypewriter = useTypewriter(NAMING_DIALOG, namingVisible);
|
||||
|
||||
// ── Fast-path setup for existing eggs (no publishing needed) ──
|
||||
useEffect(() => {
|
||||
if (!isExistingEgg || setupAttempted.current || !existingCompanion) return;
|
||||
setupAttempted.current = true;
|
||||
|
||||
// Build a minimal preview from the existing companion
|
||||
const fakePreview: BlobbiEggPreview = {
|
||||
d: existingCompanion.d,
|
||||
petId: existingCompanion.d,
|
||||
ownerPubkey: user?.pubkey ?? '',
|
||||
name: existingCompanion.name,
|
||||
stage: 'egg',
|
||||
state: 'active',
|
||||
seed: existingCompanion.seed ?? '',
|
||||
stats: {
|
||||
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
|
||||
happiness: existingCompanion.stats.happiness ?? STAT_MAX,
|
||||
health: existingCompanion.stats.health ?? STAT_MAX,
|
||||
hygiene: existingCompanion.stats.hygiene ?? STAT_MAX,
|
||||
energy: existingCompanion.stats.energy ?? STAT_MAX,
|
||||
},
|
||||
visualTraits: existingCompanion.visualTraits,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
setPreview(fakePreview);
|
||||
previewRef.current = fakePreview;
|
||||
eggTagsRef.current = existingCompanion.allTags;
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isExistingEgg, existingCompanion?.d]);
|
||||
|
||||
// ── Silent setup: create profile + egg (new egg flow only) ──
|
||||
useEffect(() => {
|
||||
if (isExistingEgg) return; // Skip for existing eggs
|
||||
if (setupAttempted.current || !user?.pubkey) return;
|
||||
// Module-level guard: if another mount already started setup for this pubkey, skip
|
||||
if (setupInFlightFor.has(user.pubkey)) return;
|
||||
setupAttempted.current = true;
|
||||
setupInFlightFor.add(user.pubkey);
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const currentProfile = profileRef.current;
|
||||
let latestProfileTags: string[][] | null = currentProfile?.allTags ?? null;
|
||||
|
||||
// 1. Create profile if needed
|
||||
if (!currentProfile) {
|
||||
const suggestedName =
|
||||
authorData?.metadata?.display_name ||
|
||||
authorData?.metadata?.name ||
|
||||
'Blobbonaut';
|
||||
|
||||
const baseTags = buildBlobbonautTags(user.pubkey);
|
||||
const tagsWithName = [
|
||||
...baseTags,
|
||||
['name', suggestedName],
|
||||
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
|
||||
];
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: tagsWithName,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
invalidateProfile();
|
||||
latestProfileTags = tagsWithName;
|
||||
}
|
||||
|
||||
// 2. Generate and publish egg
|
||||
const eggPreview = generateEggPreview(user.pubkey, 'Egg');
|
||||
setPreview(eggPreview);
|
||||
previewRef.current = eggPreview;
|
||||
|
||||
const eggTags = previewToEventTags(eggPreview);
|
||||
eggTagsRef.current = eggTags;
|
||||
|
||||
const eggEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: 'A new Blobbi egg!',
|
||||
tags: eggTags,
|
||||
created_at: eggPreview.createdAt,
|
||||
});
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// 3. Update profile with has[] entry
|
||||
if (latestProfileTags) {
|
||||
const existingHas = latestProfileTags
|
||||
.filter(([k]) => k === 'has')
|
||||
.map(([, v]) => v);
|
||||
const newHas = [...existingHas, eggPreview.d];
|
||||
|
||||
const updatedTags = updateBlobbonautTags(latestProfileTags, {
|
||||
has: newHas,
|
||||
});
|
||||
|
||||
const updatedProfileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(updatedProfileEvent);
|
||||
}
|
||||
|
||||
setStoredSelectedD(eggPreview.d);
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Setup failed:', error);
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Failed to set up your Blobbi. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
// Clear module-level guard so future adoptions can create new eggs
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(setup, 600);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// If the timer was cleared before setup ran, release the guard
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) profileRef.current = profile;
|
||||
}, [profile]);
|
||||
|
||||
// eggOnly mode: auto-complete after the egg is shown (skip hatching)
|
||||
useEffect(() => {
|
||||
if (!eggOnly || !eggVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eggOnly, eggVisible, onComplete]);
|
||||
|
||||
// Play entrance animation once
|
||||
useEffect(() => {
|
||||
if (eggVisible && !entrancePlayed.current && eggContainerRef.current) {
|
||||
entrancePlayed.current = true;
|
||||
const el = eggContainerRef.current;
|
||||
el.classList.add('animate-egg-onboard-entrance');
|
||||
const onEnd = () => {
|
||||
el.classList.remove('animate-egg-onboard-entrance');
|
||||
el.removeEventListener('animationend', onEnd);
|
||||
};
|
||||
el.addEventListener('animationend', onEnd);
|
||||
}
|
||||
}, [eggVisible]);
|
||||
|
||||
// ── Shake (DOM-only, no re-render) ──
|
||||
const triggerShake = useCallback((cls: string) => {
|
||||
const el = eggContainerRef.current;
|
||||
if (!el) return;
|
||||
el.classList.remove(
|
||||
'animate-egg-onboard-shake-light',
|
||||
'animate-egg-onboard-shake-medium',
|
||||
'animate-egg-onboard-shake-heavy',
|
||||
);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(cls);
|
||||
}, []);
|
||||
|
||||
// ── Execute the actual hatch: egg -> baby ──
|
||||
const executeHatch = useCallback(async () => {
|
||||
const tags = eggTagsRef.current;
|
||||
if (!tags) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const babyTags = updateBlobbiTags(tags, {
|
||||
stage: 'baby',
|
||||
state: 'active',
|
||||
hunger: STAT_MAX.toString(),
|
||||
happiness: STAT_MAX.toString(),
|
||||
health: STAT_MAX.toString(),
|
||||
hygiene: STAT_MAX.toString(),
|
||||
energy: STAT_MAX.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const babyName = previewRef.current?.name ?? 'Egg';
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${babyName} is a baby Blobbi.`,
|
||||
tags: babyTags,
|
||||
});
|
||||
|
||||
eggTagsRef.current = babyTags;
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
}, [publishEvent, updateCompanionEvent, invalidateCompanion]);
|
||||
|
||||
// ── Egg click ──
|
||||
const handleEggClick = useCallback(() => {
|
||||
if (phase === 'egg') {
|
||||
triggerShake('animate-egg-onboard-shake-light');
|
||||
setPhase('crack_1');
|
||||
} else if (phase === 'crack_1') {
|
||||
triggerShake('animate-egg-onboard-shake-medium');
|
||||
setPhase('crack_2');
|
||||
} else if (phase === 'crack_2') {
|
||||
triggerShake('animate-egg-onboard-shake-heavy');
|
||||
setPhase('crack_3');
|
||||
} else if (phase === 'crack_3') {
|
||||
// Final click -> hatch!
|
||||
setPhase('hatching');
|
||||
setShowFlash(true);
|
||||
|
||||
// Fire the actual hatch mutation
|
||||
executeHatch().catch(console.error);
|
||||
|
||||
// After flash, reveal the baby
|
||||
setTimeout(() => {
|
||||
setShowFlash(false);
|
||||
setShowRevealGlow(true);
|
||||
setPhase('reveal');
|
||||
|
||||
// Fade in blobbi
|
||||
setTimeout(() => setBlobbiVisible(true), 400);
|
||||
|
||||
// After blobbi settles, start dialog
|
||||
setTimeout(() => {
|
||||
setPhase('dialog');
|
||||
setDialogLineIndex(0);
|
||||
setDialogActive(true);
|
||||
}, 2200);
|
||||
}, 1400);
|
||||
}
|
||||
}, [phase, triggerShake, executeHatch]);
|
||||
|
||||
// ── Dialog click: complete line or advance ──
|
||||
const handleDialogClick = useCallback(() => {
|
||||
if (phase !== 'dialog') return;
|
||||
|
||||
if (!dialogTypewriter.done) {
|
||||
// Complete the current line instantly
|
||||
dialogTypewriter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance to next line
|
||||
const nextIndex = dialogLineIndex + 1;
|
||||
if (nextIndex < BIRTH_DIALOG.length) {
|
||||
setDialogActive(false);
|
||||
setDialogLineIndex(nextIndex);
|
||||
// Small pause before next line starts
|
||||
setTimeout(() => setDialogActive(true), 150);
|
||||
} else {
|
||||
// All lines done -> naming
|
||||
setDialogActive(false);
|
||||
setTimeout(() => {
|
||||
setPhase('naming');
|
||||
setTimeout(() => {
|
||||
setNamingVisible(true);
|
||||
setTimeout(() => nameInputRef.current?.focus(), 600);
|
||||
}, 200);
|
||||
}, 400);
|
||||
}
|
||||
}, [phase, dialogTypewriter, dialogLineIndex]);
|
||||
|
||||
// ── Complete ceremony ──
|
||||
const completeCeremony = useCallback(async (finalName: string) => {
|
||||
try {
|
||||
// Update egg/baby name if changed
|
||||
const currentTags = eggTagsRef.current;
|
||||
if (currentTags && finalName !== (previewRef.current?.name ?? 'Egg')) {
|
||||
const namedTags = updateBlobbiTags(currentTags, { name: finalName });
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${finalName} is a baby Blobbi.`,
|
||||
tags: namedTags,
|
||||
});
|
||||
updateCompanionEvent(event);
|
||||
}
|
||||
|
||||
// Mark onboarding done
|
||||
const currentProfile = profileRef.current;
|
||||
if (currentProfile) {
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
blobbi_onboarding_done: 'true',
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Failed to persist completion:', error);
|
||||
}
|
||||
}, [publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
|
||||
|
||||
// ── Naming submit ──
|
||||
const handleNameSubmit = useCallback(async () => {
|
||||
if (isNaming || !name.trim()) return;
|
||||
setIsNaming(true);
|
||||
|
||||
try {
|
||||
await completeCeremony(name.trim());
|
||||
setNamingVisible(false);
|
||||
// Fade to white, then complete
|
||||
setTimeout(() => {
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Naming failed:', error);
|
||||
toast({
|
||||
title: 'Failed to save name',
|
||||
description: 'Your Blobbi was created, but the name could not be saved.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
} finally {
|
||||
setIsNaming(false);
|
||||
}
|
||||
}, [name, isNaming, completeCeremony, onComplete]);
|
||||
|
||||
// ── Tour visual state for EggGraphic crack rendering ──
|
||||
const tourVisualState = useMemo(() => {
|
||||
switch (phase) {
|
||||
case 'crack_1': return 'crack_stage_1' as const;
|
||||
case 'crack_2': return 'crack_stage_2' as const;
|
||||
case 'crack_3': return 'crack_stage_3' as const;
|
||||
case 'hatching': return 'opening' as const;
|
||||
default: return 'idle' as const;
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const isEggPhase = phase === 'egg' || phase === 'crack_1' || phase === 'crack_2' || phase === 'crack_3';
|
||||
const isHatching = phase === 'hatching';
|
||||
const showBaby = phase === 'reveal' || phase === 'dialog' || phase === 'naming';
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)' }}
|
||||
>
|
||||
<div
|
||||
className="absolute size-32 rounded-full opacity-20 animate-pulse"
|
||||
style={{ background: `radial-gradient(circle, ${eggColor}40 0%, transparent 70%)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-hidden select-none"
|
||||
style={{
|
||||
background: showBaby
|
||||
? 'radial-gradient(ellipse at 50% 45%, rgb(60,140,180) 0%, rgb(70,160,195) 25%, rgb(85,175,205) 50%, rgb(100,190,210) 75%, rgb(115,195,195) 100%)'
|
||||
: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)',
|
||||
transition: 'background 2s ease-out',
|
||||
}}
|
||||
onClick={phase === 'dialog' ? handleDialogClick : undefined}
|
||||
>
|
||||
{/* ── Ambient background glow (egg phase only) ── */}
|
||||
{!showBaby && (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity"
|
||||
style={{
|
||||
transitionDuration: '3000ms',
|
||||
background: `radial-gradient(ellipse at 50% 50%, ${eggColor}30 0%, transparent 60%)`,
|
||||
opacity: (isEggPhase || isHatching) ? 0.07 : 0.05,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Floating particles (egg phase) ── */}
|
||||
{isEggPhase && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 2 + (i % 3),
|
||||
height: 2 + (i % 3),
|
||||
left: `${20 + (i * 12) % 60}%`,
|
||||
bottom: '40%',
|
||||
backgroundColor: `${eggColor}40`,
|
||||
animation: `onboard-particle-rise ${4 + i * 0.7}s ease-out ${i * 0.8}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The Egg ── */}
|
||||
{(isEggPhase || isHatching) && eggCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
ref={eggContainerRef}
|
||||
className={cn(
|
||||
'cursor-pointer relative',
|
||||
eggVisible ? '' : 'opacity-0',
|
||||
eggVisible && isEggPhase && 'animate-egg-onboard-breathe',
|
||||
isHatching && 'animate-egg-onboard-burst',
|
||||
)}
|
||||
onClick={isEggPhase ? handleEggClick : undefined}
|
||||
>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-full blur-2xl transition-opacity duration-1000"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${eggColor}50 0%, transparent 70%)`,
|
||||
opacity: phase === 'crack_3' ? 0.5 : phase === 'crack_2' ? 0.35 : phase === 'crack_1' ? 0.25 : 0.15,
|
||||
}}
|
||||
/>
|
||||
<BlobbiStageVisual
|
||||
companion={eggCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-56 sm:size-64 md:size-72"
|
||||
tourVisualState={tourVisualState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Screen flash ── */}
|
||||
{showFlash && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white animate-onboard-screen-flash pointer-events-none"
|
||||
style={{ zIndex: 80 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Hatched baby blobbi with golden incandescence ── */}
|
||||
{showBaby && babyCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
style={{ paddingBottom: '18%' }}
|
||||
>
|
||||
{/* Rotating golden incandescence */}
|
||||
<div className={cn(
|
||||
'absolute animate-onboard-golden-fadein',
|
||||
blobbiVisible ? '' : 'opacity-0',
|
||||
)}>
|
||||
<div
|
||||
className="animate-onboard-golden-rotate"
|
||||
style={{
|
||||
width: 900,
|
||||
height: 900,
|
||||
background: `conic-gradient(
|
||||
from 0deg,
|
||||
rgba(255, 250, 230, 0.18) 0deg,
|
||||
rgba(255, 245, 210, 0.50) 50deg,
|
||||
rgba(255, 250, 235, 0.22) 100deg,
|
||||
rgba(255, 248, 220, 0.15) 150deg,
|
||||
rgba(255, 245, 210, 0.48) 210deg,
|
||||
rgba(255, 250, 230, 0.20) 270deg,
|
||||
rgba(255, 248, 220, 0.15) 320deg,
|
||||
rgba(255, 250, 230, 0.18) 360deg
|
||||
)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bright white-gold shine directly behind blobbi */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 320,
|
||||
height: 320,
|
||||
background: 'radial-gradient(circle, rgba(255,255,245,0.70) 0%, rgba(255,250,225,0.30) 40%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Wider golden halo */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity duration-[2000ms]',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 700,
|
||||
height: 700,
|
||||
background: 'radial-gradient(circle, rgba(255, 248, 210, 0.40) 0%, rgba(255, 240, 190, 0.18) 40%, transparent 65%)',
|
||||
filter: 'blur(15px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Sparkles everywhere ── */}
|
||||
|
||||
{/* Inner ring - bright twinkling sparkles */}
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const angle = (i / 20) * Math.PI * 2;
|
||||
const r = 80 + (i % 4) * 35;
|
||||
const size = 4 + (i % 3) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`inner-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 2 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.4) 40%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,130,1) 0%, rgba(255,220,80,0.3) 50%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${1.5 + (i % 6) * 0.5}s ease-in-out ${i * 0.15}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer ring - larger, slower sparkles */}
|
||||
{Array.from({ length: 16 }).map((_, i) => {
|
||||
const angle = (i / 16) * Math.PI * 2 + 0.3;
|
||||
const r = 170 + (i % 3) * 50;
|
||||
const size = 5 + (i % 4) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`outer-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 3 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 60%)'
|
||||
: 'radial-gradient(circle, rgba(255,235,120,0.85) 0%, transparent 60%)',
|
||||
animation: `onboard-sparkle-twinkle ${2.5 + (i % 5) * 0.7}s ease-in-out ${i * 0.25}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Scattered wide-field sparkles */}
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 2.7 + 1.3) * 0.5 + 0.5) * 80 + 10;
|
||||
const y = (Math.cos(i * 3.1 + 0.7) * 0.5 + 0.5) * 70 + 10;
|
||||
const size = 3 + (i % 3) * 2;
|
||||
return (
|
||||
<div
|
||||
key={`field-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
borderRadius: '50%',
|
||||
background: i % 4 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.95) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,160,0.8) 0%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${2 + (i % 7) * 0.6}s ease-in-out ${i * 0.18}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drifting light motes rising from below */}
|
||||
{Array.from({ length: 10 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 1.9) * 0.5 + 0.5) * 70 + 15;
|
||||
return (
|
||||
<div
|
||||
key={`drift-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: 5 + (i % 3) * 3,
|
||||
height: 5 + (i % 3) * 3,
|
||||
left: `${x}%`,
|
||||
bottom: '20%',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(255,250,200,0.9) 0%, rgba(255,230,120,0.3) 50%, transparent 100%)',
|
||||
animation: `onboard-sparkle-drift ${4 + i * 0.5}s ease-out ${i * 0.5}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The baby blobbi */}
|
||||
<div className={cn(
|
||||
'relative transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}>
|
||||
<BlobbiStageVisual
|
||||
companion={babyCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-[30rem] sm:size-[36rem] md:size-[44rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Dialog text (no box, blur behind) ── */}
|
||||
{phase === 'dialog' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className="relative max-w-md w-full text-center">
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Speaker */}
|
||||
<div className="relative">
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter text */}
|
||||
<p className="text-base sm:text-lg text-white leading-relaxed font-light min-h-[3em]">
|
||||
{dialogTypewriter.displayed}
|
||||
{!dialogTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Advance indicator */}
|
||||
{dialogTypewriter.done && (
|
||||
<div className="mt-4 animate-onboard-continue-pulse">
|
||||
<span className="text-xs text-white/30">▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Naming ── */}
|
||||
{phase === 'naming' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className={cn(
|
||||
'relative max-w-md w-full text-center',
|
||||
namingVisible ? 'animate-onboard-soft-fade-in' : 'opacity-0',
|
||||
)}>
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{/* Speaker */}
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter question */}
|
||||
<p className="text-base sm:text-lg text-white/85 leading-relaxed font-light mb-6 min-h-[1.5em] whitespace-pre-line">
|
||||
{namingTypewriter.displayed}
|
||||
{!namingTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Input + confirm (appear after typewriter done) */}
|
||||
{namingTypewriter.done && (
|
||||
<div className="space-y-3 animate-onboard-soft-fade-in">
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="..."
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
className={cn(
|
||||
'text-center text-lg font-light h-12',
|
||||
'bg-white/10 border-transparent text-white placeholder:text-white/30',
|
||||
'focus:bg-white/[0.25] focus:border-transparent focus:ring-0 focus:outline-none',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'focus:shadow-[0_0_15px_rgba(255,255,255,0.15),0_0_40px_rgba(255,250,230,0.08)]',
|
||||
'transition-all duration-300',
|
||||
'rounded-full transition-shadow duration-500',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) handleNameSubmit();
|
||||
}}
|
||||
/>
|
||||
|
||||
{name.trim() && (
|
||||
<Button
|
||||
onClick={handleNameSubmit}
|
||||
disabled={isNaming}
|
||||
className={cn(
|
||||
'max-w-[12rem] mx-auto h-10 px-8 text-sm font-light tracking-wide',
|
||||
'bg-white/15 hover:bg-white/22 text-white/80 border-transparent',
|
||||
'rounded-full transition-all duration-300',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
)}
|
||||
variant="ghost"
|
||||
>
|
||||
That's the one.
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fade to white on completion ── */}
|
||||
{fadeOut && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white pointer-events-none"
|
||||
style={{
|
||||
zIndex: 90,
|
||||
animation: 'blobbi-fade-to-white 2s ease-in forwards',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,17 @@
|
||||
/**
|
||||
* BlobbiOnboardingFlow - Main component that orchestrates the onboarding steps
|
||||
*
|
||||
* This component renders the appropriate onboarding step based on the user's
|
||||
* actual profile state. The initial step is derived from whether the profile
|
||||
* exists - not hardcoded.
|
||||
*
|
||||
* MODES:
|
||||
* 1. Full onboarding (default): Auto profile creation → Adoption question → Preview
|
||||
* 2. Adoption only (adoptionOnly=true): Skip directly to Preview for existing profiles
|
||||
*
|
||||
* IMPORTANT: This component should only be rendered when:
|
||||
* - User has no profile (auto-creates profile using kind 0 name)
|
||||
* - User has profile but no pets (shows adoption)
|
||||
* - User wants to adopt another Blobbi (adoptionOnly mode)
|
||||
*
|
||||
* Profile creation is now automatic - no manual name entry step is needed.
|
||||
* BlobbiOnboardingFlow - Immersive hatching ceremony for every new Blobbi
|
||||
*
|
||||
* Every new egg goes through the hatching ceremony - whether it's a user's
|
||||
* first Blobbi or their tenth. The ceremony creates the egg silently in the
|
||||
* background and presents a wordless, emotional hatching experience.
|
||||
*
|
||||
* The `adoptionOnly` prop is accepted for API compatibility but no longer
|
||||
* changes the flow - every egg gets the full ceremony.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useBlobbiOnboarding } from '../hooks/useBlobbiOnboarding';
|
||||
import { BlobbiAdoptionStep } from './BlobbiAdoptionStep';
|
||||
import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
|
||||
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BlobbiHatchingCeremony } from './BlobbiHatchingCeremony';
|
||||
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
@@ -43,9 +30,9 @@ interface BlobbiOnboardingFlowProps {
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
/** Called when onboarding is complete */
|
||||
onComplete?: () => void;
|
||||
/**
|
||||
* If true, skip profile creation and adoption question, go directly to preview.
|
||||
* Use this for "Adopt another Blobbi" flow for existing users.
|
||||
/**
|
||||
* Accepted for API compatibility. Every new egg goes through the ceremony.
|
||||
* @deprecated No longer changes the flow.
|
||||
*/
|
||||
adoptionOnly?: boolean;
|
||||
}
|
||||
@@ -58,98 +45,18 @@ export function BlobbiOnboardingFlow({
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly = false,
|
||||
adoptionOnly,
|
||||
}: BlobbiOnboardingFlowProps) {
|
||||
const [showAdoptConfirmDialog, setShowAdoptConfirmDialog] = useState(false);
|
||||
|
||||
const {
|
||||
state,
|
||||
actions,
|
||||
coins,
|
||||
} = useBlobbiOnboarding({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('[BlobbiOnboardingFlow] Rendering:', {
|
||||
hasProfile: !!profile,
|
||||
profileName: profile?.name,
|
||||
step: state.step,
|
||||
hasPreview: !!state.preview,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Handle adopt button click - show confirmation dialog
|
||||
const handleAdoptClick = () => {
|
||||
setShowAdoptConfirmDialog(true);
|
||||
};
|
||||
|
||||
// Handle confirm adoption
|
||||
const handleConfirmAdopt = async () => {
|
||||
await actions.adoptPreview();
|
||||
setShowAdoptConfirmDialog(false);
|
||||
};
|
||||
|
||||
// ─── Step: Auto Profile Creation ──────────────────────────────────────────────
|
||||
// Shows a loading state while profile is being auto-created
|
||||
if (state.step === 'creating-profile') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[300px] gap-4 p-8">
|
||||
<Loader2 className="size-10 text-primary animate-spin" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
Setting up your profile...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Adoption Question ──────────────────────────────────────────────────
|
||||
// Shown when profile exists but user has no pets yet
|
||||
if (state.step === 'adoption-question') {
|
||||
return (
|
||||
<BlobbiAdoptionStep
|
||||
blobbonautName={state.blobbonautName || profile?.name}
|
||||
onStartAdoption={actions.startAdoptionPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Egg Preview ────────────────────────────────────────────────────────
|
||||
// Shown when user is previewing/choosing an egg to adopt
|
||||
if (state.step === 'preview' && state.preview) {
|
||||
return (
|
||||
<>
|
||||
<BlobbiEggPreviewCard
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isFirstPreview={state.isFirstPreview}
|
||||
isProcessing={state.isProcessing}
|
||||
actionInProgress={state.actionInProgress === 'reroll' ? 'reroll' : state.actionInProgress === 'adopt' ? 'adopt' : null}
|
||||
onReroll={actions.rerollPreview}
|
||||
onAdopt={handleAdoptClick}
|
||||
onNameChange={actions.setPreviewName}
|
||||
/>
|
||||
|
||||
<BlobbiAdoptionConfirmDialog
|
||||
open={showAdoptConfirmDialog}
|
||||
onOpenChange={setShowAdoptConfirmDialog}
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isAdopting={state.isProcessing && state.actionInProgress === 'adopt'}
|
||||
onConfirm={handleConfirmAdopt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen if parent logic is correct)
|
||||
console.warn('[BlobbiOnboardingFlow] Unexpected state - no matching step');
|
||||
return null;
|
||||
return (
|
||||
<BlobbiHatchingCeremony
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
updateCompanionEvent={updateCompanionEvent}
|
||||
invalidateProfile={invalidateProfile}
|
||||
invalidateCompanion={invalidateCompanion}
|
||||
setStoredSelectedD={setStoredSelectedD}
|
||||
onComplete={onComplete}
|
||||
eggOnly={adoptionOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Blobbi Onboarding Module
|
||||
*
|
||||
* Provides components and hooks for the Blobbi onboarding flow:
|
||||
* 1. Auto profile creation (using kind 0 name)
|
||||
* 2. Adoption question
|
||||
* 3. Egg preview with reroll/adopt
|
||||
*
|
||||
* Every new egg goes through the immersive hatching ceremony:
|
||||
* dark screen, huge egg, click-to-hatch, sentimental birth reveal, naming.
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { BlobbiAdoptionStep } from './components/BlobbiAdoptionStep';
|
||||
export { BlobbiEggPreviewCard } from './components/BlobbiEggPreviewCard';
|
||||
export { BlobbiAdoptionConfirmDialog } from './components/BlobbiAdoptionConfirmDialog';
|
||||
export { BlobbiOnboardingFlow } from './components/BlobbiOnboardingFlow';
|
||||
export { BlobbiHatchingCeremony } from './components/BlobbiHatchingCeremony';
|
||||
|
||||
// Hooks
|
||||
// Hooks (used internally; kept exported for potential external use)
|
||||
export { useBlobbiOnboarding } from './hooks/useBlobbiOnboarding';
|
||||
export type {
|
||||
OnboardingStep,
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
|
||||
*
|
||||
* Rendered directly in the BlobbiPage layout so the experience feels
|
||||
* focused and guided. Adapts its messaging based on the current tour step.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Send, Check, MousePointerClick } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
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';
|
||||
|
||||
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!`}
|
||||
</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.`}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
|
||||
*
|
||||
* Tells the user their egg is about to hatch and guides them to create a post.
|
||||
* Contains a single mission: create the hatch post.
|
||||
*/
|
||||
|
||||
import { Egg, Send, Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Advance the tour (called after post is confirmed complete) */
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
onContinue,
|
||||
}: FirstHatchTourModalProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header with egg accent */}
|
||||
<div className="px-6 pt-8 pb-4 text-center space-y-3">
|
||||
<div className="mx-auto size-14 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<Egg className="size-7 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{capitalizedName} is ready to hatch!
|
||||
</DialogTitle>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Share a post to the Nostr network and help {capitalizedName} break free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Status indicator */}
|
||||
<div className={
|
||||
postCompleted
|
||||
? 'mt-0.5 size-5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0'
|
||||
: 'mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0'
|
||||
}>
|
||||
{postCompleted && <Check className="size-3 text-emerald-500" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{postCompleted ? 'Post shared!' : 'Share a hatch post'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Post must include the phrase:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!postCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 pb-6">
|
||||
{postCompleted ? (
|
||||
<Button className="w-full" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
|
||||
*
|
||||
* Orchestration only -- no rendering, no animations.
|
||||
* The hook manages:
|
||||
* - Ordered step progression
|
||||
* - Persisted state via localStorage (survives refresh / close)
|
||||
* - Derived booleans for UI consumption
|
||||
* - Safe advance / goTo / complete / reset actions
|
||||
*
|
||||
* Activation is handled separately by useFirstHatchTourActivation,
|
||||
* which calls `start()` when all preconditions are met.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Future integration points
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
|
||||
* to decide whether to start the tour.
|
||||
* 2. UI components read `state.currentStepId` and render overlays,
|
||||
* spotlights, modals, or animation cues accordingly.
|
||||
* 3. Animation components call `actions.advance()` when their
|
||||
* sequence finishes (for autoAdvance steps).
|
||||
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
|
||||
* on the user interaction.
|
||||
* 5. EggGraphic receives a visual-state prop derived from
|
||||
* `state.currentStepId` -- it does NOT own the tour logic.
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
|
||||
import {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
type FirstHatchTourStepId,
|
||||
type FirstHatchTourPersistedState,
|
||||
type TourState,
|
||||
type TourActions,
|
||||
} from '../lib/tour-types';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* localStorage key for the first hatch tour state.
|
||||
* Not user-scoped because onboarding state is device-local and the tour
|
||||
* is inherently tied to "first ever egg on this device". If multi-user
|
||||
* support on the same device becomes a concern, scope by pubkey.
|
||||
*/
|
||||
const STORAGE_KEY = 'blobbi:tour:first-hatch';
|
||||
|
||||
/** Pre-computed lookup: stepId -> index */
|
||||
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
|
||||
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
|
||||
);
|
||||
|
||||
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
|
||||
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
|
||||
|
||||
// ─── Result Type ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseFirstHatchTourResult {
|
||||
/** Reactive tour state for UI consumption */
|
||||
state: TourState<FirstHatchTourStepId>;
|
||||
/** Actions to drive the tour forward */
|
||||
actions: TourActions<FirstHatchTourStepId>;
|
||||
/**
|
||||
* Convenience: check if the current step matches a given id.
|
||||
* Useful for conditional rendering: `isStep('egg_crack_stage_1')`.
|
||||
*/
|
||||
isStep: (stepId: FirstHatchTourStepId) => boolean;
|
||||
/**
|
||||
* Convenience: check if the current step is one of the given ids.
|
||||
* Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`.
|
||||
*/
|
||||
isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean;
|
||||
/**
|
||||
* The current step definition (with autoAdvance metadata), or null.
|
||||
*/
|
||||
currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFirstHatchTour(): UseFirstHatchTourResult {
|
||||
// ── Persisted state ──
|
||||
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
|
||||
STORAGE_KEY,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
);
|
||||
|
||||
// Stable ref to current persisted state so callbacks never go stale.
|
||||
const persistedRef = useRef(persisted);
|
||||
persistedRef.current = persisted;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const updatePersisted = useCallback(
|
||||
(patch: Partial<FirstHatchTourPersistedState>) => {
|
||||
setPersisted((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
},
|
||||
[setPersisted],
|
||||
);
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const start = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
// No-op if already active or completed
|
||||
if (p.completed || p.currentStepId !== null) return;
|
||||
|
||||
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
|
||||
if (!firstStep) return;
|
||||
|
||||
updatePersisted({ currentStepId: firstStep.id });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
if (p.completed || p.currentStepId === null) return;
|
||||
|
||||
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
|
||||
if (currentIndex === undefined) return;
|
||||
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
|
||||
// Past the end -- complete
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
|
||||
if (nextStep.id === 'complete') {
|
||||
// Reaching the 'complete' terminal step means the tour is done
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: nextStep.id });
|
||||
}
|
||||
}, [updatePersisted]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => {
|
||||
if (!STEP_INDEX_MAP.has(stepId)) {
|
||||
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
|
||||
}
|
||||
|
||||
if (stepId === 'complete') {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: stepId, completed: false });
|
||||
}
|
||||
},
|
||||
[updatePersisted],
|
||||
);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
|
||||
}, [setPersisted]);
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
const currentStepIndex = persisted.currentStepId !== null
|
||||
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
|
||||
: -1;
|
||||
|
||||
const state = useMemo((): TourState<FirstHatchTourStepId> => {
|
||||
const isActive = persisted.currentStepId !== null && !persisted.completed;
|
||||
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
|
||||
|
||||
return {
|
||||
isActive,
|
||||
currentStepId: persisted.currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
|
||||
isCompleted: persisted.completed,
|
||||
progress: persisted.completed
|
||||
? 1
|
||||
: currentStepIndex >= 0
|
||||
? currentStepIndex / LAST_REAL_STEP_INDEX
|
||||
: 0,
|
||||
};
|
||||
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
|
||||
|
||||
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
|
||||
start,
|
||||
advance,
|
||||
goTo,
|
||||
complete,
|
||||
reset,
|
||||
}), [start, advance, goTo, complete, reset]);
|
||||
|
||||
// ── Convenience helpers ──
|
||||
|
||||
const isStep = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const isAnyStep = useCallback(
|
||||
(...stepIds: FirstHatchTourStepId[]) => {
|
||||
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
|
||||
},
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const currentStepDef = currentStepIndex >= 0
|
||||
? FIRST_HATCH_TOUR_STEPS[currentStepIndex]
|
||||
: null;
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
isStep,
|
||||
isAnyStep,
|
||||
currentStepDef,
|
||||
};
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* useFirstHatchTourActivation - Activation guard for the first-egg hatch tour.
|
||||
*
|
||||
* This hook checks all preconditions and calls `tour.actions.start()` when
|
||||
* the tour should activate. It is intentionally separated from the tour
|
||||
* state machine so that:
|
||||
* - The state machine stays generic and reusable.
|
||||
* - Activation rules are centralized in one place.
|
||||
* - The rules are easy to read and modify.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Activation rules (ALL must be true):
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. The companions list is loaded (not loading / error).
|
||||
* 2. The user has exactly 1 Blobbi.
|
||||
* 3. That Blobbi is in the egg stage.
|
||||
* 4. No Blobbi is in baby or adult stage.
|
||||
* 5. The tour has not been completed yet (checked via profile tag
|
||||
* AND localStorage fallback).
|
||||
*
|
||||
* 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.
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import type { UseFirstHatchTourResult } from './useFirstHatchTour';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FirstHatchTourActivationInput {
|
||||
/** The full list of the user's Blobbi companions */
|
||||
companions: BlobbiCompanion[];
|
||||
/** Whether the companions list is still loading */
|
||||
isLoading: boolean;
|
||||
/** The tour hook result (localStorage-based 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.
|
||||
*/
|
||||
profileOnboardingDone?: boolean;
|
||||
}
|
||||
|
||||
export interface FirstHatchTourActivationResult {
|
||||
/**
|
||||
* Whether all preconditions for activating the tour are met right now.
|
||||
* This is a derived boolean -- it does NOT mean the tour IS active,
|
||||
* just that it SHOULD be activated. The tour may already be active
|
||||
* from a previous render or a persisted state.
|
||||
*/
|
||||
shouldActivate: boolean;
|
||||
/**
|
||||
* Whether the tour is eligible (preconditions met and not yet completed).
|
||||
* Useful for hiding UI that should only appear during the tour window.
|
||||
*/
|
||||
isEligible: boolean;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluates activation preconditions and auto-starts the tour when met.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const tour = useFirstHatchTour();
|
||||
* const activation = useFirstHatchTourActivation({
|
||||
* companions,
|
||||
* isLoading: companionsLoading,
|
||||
* tour,
|
||||
* profileOnboardingDone: profile?.onboardingDone,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useFirstHatchTourActivation({
|
||||
companions,
|
||||
isLoading,
|
||||
tour,
|
||||
profileOnboardingDone: _profileOnboardingDone = false,
|
||||
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
|
||||
// ── Precondition evaluation ──
|
||||
|
||||
const { shouldActivate, isEligible } = useMemo(() => {
|
||||
// Can't evaluate until data is loaded
|
||||
if (isLoading) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// localStorage tour already completed — this is always authoritative
|
||||
if (tour.state.isCompleted) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// Must have exactly 1 companion
|
||||
if (companions.length !== 1) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
const onlyBlobbi = companions[0];
|
||||
|
||||
// That companion must be an egg
|
||||
if (onlyBlobbi.stage !== 'egg') {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
|
||||
// but kept explicit for clarity and future-proofing if rules change)
|
||||
const hasBabyOrAdult = companions.some(
|
||||
(c) => c.stage === 'baby' || c.stage === 'adult',
|
||||
);
|
||||
if (hasBabyOrAdult) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// ── 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]);
|
||||
|
||||
// ── Auto-start effect ──
|
||||
// When all preconditions are met and the tour hasn't started yet,
|
||||
// start it. This fires once and then `shouldActivate` flips to false
|
||||
// because `tour.state.isActive` becomes true.
|
||||
useEffect(() => {
|
||||
if (shouldActivate) {
|
||||
tour.actions.start();
|
||||
}
|
||||
}, [shouldActivate, tour.actions]);
|
||||
|
||||
return { shouldActivate, isEligible };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Blobbi Tour Module
|
||||
*
|
||||
* Provides the orchestration layer for guided tours / tutorials.
|
||||
* Currently implements the first-egg hatch tour.
|
||||
*
|
||||
* Architecture:
|
||||
* - tour-types.ts: Step definitions, persisted state shape, generic types
|
||||
* - useFirstHatchTour: State machine (step progression, persistence, actions)
|
||||
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
|
||||
*
|
||||
* UI components import from this barrel and read tour state to decide
|
||||
* what to render. They call tour actions (advance, goTo, complete) in
|
||||
* response to user interactions or animation completions.
|
||||
*/
|
||||
|
||||
// ── Types (generic tour infrastructure) ──
|
||||
export type {
|
||||
TourStepDef,
|
||||
TourPersistedState,
|
||||
TourState,
|
||||
TourActions,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Types & Constants ──
|
||||
export {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
} from './lib/tour-types';
|
||||
export type {
|
||||
FirstHatchTourStepId,
|
||||
FirstHatchTourPersistedState,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Hooks ──
|
||||
export { useFirstHatchTour } from './hooks/useFirstHatchTour';
|
||||
export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour';
|
||||
|
||||
export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation';
|
||||
export type {
|
||||
FirstHatchTourActivationInput,
|
||||
FirstHatchTourActivationResult,
|
||||
} from './hooks/useFirstHatchTourActivation';
|
||||
|
||||
// ── First Hatch Tour - Components ──
|
||||
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Tour System - Core Types
|
||||
*
|
||||
* Generic, reusable types for step-based guided tours.
|
||||
* The tour system is designed to be:
|
||||
* - Easy to extend with new tours (define steps + config)
|
||||
* - Easy to reorder steps (change the STEPS array)
|
||||
* - Persistent across page refreshes (localStorage)
|
||||
* - Decoupled from rendering (UI reads state, doesn't own it)
|
||||
*/
|
||||
|
||||
// ─── Generic Tour Infrastructure ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A tour step definition.
|
||||
*
|
||||
* Each step has a unique id and optional metadata that future UI layers
|
||||
* can use to decide what to render (spotlights, modals, animations, etc.).
|
||||
*/
|
||||
export interface TourStepDef<StepId extends string = string> {
|
||||
/** Unique identifier for this step */
|
||||
id: StepId;
|
||||
/**
|
||||
* Whether this step auto-advances (e.g. animations) or waits for
|
||||
* an explicit `advance()` / `goTo()` call from the UI.
|
||||
* Default: false (manual).
|
||||
*/
|
||||
autoAdvance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted state for a tour.
|
||||
* Stored in localStorage so tours survive refresh / close / return.
|
||||
*/
|
||||
export interface TourPersistedState<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.
|
||||
*/
|
||||
export interface TourState<StepId extends string = string> {
|
||||
/** Whether the tour is currently active (started and not yet completed) */
|
||||
isActive: boolean;
|
||||
/** Current step id, or null when idle / completed */
|
||||
currentStepId: StepId | null;
|
||||
/** 0-based index of the current step in the steps array, or -1 */
|
||||
currentStepIndex: number;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Whether the current step is the last one before completion */
|
||||
isLastStep: boolean;
|
||||
/** Whether the tour has been completed (persisted) */
|
||||
isCompleted: boolean;
|
||||
/** Progress as a fraction 0..1 */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions exposed by a tour hook.
|
||||
*/
|
||||
export interface TourActions<StepId extends string = string> {
|
||||
/** Start the tour from the first step (no-op if already active or completed) */
|
||||
start: () => void;
|
||||
/** Advance to the next step. Completes the tour if on the last step. */
|
||||
advance: () => void;
|
||||
/** Jump to a specific step by id. Throws if the step doesn't exist. */
|
||||
goTo: (stepId: StepId) => void;
|
||||
/** Mark the tour as completed and reset to idle. */
|
||||
complete: () => void;
|
||||
/** Reset the tour entirely (clears persisted state). For dev/testing. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ─── First Hatch Tour ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step ids for the first-egg hatch tour.
|
||||
*
|
||||
* Flow:
|
||||
* 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)
|
||||
* 9. complete — terminal, marks tour done
|
||||
*
|
||||
* The order here matches the intended flow. To reorder steps,
|
||||
* change FIRST_HATCH_TOUR_STEPS (the array), not this type.
|
||||
*/
|
||||
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'
|
||||
| 'complete';
|
||||
|
||||
/**
|
||||
* Ordered step definitions for the first hatch tour.
|
||||
*
|
||||
* To add / remove / reorder steps, edit this array.
|
||||
* The tour state machine walks through these in order.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<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: '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,
|
||||
};
|
||||
+102
-178
@@ -1,50 +1,31 @@
|
||||
/**
|
||||
* BlobbiPhotoModal - Modal for taking and sharing Blobbi photos
|
||||
* BlobbiPhotoModal - Fullscreen photo overlay
|
||||
*
|
||||
* Features:
|
||||
* - Polaroid-style preview of the Blobbi
|
||||
* - Download as PNG
|
||||
* - Post to Nostr with Blossom upload
|
||||
*
|
||||
* Uses html-to-image for DOM-to-PNG conversion.
|
||||
* Simple blurred overlay with the polaroid photo centered,
|
||||
* and download/share buttons below. Tap outside to close.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Download, Send, Loader2, Camera } from 'lucide-react';
|
||||
import { Download, Share2, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BlobbiPolaroidCard } from './BlobbiPolaroidCard';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { downloadTextFile, openUrl } from '@/lib/downloadFile';
|
||||
import { trackDailyMissionProgress } from '@/blobbi/actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export interface BlobbiPhotoModalProps {
|
||||
/** Whether the modal is open */
|
||||
open: boolean;
|
||||
/** Callback when the modal should close */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi companion to photograph */
|
||||
companion: BlobbiCompanion;
|
||||
}
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a data URL to a File object
|
||||
*/
|
||||
function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] ?? 'image/png';
|
||||
@@ -57,218 +38,161 @@ function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
return new File([u8arr], filename, { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download in the browser
|
||||
*/
|
||||
function downloadFile(dataUrl: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPhotoModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
}: BlobbiPhotoModalProps) {
|
||||
const polaroidRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Generate PNG from the polaroid card
|
||||
*/
|
||||
const generateImage = useCallback(async (): Promise<string | null> => {
|
||||
if (!polaroidRef.current) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Could not capture the photo. Please try again.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!polaroidRef.current) return null;
|
||||
try {
|
||||
// Use html-to-image with high quality settings
|
||||
const dataUrl = await toPng(polaroidRef.current, {
|
||||
return await toPng(polaroidRef.current, {
|
||||
quality: 1.0,
|
||||
pixelRatio: 2, // 2x for retina displays
|
||||
pixelRatio: 2,
|
||||
cacheBust: true,
|
||||
// Skip external fonts that might fail to load
|
||||
skipFonts: true,
|
||||
});
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to generate image:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to generate the photo. Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to generate image:', error);
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Failed to capture photo.' });
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle download action
|
||||
*/
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const dataUrl = await generateImage();
|
||||
if (dataUrl) {
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-polaroid.png`;
|
||||
downloadFile(dataUrl, filename);
|
||||
toast({
|
||||
title: 'Photo saved!',
|
||||
description: 'Your Blobbi photo has been downloaded.',
|
||||
});
|
||||
if (!dataUrl) return;
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-photo.png`;
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// On native, use the download utility which handles share sheet
|
||||
const blob = dataUrlToFile(dataUrl, filename);
|
||||
const url = URL.createObjectURL(blob);
|
||||
await openUrl(url);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
toast({ title: 'Photo saved!' });
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [generateImage, companion.name]);
|
||||
|
||||
/**
|
||||
* Handle post action - upload to Blossom and create Nostr post
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to post your Blobbi photo.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPosting(true);
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
// Generate the image
|
||||
const dataUrl = await generateImage();
|
||||
if (!dataUrl) {
|
||||
return;
|
||||
}
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Convert to File for upload
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.png`;
|
||||
const file = dataUrlToFile(dataUrl, filename);
|
||||
|
||||
// Upload to Blossom - returns NIP-94 compatible tags
|
||||
const tags = await uploadFile(file);
|
||||
|
||||
// Extract URL from the 'url' tag (NIP-94 format)
|
||||
// The upload hook returns tags like [['url', '...'], ['m', '...'], ['x', '...'], ...]
|
||||
const urlTag = tags.find((tag) => tag[0] === 'url');
|
||||
if (!urlTag || !urlTag[1]) {
|
||||
throw new Error('Upload succeeded but no URL was returned');
|
||||
}
|
||||
if (!urlTag?.[1]) throw new Error('Upload succeeded but no URL returned');
|
||||
const url = urlTag[1];
|
||||
|
||||
// Build imeta tag from all NIP-94 tags
|
||||
// Format: ['imeta', 'url https://...', 'm image/png', 'x abc123', ...]
|
||||
const imetaFields = tags.map((tag) => `${tag[0]} ${tag[1]}`);
|
||||
|
||||
// Create the post content
|
||||
const content = `${companion.name} ${url}`;
|
||||
|
||||
// Publish kind 1 event
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
content: `${companion.name} ${url}`,
|
||||
tags: [['imeta', ...imetaFields]],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Posted!',
|
||||
description: 'Your Blobbi photo has been shared.',
|
||||
});
|
||||
|
||||
// Track daily mission progress for photo action
|
||||
toast({ title: 'Posted!', description: 'Your Blobbi photo has been shared.' });
|
||||
trackDailyMissionProgress('take_photo', 1, user.pubkey);
|
||||
|
||||
// Close the modal after successful post
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to post:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to post',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to share:', error);
|
||||
toast({ variant: 'destructive', title: 'Failed to post', description: error instanceof Error ? error.message : 'Please try again.' });
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [user, generateImage, companion.name, uploadFile, createEvent, onOpenChange]);
|
||||
|
||||
const isProcessing = isGenerating || isPosting;
|
||||
if (!open) return null;
|
||||
|
||||
const isProcessing = isDownloading || isSharing;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Camera className="size-5" />
|
||||
Take a Photo
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Capture a polaroid-style photo of {companion.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center">
|
||||
{/* Backdrop — tap to close */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/60 backdrop-blur-sm"
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Polaroid preview - centered */}
|
||||
<div className="flex justify-center py-4">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
{/* Close button — top-right of the container */}
|
||||
<button
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
className="absolute top-3 right-3 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
{/* Polaroid card */}
|
||||
<div className="relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="relative z-10 flex items-center gap-6 mt-8">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-sky-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #0ea5e9 25%, transparent), color-mix(in srgb, #0ea5e9 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isDownloading ? <Loader2 className="size-6 animate-spin" /> : <Download className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Save</span>
|
||||
</button>
|
||||
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4 mr-2" />
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={isProcessing || !user}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPosting ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4 mr-2" />
|
||||
)}
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Login hint if not logged in */}
|
||||
{!user && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Log in to post your Blobbi photo
|
||||
</p>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-violet-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #8b5cf6 25%, transparent), color-mix(in srgb, #8b5cf6 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isSharing ? <Loader2 className="size-6 animate-spin" /> : <Share2 className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Post</span>
|
||||
</button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -652,3 +652,50 @@
|
||||
@apply min-h-[350px];
|
||||
}
|
||||
|
||||
/* Blobbi idle animations — speed/intensity driven by happiness via inline style */
|
||||
@keyframes blobbi-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes blobbi-sway {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(1.5deg); }
|
||||
75% { transform: rotate(-1.5deg); }
|
||||
}
|
||||
|
||||
/* Hatch ceremony shake animations */
|
||||
@keyframes blobbi-shake-light {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(-3deg); }
|
||||
75% { transform: rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes blobbi-shake-medium {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(-6deg); }
|
||||
40% { transform: rotate(5deg); }
|
||||
60% { transform: rotate(-4deg); }
|
||||
80% { transform: rotate(6deg); }
|
||||
}
|
||||
|
||||
@keyframes blobbi-shake-heavy {
|
||||
0%, 100% { transform: rotate(0deg) scale(1); }
|
||||
15% { transform: rotate(-8deg) scale(1.02); }
|
||||
30% { transform: rotate(7deg) scale(0.98); }
|
||||
45% { transform: rotate(-9deg) scale(1.03); }
|
||||
60% { transform: rotate(8deg) scale(0.97); }
|
||||
75% { transform: rotate(-7deg) scale(1.02); }
|
||||
90% { transform: rotate(9deg) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes blobbi-flash {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes blobbi-fade-to-white {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
|
||||
+1278
-1131
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user