Replace first-hatch modal with inline card, hide dashboard controls during tour

UX change: the first-hatch experience is now a focused onboarding screen
instead of a modal interruption.

Layout during first-hatch tour:
- Egg visual (top, with tour animations)
- Stats (if any visible)
- FirstHatchTourCard inline below stats (mission + post CTA)
- No floating hero controls (camera, info, companion, incubation)
- No bottom action bar (blobbies, missions, actions, shop, inventory)
- No inline activity area (music, sing)

The page feels like a dedicated guided flow rather than a dashboard
with overlays. Normal dashboard controls return after tour completion.

Architecture: clean branch in BlobbiDashboard render --
isFirstHatchTourActive gates visibility of controls/bar/activities.
The inline card lives at the same level as other content sections.
The first egg is treated as already in the hatch onboarding path
without requiring the normal 'start incubation' entry point.
This commit is contained in:
filemon
2026-04-01 18:11:48 -03:00
parent 2c737ca322
commit 0487586af9
3 changed files with 145 additions and 52 deletions
@@ -0,0 +1,99 @@
/**
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
*
* Replaces the modal. Rendered directly in the BlobbiPage layout so the
* experience feels focused and guided rather than interrupted.
*/
import { Send, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourCardProps {
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Advance the tour (called after post is confirmed complete) */
onContinue: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourCard({
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
onContinue,
}: FirstHatchTourCardProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return (
<div className="w-full max-w-sm mx-auto px-4 space-y-4">
{/* Title + description */}
<div className="text-center space-y-1.5">
<h3 className="text-lg font-semibold">
{capitalizedName} is ready to hatch!
</h3>
<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="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">
Your post must include:
</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>
{/* Continue or hint */}
{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>
);
}
+1 -1
View File
@@ -43,4 +43,4 @@ export type {
} from './hooks/useFirstHatchTourActivation';
// ── First Hatch Tour - Components ──
export { FirstHatchTourModal } from './components/FirstHatchTourModal';
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
+45 -51
View File
@@ -90,7 +90,7 @@ import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reacti
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourModal } from '@/blobbi/tour';
import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourCard } from '@/blobbi/tour';
import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions';
import type { EggTourVisualState } from '@/blobbi/egg';
@@ -946,26 +946,30 @@ function BlobbiDashboard({
// The required phrase for the first-hatch post
const firstHatchPhrase = useMemo(() => buildHatchPhrase(companion.name), [companion.name]);
// Auto-advance from idle to egg_ready_hint, then to show_hatch_modal
// Auto-advance from idle -> egg_ready_hint -> show_hatch_modal (inline card)
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('idle')) {
// Advance immediately to egg_ready_hint
firstHatchTour.actions.advance();
firstHatchTour.actions.advance(); // -> egg_ready_hint
}
}, [isFirstHatchTourActive, firstHatchTour]);
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('egg_ready_hint')) {
// Show the ready hint briefly, then move to the modal
// Show the ready hint wiggle briefly, then show the inline card
const timer = setTimeout(() => {
firstHatchTour.actions.advance();
firstHatchTour.actions.advance(); // -> show_hatch_modal (inline card step)
}, 3000);
return () => clearTimeout(timer);
}
}, [isFirstHatchTourActive, firstHatchTour]);
// Whether the inline first-hatch card should be shown
const showFirstHatchCard = isFirstHatchTourActive && (
firstHatchTour.isStep('show_hatch_modal') || firstHatchTour.isStep('await_create_post')
);
// Detect hatch post completion for the first-hatch tour
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -1419,8 +1423,8 @@ function BlobbiDashboard({
{/* Hero Section */}
<div className="flex-1 flex flex-col items-center justify-center px-4 py-4 sm:px-6">
{/* Floating Dashboard Controls */}
<BlobbiDashboardFloatingControls
{/* Floating Dashboard Controls - hidden during first-hatch tour */}
{!isFirstHatchTourActive && <BlobbiDashboardFloatingControls
stage={companion.stage}
onSetAsCompanion={handleSetAsCompanion}
isCurrentCompanion={isCurrentCompanion}
@@ -1453,7 +1457,7 @@ function BlobbiDashboard({
onDevOpenEditor={() => setShowDevEditor(true)}
// DEV ONLY: Open emotion tester panel
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
/>
/>}
{/* Blobbi Name */}
<div className="flex items-center gap-2 mb-6">
@@ -1528,8 +1532,23 @@ function BlobbiDashboard({
{/* Inline Activity Area - inside padded container for proper spacing above bottom bar */}
{inlineActivity.type === 'music' && (
{/* First Hatch Tour: inline card below stats */}
{showFirstHatchCard && (
<div className="mt-6">
<FirstHatchTourCard
blobbiName={companion.name}
requiredPhrase={firstHatchPhrase}
postCompleted={!!tourPostFound}
onCreatePost={() => setShowPostModal(true)}
onContinue={() => {
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
}}
/>
</div>
)}
{/* Inline Activity Area - hidden during first-hatch tour */}
{!isFirstHatchTourActive && inlineActivity.type === 'music' && (
<div className="mt-6">
<InlineMusicPlayer
selection={inlineActivity.selection}
@@ -1543,7 +1562,7 @@ function BlobbiDashboard({
</div>
)}
{inlineActivity.type === 'sing' && (
{!isFirstHatchTourActive && inlineActivity.type === 'sing' && (
<div className="mt-6">
<InlineSingCard
onConfirm={handleConfirmSing}
@@ -1556,27 +1575,20 @@ function BlobbiDashboard({
)}
</div>
{/* Bottom Action Bar */}
<BlobbiBottomBar
onBlobbiesClick={() => setShowSelector(true)}
onMissionsClick={() => {
if (isFirstHatchTourActive) {
// During the first-hatch tour, open the tour modal instead
if (!firstHatchTour.isStep('show_hatch_modal') && !firstHatchTour.isStep('await_create_post')) {
firstHatchTour.actions.goTo('show_hatch_modal');
}
} else {
setShowMissionsModal(true);
}
}}
onActionsClick={() => setShowActionsModal(true)}
onShopClick={() => setShowShopModal(true)}
onInventoryClick={() => setShowInventoryModal(true)}
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
isInTaskProcess={isFirstHatchTourActive || isInTaskProcess}
remainingTasksCount={isFirstHatchTourActive ? (tourPostFound ? 0 : 1) : remainingTasksCount}
allTasksComplete={isFirstHatchTourActive ? !!tourPostFound : allTasksComplete}
/>
{/* Bottom Action Bar - hidden during first-hatch tour */}
{!isFirstHatchTourActive && (
<BlobbiBottomBar
onBlobbiesClick={() => setShowSelector(true)}
onMissionsClick={() => setShowMissionsModal(true)}
onActionsClick={() => setShowActionsModal(true)}
onShopClick={() => setShowShopModal(true)}
onInventoryClick={() => setShowInventoryModal(true)}
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
isInTaskProcess={isInTaskProcess}
remainingTasksCount={remainingTasksCount}
allTasksComplete={allTasksComplete}
/>
)}
{/* Blobbi Selector Modal */}
<Dialog open={showSelector} onOpenChange={setShowSelector}>
@@ -1724,24 +1736,6 @@ function BlobbiDashboard({
onSuccess={refetchCurrentTasks}
/>
{/* First Hatch Tour Modal */}
<FirstHatchTourModal
open={isFirstHatchTourActive && (firstHatchTour.isStep('show_hatch_modal') || firstHatchTour.isStep('await_create_post'))}
onOpenChange={(open) => {
if (!open && isFirstHatchTourActive && firstHatchTour.isStep('show_hatch_modal')) {
// User dismissed the modal -- move to await_create_post so they can
// come back to it (the modal stays open for await_create_post too)
firstHatchTour.actions.advance();
}
}}
blobbiName={companion.name}
requiredPhrase={firstHatchPhrase}
postCompleted={!!tourPostFound}
onCreatePost={() => setShowPostModal(true)}
onContinue={() => {
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
}}
/>
{/* Blobbi Photo Modal - polaroid-style photo capture */}
<BlobbiPhotoModal