Merge branch 'update-hatch-action' into 'main'

Add first-hatch tour orchestration layer (state machine + activation)

See merge request soapbox-pub/ditto!153
This commit is contained in:
Alex Gleason
2026-04-03 02:43:05 +00:00
34 changed files with 3422 additions and 4966 deletions
@@ -1,19 +1,39 @@
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
/**
* Missions modal for Blobbi.
*
* Shows:
* - Daily missions (always visible, separate reward system)
* - Incubation tasks when the current Blobbi is incubating (egg stage)
* - Evolve tasks when evolving (baby stage)
* Missions modal for Blobbi — card-grid quest board.
*
* Layout:
* 1. Sticky header with title, subtitle, legend help button, close
* 2. Current Focus section (hatch / evolve) — collapsible, default open
* 3. Daily Bounties section — collapsible, default open
* 4. Settings row — low emphasis toggle (not collapsible)
*
* Both main sections use lightweight Radix Collapsible wrappers.
* Collapsed headers still show summary info (progress / coins).
*/
import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
import {
Loader2,
XCircle,
AlertTriangle,
Coins,
X,
Eye,
Scroll,
Compass,
HelpCircle,
ChevronDown,
} from 'lucide-react';
import { formatCompactNumber, cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
AlertDialog,
AlertDialogAction,
@@ -24,7 +44,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useState } from 'react';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
@@ -42,36 +61,86 @@ import { useRerollMission } from '../hooks/useRerollMission';
interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Current companion being viewed */
companion: BlobbiCompanion;
/** Current Blobbonaut profile (required for coin updates) */
profile: BlobbonautProfile | null;
/** Callback to update profile in query cache after claiming */
updateProfileEvent: (event: NostrEvent) => void;
/** Hatch tasks result from useHatchTasks */
hatchTasks: HatchTasksResult;
/** Evolve tasks result from useEvolveTasks */
evolveTasks: EvolveTasksResult;
/** Called when user clicks "Create Post" action in tasks */
onOpenPostModal: () => void;
/** Called when all hatch tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching: boolean;
/** Called when all evolve tasks are complete and user clicks "Evolve" */
onEvolve: () => void;
/** Whether evolving is in progress */
isEvolving: boolean;
/** Called when user confirms stopping incubation */
onStopIncubation: () => Promise<void>;
/** Whether stop incubation is in progress */
isStoppingIncubation: boolean;
/** Called when user confirms stopping evolution */
onStopEvolution: () => Promise<void>;
/** Whether stop evolution is in progress */
isStoppingEvolution: boolean;
/** Available Blobbi stages across all user's companions (for mission filtering) */
availableStages?: ('egg' | 'baby' | 'adult')[];
showMissionCard?: boolean;
onToggleMissionCard?: (visible: boolean) => void;
}
// ─── Section Chevron ─────────────────────────────────────────────────────────
function SectionChevron({ open }: { open: boolean }) {
return (
<ChevronDown
className={cn(
'size-4 text-muted-foreground/60 transition-transform duration-200',
open && 'rotate-180',
)}
/>
);
}
// ─── Mission Type Legend ──────────────────────────────────────────────────────
function MissionTypeLegend() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="rounded-full p-1.5 opacity-50 hover:opacity-100 hover:bg-muted transition-all"
aria-label="Mission types legend"
>
<HelpCircle className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56 p-3">
<p className="text-xs font-semibold mb-2">Mission Types</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
<Scroll className="size-3 text-amber-500" />
</div>
<div>
<p className="text-xs font-medium">Daily Bounty</p>
<p className="text-[10px] text-muted-foreground">Resets every day</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-sky-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🥚</span>
</div>
<div>
<p className="text-xs font-medium">Hatch Task</p>
<p className="text-[10px] text-muted-foreground">Egg progression</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🐣</span>
</div>
<div>
<p className="text-xs font-medium">Evolve Task</p>
<p className="text-[10px] text-muted-foreground">Baby progression</p>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
// ─── Daily Missions Section ───────────────────────────────────────────────────
@@ -79,14 +148,20 @@ interface BlobbiMissionsModalProps {
interface DailyMissionsSectionProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
/** Available Blobbi stages the user has */
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) {
function DailyMissionsSection({
profile,
updateProfileEvent,
availableStages,
disabled,
defaultOpen = true,
}: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayClaimedReward,
@@ -100,58 +175,56 @@ function DailyMissionsSection({ profile, updateProfileEvent, availableStages, di
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
profile,
updateProfileEvent
updateProfileEvent,
);
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const handleClaimReward = (missionId: string) => {
claimReward({ missionId });
};
const handleRerollMission = (missionId: string) => {
rerollMission({ missionId, availableStages });
};
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-sm">Daily Missions</h3>
<Scroll className="size-4 text-amber-500 dark:text-amber-400 shrink-0" />
<h3 className="font-semibold text-sm">Daily Bounties</h3>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0" />
<span className="whitespace-nowrap">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
<span className="tabular-nums">
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
</span>
{claimableCount > 0 && (
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
{claimableCount}
</span>
)}
</div>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
{/* Mission list */}
<CollapsibleContent className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={handleClaimReward}
onRerollMission={handleRerollMission}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={(id) => claimReward({ missionId: id })}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</div>
</CollapsibleContent>
</Collapsible>
);
@@ -224,9 +297,9 @@ function StopConfirmationDialog({
);
}
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
interface ProcessContentProps {
interface CurrentFocusSectionProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
@@ -238,7 +311,7 @@ interface ProcessContentProps {
defaultOpen?: boolean;
}
function ProcessContent({
function CurrentFocusSection({
companion,
tasks,
processType,
@@ -248,93 +321,98 @@ function ProcessContent({
onStop,
isStopping,
defaultOpen = true,
}: ProcessContentProps) {
}: CurrentFocusSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const isIncubation = processType === 'incubation';
const emoji = isIncubation ? '🥚' : '🐣';
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
const description = isIncubation
? 'Complete these tasks to hatch your Blobbi'
: 'Complete these tasks to evolve your Blobbi';
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
const completeEmoji = isIncubation ? '🐣' : '';
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
const badgeLabel = isIncubation ? 'Hatch' : 'Evolve';
const category = isIncubation ? ('hatch' as const) : ('evolve' as const);
const completedCount = tasks.tasks.filter(t => t.completed).length;
const completedCount = tasks.tasks.filter((t) => t.completed).length;
const totalTasks = tasks.tasks.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<span className="text-lg">{emoji}</span>
<h3 className="font-semibold text-sm">{title}</h3>
<Badge
variant="secondary"
className={cn(
'text-xs font-semibold px-2 py-0.5',
isIncubation
? 'bg-sky-500/15 text-sky-600 dark:text-sky-400'
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
)}
>
{badgeLabel}
</Badge>
<span className="text-sm font-semibold">{title}</span>
</div>
<div className="flex items-center gap-2">
<span className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full",
tasks.allCompleted
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{completedCount}/{totalTasks}
<span
className={cn(
'text-xs font-medium tabular-nums',
tasks.allCompleted
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)}
>
{completedCount} / {totalTasks}
</span>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
{/* Tasks content */}
<CollapsibleContent className="pt-3">
{/* Tasks Panel */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onOpenPostModal={onOpenPostModal}
onComplete={onComplete}
isCompleting={isCompleting}
emoji={emoji}
title={title}
description={description}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
/>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
{/* Task card grid */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onOpenPostModal={onOpenPostModal}
onComplete={onComplete}
isCompleting={isCompleting}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
category={category}
/>
{/* Stop Process Button */}
<div className="mt-6 pt-4 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
{isStopping ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-4 mr-2" />
{stopLabel}
</>
)}
</Button>
{/* Stop process — low emphasis */}
<div className="mt-3 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8 px-3"
>
{isStopping ? (
<>
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-3.5 mr-1.5" />
{stopLabel}
</>
)}
</Button>
</div>
</div>
</CollapsibleContent>
{/* Stop Confirmation Dialog */}
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
@@ -347,6 +425,17 @@ function ProcessContent({
);
}
// ─── Empty Focus State ────────────────────────────────────────────────────────
function EmptyFocusState() {
return (
<div className="py-6 text-center">
<Compass className="size-5 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No active progression right now</p>
</div>
);
}
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function BlobbiMissionsModal({
@@ -367,54 +456,46 @@ export function BlobbiMissionsModal({
onStopEvolution,
isStoppingEvolution,
availableStages,
showMissionCard,
onToggleMissionCard,
}: BlobbiMissionsModalProps) {
const isIncubating = companion.state === 'incubating';
const isEvolvingState = companion.state === 'evolving';
const isEgg = companion.stage === 'egg';
const isBaby = companion.stage === 'baby';
// Check if there's an active hatch/evolve process
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle className="flex items-center gap-2">
<Target className="size-5 shrink-0" />
Missions
</DialogTitle>
<DialogDescription className="break-words">
Complete missions to earn rewards for {companion.name}
</DialogDescription>
{/* ── Sticky Header ── */}
<div className="sticky top-0 z-10 bg-background px-4 sm:px-5 pt-4 pb-3 border-b border-border/60">
<div className="flex items-center justify-between">
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight">Missions</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Quests & bounties for {companion.name}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<MissionTypeLegend />
<DialogClose className="rounded-full p-1.5 opacity-60 hover:opacity-100 hover:bg-muted transition-all">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
</div>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-6 py-3 sm:py-4 space-y-4">
{/* Daily Missions Section - Always visible, expanded by default */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
defaultOpen={true}
/>
{/* Hatch/Evolve Process Section - Only when active, expanded by default */}
{hasActiveProcess && (
{/* ── Scrollable Content ── */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-5 py-4 space-y-5">
{/* 1. Current Focus */}
{hasActiveProcess ? (
<>
{isIncubating && isEgg ? (
<ProcessContent
<CurrentFocusSection
companion={companion}
tasks={hatchTasks}
processType="incubation"
@@ -423,10 +504,9 @@ export function BlobbiMissionsModal({
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
defaultOpen={true}
/>
) : isEvolvingState && isBaby ? (
<ProcessContent
<CurrentFocusSection
companion={companion}
tasks={evolveTasks}
processType="evolution"
@@ -435,10 +515,43 @@ export function BlobbiMissionsModal({
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
defaultOpen={true}
/>
) : null}
</>
) : (
<EmptyFocusState />
)}
{/* Divider */}
<div className="h-px bg-border/60" />
{/* 2. Daily Bounties */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
/>
{/* 3. Settings */}
{onToggleMissionCard !== undefined && showMissionCard !== undefined && (
<>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between py-1">
<Label
htmlFor="mission-card-toggle"
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer"
>
<Eye className="size-3.5" />
Show mission card on main page
</Label>
<Switch
id="mission-card-toggle"
checked={showMissionCard}
onCheckedChange={onToggleMissionCard}
/>
</div>
</>
)}
</div>
</DialogContent>
@@ -30,6 +30,7 @@ import { toast } from '@/hooks/useToast';
import {
BLOBBI_POST_REQUIRED_HASHTAGS,
buildHatchPhrase,
} from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -49,33 +50,13 @@ interface BlobbiPostModalProps {
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Sanitize a name into a valid hashtag format.
* - Removes special characters
* - Replaces spaces with nothing (camelCase-like)
* - Ensures lowercase
* - Handles edge cases
*/
function sanitizeToHashtag(name: string): string {
return name
.toLowerCase()
// Remove emojis and special characters, keep letters, numbers, underscores
.replace(/[^\p{L}\p{N}_]/gu, '')
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
.replace(/^(\d)/, 'blobbi$1')
// Limit length
.slice(0, 30)
// Fallback if empty
|| 'myblobbi';
}
/**
* Build the required prefix text based on process type.
*/
function buildPrefix(process: BlobbiPostProcess): string {
return process === 'evolve'
? 'Hello Nostr! Posting to evolve'
: 'Hello Nostr! Posting to hatch';
? 'Posting to evolve'
: 'Posting to hatch';
}
// ─── Main Component ───────────────────────────────────────────────────────────
@@ -91,20 +72,19 @@ export function BlobbiPostModal({
const { mutateAsync: createEvent, isPending } = useNostrPublish();
// Compute the required elements based on props
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
const prefix = useMemo(() => buildPrefix(process), [process]);
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
// All required hashtags including the Blobbi name (first)
const allRequiredHashtags = useMemo(() =>
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
[blobbiHashtag]
// The required phrase that must appear in the post
const requiredPhrase = useMemo(() =>
process === 'hatch'
? buildHatchPhrase(blobbiName)
: `${prefix} ${capitalizedName} #blobbi`,
[process, blobbiName, prefix, capitalizedName]
);
// Build default content
const defaultContent = useMemo(() =>
`${prefix} #${allRequiredHashtags.join(' #')}`,
[prefix, allRequiredHashtags]
);
// Build default content (the phrase itself is enough)
const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]);
const [content, setContent] = useState(defaultContent);
const [validationError, setValidationError] = useState<string | null>(null);
@@ -118,24 +98,14 @@ export function BlobbiPostModal({
}, [open, defaultContent]);
/**
* Validate that the content still contains the required prefix and hashtags.
* Validate that the content contains the required phrase.
*/
const validateContent = useCallback((text: string): string | null => {
// Check prefix
if (!text.startsWith(prefix)) {
return 'The post must start with the required text';
if (!text.includes(requiredPhrase)) {
return `The post must contain: "${requiredPhrase}"`;
}
// Check all required hashtags are present (including Blobbi name)
const lowerText = text.toLowerCase();
for (const tag of allRequiredHashtags) {
if (!lowerText.includes(`#${tag.toLowerCase()}`)) {
return `Missing required hashtag: #${tag}`;
}
}
return null;
}, [prefix, allRequiredHashtags]);
}, [requiredPhrase]);
/**
* Handle content change with validation.
@@ -180,21 +150,26 @@ export function BlobbiPostModal({
}
try {
// Build tags for the post
// Build tags for the post: extract all hashtags from content
const tags: string[][] = [];
const seen = new Set<string>();
// Add all required hashtags as 't' tags
for (const hashtag of allRequiredHashtags) {
tags.push(['t', hashtag.toLowerCase()]);
// Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags
for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) {
const lower = hashtag.toLowerCase();
if (!seen.has(lower)) {
tags.push(['t', lower]);
seen.add(lower);
}
}
// Extract any additional hashtags the user added
const additionalHashtags = content.match(/#(\w+)/g) || [];
const requiredLower = allRequiredHashtags.map(t => t.toLowerCase());
for (const tag of additionalHashtags) {
// Extract any additional hashtags from the content
const contentHashtags = content.match(/#(\w+)/g) || [];
for (const tag of contentHashtags) {
const tagValue = tag.slice(1).toLowerCase();
if (!requiredLower.includes(tagValue)) {
if (!seen.has(tagValue)) {
tags.push(['t', tagValue]);
seen.add(tagValue);
}
}
@@ -220,7 +195,7 @@ export function BlobbiPostModal({
variant: 'destructive',
});
}
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
const canPost = !validationError && content.trim().length > 0;
@@ -282,13 +257,9 @@ export function BlobbiPostModal({
{/* Preview of required content */}
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
<p className="text-xs text-muted-foreground mb-1">Required content:</p>
<p className="text-sm font-medium">
<span className="text-primary">{prefix}</span>
{' '}
{allRequiredHashtags.map(tag => (
<span key={tag} className="text-blue-500">#{tag} </span>
))}
<p className="text-xs text-muted-foreground mb-1">Required phrase:</p>
<p className="text-sm font-medium text-primary">
{requiredPhrase}
</p>
</div>
</div>
@@ -1,285 +1,164 @@
/**
* DailyMissionsPanel - UI component for displaying daily missions
*
* Shows:
* - Daily mission list with progress bars
* - Completion state
* - Claim buttons for completed missions
* - Coin rewards
* - Bonus mission after completing all regular missions
* - Empty state when no missions available (egg-only users)
* - Reroll button to replace missions (max 3/day)
* DailyMissionsPanel — card-grid layout for daily bounties.
*
* Each mission is a compact card in a 2-col grid.
* Tapping a card expands it to show progress, claim button, and reroll.
* Only one card expanded at a time.
*/
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import {
Check,
Coins,
Gift,
Sparkles,
Egg,
Trophy,
RefreshCw,
Heart,
Utensils,
Droplets,
Moon,
Camera,
Mic,
Music,
Pill,
CircleDot,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMission } from '../lib/daily-missions';
import type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
/** The daily missions to display */
missions: DailyMission[];
/** Callback when claiming a mission reward */
onClaimReward: (missionId: string) => void;
/** Callback when rerolling a mission */
onRerollMission?: (missionId: string) => void;
/** Total coins earned today */
todayCoins: number;
/** Whether claiming is disabled (e.g., during another operation) */
disabled?: boolean;
/** Whether the bonus mission is available */
bonusAvailable?: boolean;
/** Whether the bonus mission has been claimed */
bonusClaimed?: boolean;
/** Bonus mission reward amount */
bonusReward?: number;
/** Whether user has no eligible missions (e.g., only eggs) */
noMissionsAvailable?: boolean;
/** Number of rerolls remaining today */
rerollsRemaining?: number;
/** Whether a reroll is currently in progress */
isRerolling?: boolean;
}
// ─── Mission Item ─────────────────────────────────────────────────────────────
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
interface MissionItemProps {
mission: DailyMission;
onClaim: () => void;
onReroll?: () => void;
disabled?: boolean;
canReroll?: boolean;
isRerolling?: boolean;
function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
const cls = 'size-5';
switch (action) {
case 'interact':
return <Heart className={cls} />;
case 'feed':
return <Utensils className={cls} />;
case 'clean':
return <Droplets className={cls} />;
case 'sleep':
return <Moon className={cls} />;
case 'take_photo':
return <Camera className={cls} />;
case 'sing':
return <Mic className={cls} />;
case 'play_music':
return <Music className={cls} />;
case 'medicine':
return <Pill className={cls} />;
default:
return <CircleDot className={cls} />;
}
}
function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) {
const progressPercent = (mission.currentCount / mission.requiredCount) * 100;
const canClaim = mission.completed && !mission.claimed;
// Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining
const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll;
// ─── Bonus Card ───────────────────────────────────────────────────────────────
return (
<div
className={cn(
'relative p-3 sm:p-4 rounded-lg border transition-colors overflow-hidden',
mission.claimed
? 'bg-primary/5 border-primary/20'
: mission.completed
? 'bg-green-500/5 border-green-500/30'
: 'bg-card border-border'
)}
>
{/* Top right area: Claimed badge OR Reroll button */}
<div className="absolute top-2 right-2">
{mission.claimed ? (
<div className="flex items-center gap-1 text-xs text-primary font-medium">
<Check className="size-3" />
Claimed
</div>
) : showRerollButton ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={onReroll}
disabled={disabled || isRerolling}
>
<RefreshCw className={cn("size-3.5", isRerolling && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace this mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
{/* Mission content */}
<div className="space-y-2 sm:space-y-3">
{/* Title and description */}
<div className="pr-14 sm:pr-16">
<h4 className="font-medium text-sm break-words">{mission.title}</h4>
<p className="text-xs text-muted-foreground mt-0.5 break-words">
{mission.description}
</p>
</div>
{/* Progress bar */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs gap-2">
<span className="text-muted-foreground whitespace-nowrap">
{mission.currentCount} / {mission.requiredCount}
</span>
<span className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400 whitespace-nowrap">
<Coins className="size-3 shrink-0" />
{formatCompactNumber(mission.reward)}
</span>
</div>
<Progress
value={progressPercent}
className={cn(
'h-2',
mission.completed && '[&>div]:bg-green-500'
)}
/>
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-green-600 hover:bg-green-700 text-white"
>
<Gift className="size-4 mr-2 shrink-0" />
<span className="truncate">Claim {formatCompactNumber(mission.reward)} Coins</span>
</Button>
)}
</div>
</div>
);
}
// ─── Bonus Mission Item ───────────────────────────────────────────────────────
interface BonusMissionItemProps {
interface BonusCardProps {
isAvailable: boolean;
isClaimed: boolean;
reward: number;
onClaim: () => void;
disabled?: boolean;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
return (
<div
className={cn(
'relative p-3 sm:p-4 rounded-lg border-2 transition-colors overflow-hidden',
isClaimed
? 'bg-amber-500/10 border-amber-500/30'
: isAvailable
? 'bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-500/40 animate-pulse'
: 'bg-muted/30 border-dashed border-muted-foreground/20'
)}
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isClaimed}
progress={progress}
isExpanded={isExpanded}
onToggle={onToggle}
>
{/* Claimed badge */}
{isClaimed && (
<div className="absolute top-2 right-2">
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
<Check className="size-3" />
Claimed
</div>
</div>
)}
<MissionDescription>
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions to unlock this bonus'}
</MissionDescription>
{/* Mission content */}
<div className="space-y-2 sm:space-y-3">
{/* Title and description */}
<div className={cn("pr-14 sm:pr-16", !isAvailable && !isClaimed && "opacity-50")}>
<div className="flex items-center gap-2">
<Trophy className={cn(
"size-4 shrink-0",
isClaimed
? "text-amber-600 dark:text-amber-400"
: isAvailable
? "text-amber-500"
: "text-muted-foreground"
)} />
<h4 className="font-medium text-sm">Daily Champion</h4>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions above to unlock this bonus'}
</p>
</div>
{/* Reward display */}
<div className="flex items-center justify-between text-xs gap-2">
<span className={cn(
"text-muted-foreground",
!isAvailable && !isClaimed && "opacity-50"
)}>
Bonus Reward
</span>
<span className={cn(
"flex items-center gap-1 font-medium",
isClaimed || isAvailable
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground"
)}>
<Coins className="size-3 shrink-0" />
+{formatCompactNumber(reward)}
</span>
</div>
{/* Claim button */}
{isAvailable && !isClaimed && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
<Trophy className="size-4 mr-2 shrink-0" />
<span className="truncate">Claim Bonus {formatCompactNumber(reward)} Coins!</span>
</Button>
)}
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
+{formatCompactNumber(reward)}
</div>
</div>
{isAvailable && !isClaimed && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
>
<Trophy className="size-3.5 mr-1.5" />
Claim Bonus {formatCompactNumber(reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
}
// ─── No Missions Available State ──────────────────────────────────────────────
// ─── Empty / Done States ──────────────────────────────────────────────────────
function NoMissionsState() {
return (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Egg className="size-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm">Hatch Your Blobbi First</h4>
<p className="text-xs text-muted-foreground">
Daily missions will be available once you have
<br />
a hatched Blobbi to interact with!
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Egg className="size-5 text-muted-foreground/50" />
<div>
<p className="text-sm font-medium">Hatch your Blobbi first</p>
<p className="text-xs text-muted-foreground mt-0.5">
Daily missions unlock after hatching
</p>
</div>
</div>
);
}
// ─── All Claimed State ────────────────────────────────────────────────────────
interface AllClaimedStateProps {
todayCoins: number;
}
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
return (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-6 text-primary" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm">All Done for Today!</h4>
<p className="text-xs text-muted-foreground">
You earned <span className="font-medium text-amber-600 dark:text-amber-400">{formatCompactNumber(todayCoins)} coins</span> today.
<br />
Come back tomorrow for new missions!
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-5 text-primary/60" />
<div>
<p className="text-sm font-medium">All done for today</p>
<p className="text-xs text-muted-foreground mt-0.5">
Earned{' '}
<span className="font-medium text-amber-600 dark:text-amber-400">
{formatCompactNumber(todayCoins)} coins
</span>{' '}
come back tomorrow!
</p>
</div>
</div>
@@ -288,20 +167,17 @@ function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
// ─── Reroll Counter ───────────────────────────────────────────────────────────
interface RerollCounterProps {
remaining: number;
}
function RerollCounter({ remaining }: { remaining: number }) {
const text =
remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
function RerollCounter({ remaining }: RerollCounterProps) {
const text = remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
return (
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
<RefreshCw className="size-3" />
<div className="flex items-center justify-end gap-1 text-[11px] text-muted-foreground col-span-full">
<RefreshCw className="size-2.5" />
<span>{text}</span>
</div>
);
@@ -322,48 +198,121 @@ export function DailyMissionsPanel({
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
// Show empty state if user has no eligible missions (e.g., only eggs)
if (noMissionsAvailable) {
return <NoMissionsState />;
}
const [expandedId, setExpandedId] = useState<string | null>(null);
if (noMissionsAvailable) return <NoMissionsState />;
const allRegularClaimed = missions.every((m) => m.claimed);
const allDone = allRegularClaimed && bonusClaimed;
// Show "all done" state only when everything including bonus is claimed
if (allDone) {
return <AllClaimedState todayCoins={todayCoins} />;
}
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<div className="space-y-3">
{/* Reroll counter - only show if reroll functionality is available */}
{onRerollMission && (
<RerollCounter remaining={rerollsRemaining} />
)}
{/* Regular missions */}
{missions.map((mission) => (
<MissionItem
key={mission.id}
mission={mission}
onClaim={() => onClaimReward(mission.id)}
onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined}
disabled={disabled}
canReroll={canReroll}
isRerolling={isRerolling}
/>
))}
{/* Bonus mission - always visible */}
<BonusMissionItem
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{/* Reroll counter */}
{onRerollMission && <RerollCounter remaining={rerollsRemaining} />}
{/* Regular mission cards */}
{missions.map((mission) => {
const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
const canClaim = mission.completed && !mission.claimed;
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
return (
<ExpandableMissionCard
key={mission.id}
id={mission.id}
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.claimed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
{/* Description */}
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.claimed && (
<MissionProgress
current={mission.currentCount}
required={mission.requiredCount}
completed={mission.completed}
/>
)}
{/* Reward + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
{formatCompactNumber(mission.reward)}
</span>
{showReroll && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onRerollMission(mission.id);
}}
disabled={disabled || isRerolling}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
>
<RefreshCw className={cn('size-3', isRerolling && 'animate-spin')} />
</button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{mission.claimed && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
</span>
)}
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onClaimReward(mission.id);
}}
disabled={disabled}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
>
<Gift className="size-3.5 mr-1.5" />
Claim {formatCompactNumber(mission.reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
})}
{/* Bonus card */}
<BonusCard
isAvailable={bonusAvailable}
isClaimed={bonusClaimed}
reward={bonusReward}
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
disabled={disabled}
isExpanded={expandedId === 'bonus'}
onToggle={handleToggle}
/>
</div>
);
@@ -0,0 +1,250 @@
// src/blobbi/actions/components/ExpandableMissionCard.tsx
/**
* Expandable mission card for the quest-board grid.
*
* Collapsed: compact square-ish card showing icon, title, and a tiny
* progress ring / checkmark.
* Expanded: full-width row that reveals description, progress bar,
* action link, claim button, dynamic hints, etc.
*
* Only one card is expanded at a time per section (controlled by parent).
*/
import type { ReactNode } from 'react';
import { Check, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export type MissionCategory = 'daily' | 'hatch' | 'evolve';
export interface ExpandableMissionCardProps {
/** Unique id used to track which card is expanded */
id: string;
/** Mission category for visual styling */
category: MissionCategory;
/** Icon rendered in the compact card (ReactNode — usually a lucide icon or emoji span) */
icon: ReactNode;
/** Short title */
title: string;
/** Whether the mission is complete */
completed: boolean;
/** Progress fraction 0-1 (used for the tiny ring in compact mode) */
progress: number;
/** Whether this card is currently expanded */
isExpanded: boolean;
/** Parent calls this to toggle expansion */
onToggle: (id: string) => void;
/** Content rendered only when expanded */
children: ReactNode;
/** Optional extra className on the outer wrapper */
className?: string;
}
// ─── Tiny Progress Ring ───────────────────────────────────────────────────────
function ProgressRing({ progress, completed, category }: { progress: number; completed: boolean; category: MissionCategory }) {
const size = 28;
const stroke = 2.5;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - progress * circumference;
if (completed) {
return (
<div className="size-7 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
</div>
);
}
const ringColor =
category === 'hatch'
? 'text-sky-500'
: category === 'evolve'
? 'text-violet-500'
: 'text-amber-500';
return (
<svg width={size} height={size} className={cn('shrink-0 -rotate-90', ringColor)}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
opacity={0.15}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
);
}
// ─── Accent colors per category ───────────────────────────────────────────────
const CATEGORY_STYLES: Record<MissionCategory, { bg: string; expandedBg: string; border: string }> = {
daily: {
bg: 'bg-amber-500/[0.06] hover:bg-amber-500/10',
expandedBg: 'bg-amber-500/[0.06]',
border: 'ring-amber-500/20',
},
hatch: {
bg: 'bg-sky-500/[0.06] hover:bg-sky-500/10',
expandedBg: 'bg-sky-500/[0.06]',
border: 'ring-sky-500/20',
},
evolve: {
bg: 'bg-violet-500/[0.06] hover:bg-violet-500/10',
expandedBg: 'bg-violet-500/[0.06]',
border: 'ring-violet-500/20',
},
};
// ─── Component ────────────────────────────────────────────────────────────────
export function ExpandableMissionCard({
id,
category,
icon,
title,
completed,
progress,
isExpanded,
onToggle,
children,
className,
}: ExpandableMissionCardProps) {
const styles = CATEGORY_STYLES[category];
// ── Collapsed card ──
if (!isExpanded) {
return (
<button
type="button"
onClick={() => onToggle(id)}
className={cn(
'flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all text-center cursor-pointer select-none',
'ring-1 ring-transparent',
completed ? 'bg-emerald-500/[0.06] hover:bg-emerald-500/10' : styles.bg,
className,
)}
>
{/* Icon */}
<div className="text-lg leading-none">{icon}</div>
{/* Title — 2 lines max */}
<span className={cn(
'text-[11px] font-medium leading-tight line-clamp-2 min-h-[2lh]',
completed && 'text-emerald-600 dark:text-emerald-400',
)}>
{title}
</span>
{/* Progress ring / check */}
<ProgressRing progress={progress} completed={completed} category={category} />
</button>
);
}
// ── Expanded card (spans full row) ──
return (
<div
className={cn(
'col-span-full rounded-xl ring-1 transition-all overflow-hidden',
completed ? 'bg-emerald-500/[0.06] ring-emerald-500/20' : cn(styles.expandedBg, styles.border),
className,
)}
>
{/* Compact header — click to collapse */}
<button
type="button"
onClick={() => onToggle(id)}
className="w-full flex items-center gap-3 p-3 text-left cursor-pointer select-none"
>
<div className="text-lg leading-none shrink-0">{icon}</div>
<span className={cn(
'text-sm font-medium flex-1 min-w-0',
completed && 'text-emerald-600 dark:text-emerald-400',
)}>
{title}
</span>
<ProgressRing progress={progress} completed={completed} category={category} />
</button>
{/* Expanded details */}
<div className="px-3 pb-3 pt-0 space-y-2">
{children}
</div>
</div>
);
}
// ─── Shared detail sub-components ─────────────────────────────────────────────
/** Description text */
export function MissionDescription({ children }: { children: ReactNode }) {
return <p className="text-xs text-muted-foreground leading-snug">{children}</p>;
}
/** Progress bar with fraction label */
export function MissionProgress({ current, required, completed }: { current: number; required: number; completed: boolean }) {
const pct = required > 0 ? Math.round((current / required) * 100) : 0;
return (
<div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
<span className="tabular-nums">{current} / {required}</span>
<span className="tabular-nums">{pct}%</span>
</div>
<Progress value={pct} className={cn('h-1.5', completed && '[&>div]:bg-emerald-500')} />
</div>
);
}
/** Inline action link (navigate, external, modal) */
export function MissionAction({
label,
type,
onClick,
}: {
label: string;
type: 'navigate' | 'external_link' | 'open_modal';
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{label}
{type === 'external_link' ? (
<ExternalLink className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</button>
);
}
/** Dynamic / live task hint */
export function DynamicHint({ current, required }: { current: number; required: number }) {
return (
<div className="flex items-center gap-1.5 text-[11px] text-amber-600/80 dark:text-amber-400/80">
<AlertCircle className="size-3 shrink-0" />
<span>Lowest stat: {current}% (need {required}%+)</span>
</div>
);
}
+149 -217
View File
@@ -1,22 +1,38 @@
// src/blobbi/actions/components/TasksPanel.tsx
/**
* Generic UI component for displaying task progress.
* Shows a list of tasks with progress indicators and action buttons.
* Used for both hatch and evolve tasks.
* Card-grid presentation for hatch / evolve tasks.
*
* Each task is a compact card in a 2-column grid.
* Tapping a card expands it inline (full row) to reveal details.
* Only one card is expanded at a time.
*/
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Palette,
Droplets,
MessageSquare,
Heart,
UserPen,
Activity,
Loader2,
HelpCircle,
} from 'lucide-react';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { HatchTask } from '../hooks/useHatchTasks';
import type { MissionCategory } from './ExpandableMissionCard';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
MissionAction,
DynamicHint,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -24,149 +40,38 @@ interface TasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks the complete button */
onComplete: () => void;
/** Whether completion is in progress */
isCompleting?: boolean;
/** Emoji to show in header */
emoji: string;
/** Title for the tasks panel */
title: string;
/** Description for the tasks panel */
description: string;
/** Label for the complete button */
completeLabel: string;
/** Label while completing */
completingLabel: string;
/** Emoji for complete button */
completeEmoji: string;
/** Mission category for styling the cards */
category?: MissionCategory;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
/** Map task ids to lucide icons. Falls back to a generic icon. */
function TaskIcon({ taskId }: { taskId: string }) {
const iconClass = 'size-5';
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const isDynamic = task.type === 'dynamic';
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
onOpenPostModal();
}
break;
}
};
const progress = task.required > 1
? Math.round((task.current / task.required) * 100)
: task.completed ? 100 : 0;
return (
<div
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border transition-all overflow-hidden",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: isDynamic
? "bg-amber-500/5 border-amber-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Top row on mobile: Status + Task info */}
<div className="flex items-start sm:items-center gap-3 sm:contents">
{/* Status indicator */}
<div className={cn(
"size-8 sm:size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: isDynamic
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-4 sm:size-5" />
) : isDynamic ? (
<AlertCircle className="size-4 sm:size-5" />
) : task.required > 1 ? (
<span className="text-xs sm:text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-base sm:text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mb-0.5 sm:mb-1">
<h4 className={cn(
"font-medium text-sm sm:text-base break-words",
task.completed && "text-emerald-600 dark:text-emerald-400",
isDynamic && !task.completed && "text-amber-600 dark:text-amber-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs shrink-0">
Complete
</Badge>
)}
{isDynamic && !task.completed && (
<Badge variant="secondary" className="bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs shrink-0">
Live
</Badge>
)}
</div>
<p className="text-xs sm:text-sm text-muted-foreground break-words">
{task.description}
</p>
{/* Progress bar for multi-step tasks (not for dynamic stat tasks) */}
{task.required > 1 && !task.completed && !isDynamic && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
{/* Dynamic task hint */}
{isDynamic && !task.completed && (
<p className="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">
Lowest stat: {task.current}% (need {task.required}%+)
</p>
)}
</div>
</div>
{/* Action button - full width on mobile when present */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2 w-full sm:w-auto mt-1 sm:mt-0"
>
<span className="truncate">{task.actionLabel}</span>
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
</Button>
)}
</div>
);
switch (taskId) {
case 'create_themes':
return <Palette className={iconClass} />;
case 'color_moments':
return <Droplets className={iconClass} />;
case 'create_posts':
return <MessageSquare className={iconClass} />;
case 'interactions':
return <Heart className={iconClass} />;
case 'edit_profile':
return <UserPen className={iconClass} />;
case 'maintain_stats':
return <Activity className={iconClass} />;
default:
return <HelpCircle className={iconClass} />;
}
}
// ─── Main Component ───────────────────────────────────────────────────────────
@@ -178,86 +83,113 @@ export function TasksPanel({
onOpenPostModal,
onComplete,
isCompleting = false,
emoji,
title,
description,
completeLabel,
completingLabel,
completeEmoji,
category = 'hatch',
}: TasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
const [expandedId, setExpandedId] = useState<string | null>(null);
const navigate = useNavigate();
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent overflow-hidden">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-start sm:items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<span className="text-xl sm:text-2xl shrink-0">{emoji}</span>
<span className="break-words">{title}</span>
</CardTitle>
<CardDescription className="text-xs sm:text-sm break-words">
{description}
</CardDescription>
</div>
<Badge variant="outline" className="text-sm sm:text-base px-2 sm:px-3 py-0.5 sm:py-1 shrink-0">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-3 sm:mt-4">
<div className="flex items-center justify-between text-xs sm:text-sm mb-2">
<span className="text-muted-foreground">Overall progress</span>
<span className="font-medium">{overallProgress}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
</CardHeader>
<CardContent className="space-y-2 sm:space-y-3 px-3 sm:px-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{tasks.map(task => (
<TaskRow
key={task.id}
task={task}
onOpenPostModal={onOpenPostModal}
/>
))}
{/* Complete button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<Button
onClick={onComplete}
disabled={isCompleting}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-xl">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
<div className="space-y-3">
{/* Card grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{tasks.map((task) => {
const isDynamic = task.type === 'dynamic';
const progress =
task.required > 0 ? task.current / task.required : task.completed ? 1 : 0;
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') onOpenPostModal();
break;
}
};
return (
<ExpandableMissionCard
key={task.id}
id={task.id}
category={category}
icon={<TaskIcon taskId={task.id} />}
title={task.name}
completed={task.completed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === task.id}
onToggle={handleToggle}
>
{/* Expanded content */}
<MissionDescription>{task.description}</MissionDescription>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !isDynamic && (
<MissionProgress
current={task.current}
required={task.required}
completed={task.completed}
/>
)}
{/* Dynamic stat hint */}
{isDynamic && !task.completed && (
<DynamicHint current={task.current} required={task.required} />
)}
{/* Action link */}
{task.action && task.actionLabel && !task.completed && (
<MissionAction
label={task.actionLabel}
type={task.action}
onClick={handleAction}
/>
)}
</ExpandableMissionCard>
);
})}
</div>
{/* CTA button when all tasks are done */}
{allCompleted && (
<Button
onClick={onComplete}
disabled={isCompleting}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white shadow-sm"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-lg">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
)}
</div>
);
}
+21 -15
View File
@@ -34,10 +34,10 @@ export const KIND_SHORT_TEXT_NOTE = 1;
export const HATCH_REQUIRED_INTERACTIONS = 7;
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
/** Prefix text for Blobbi hatch post */
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
// Legacy export for backwards compatibility
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
@@ -110,16 +110,28 @@ export interface HatchTasksResult {
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Build the required phrase for a hatch post.
* Format: "Posting to hatch {CapitalizedName} #blobbi"
*/
export function buildHatchPhrase(blobbiName: string): string {
const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`;
}
/**
* Check if a post is a valid Blobbi hatch post.
* Must contain the required prefix and all required hashtags including the Blobbi name.
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
* The user may add extra text before or after it.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
* @param blobbiName - The Blobbi's name
*/
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with prefix
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
const phrase = buildHatchPhrase(blobbiName);
// The phrase must appear somewhere in the content
if (!event.content.includes(phrase)) {
return false;
}
@@ -128,18 +140,12 @@ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
// All required hashtags must be present as t tags
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (!hasRequiredHashtags) {
return false;
}
// Blobbi name hashtag must also be present
const blobbiHashtag = sanitizeToHashtag(blobbiName);
return hashtags.includes(blobbiHashtag);
return hasRequiredHashtags;
}
// Legacy function name for backwards compatibility
+1
View File
@@ -57,6 +57,7 @@ export {
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
buildHatchPhrase,
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
+39 -11
View File
@@ -976,7 +976,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
event,
d,
currentCompanion: getTagValue(tags, 'current_companion'),
onboardingDone: parseBooleanTag(tags, 'onboarding_done', false),
onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false)
|| parseBooleanTag(tags, 'onboarding_done', false),
name: getTagValue(tags, 'name'),
has: getTagValues(tags, 'has'),
coins: parseNumericTag(tags, 'coins') ?? 0,
@@ -996,7 +997,7 @@ export function buildBlobbonautTags(pubkey: string): string[][] {
return [
['d', getCanonicalBlobbonautD(pubkey)],
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
['onboarding_done', 'false'],
['blobbi_onboarding_done', 'false'],
['pettingLevel', '0'],
];
}
@@ -1138,7 +1139,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
* These tags are controlled by the application and may be overwritten.
*/
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
'd', 'b', 'name', 'current_companion', 'onboarding_done', 'has', 'storage',
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
// Legacy player progress tags (preserved for compatibility)
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
@@ -1365,17 +1366,44 @@ export function profileNeedsPettingLevelNormalization(profile: BlobbonautProfile
}
/**
* Build updated tags for normalizing a profile to include pettingLevel.
* Preserves all existing tags and adds pettingLevel: 0 if missing.
* Check if a profile uses the legacy `onboarding_done` tag instead of the
* new `blobbi_onboarding_done` tag. Returns true if migration is needed.
*/
export function profileNeedsOnboardingTagMigration(profile: BlobbonautProfile): boolean {
const hasNewTag = profile.allTags.some(([name]) => name === 'blobbi_onboarding_done');
const hasOldTag = profile.allTags.some(([name]) => name === 'onboarding_done');
// Needs migration if: has old tag but not the new one
return !hasNewTag && hasOldTag;
}
/**
* Build updated tags for normalizing a profile.
* Handles:
* - Adding pettingLevel: 0 if missing
* - Migrating onboarding_done → blobbi_onboarding_done
*
* Preserves all existing tags except the ones being migrated.
*/
export function buildNormalizedProfileTags(profile: BlobbonautProfile): string[][] {
if (!profileNeedsPettingLevelNormalization(profile)) {
return profile.allTags;
let tags = profile.allTags;
let changed = false;
// Normalize pettingLevel
if (profileNeedsPettingLevelNormalization(profile)) {
tags = updateBlobbonautTags(tags, { pettingLevel: '0' });
changed = true;
}
return updateBlobbonautTags(profile.allTags, {
pettingLevel: '0',
});
// Migrate onboarding_done → blobbi_onboarding_done
if (profileNeedsOnboardingTagMigration(profile)) {
const oldValue = tags.find(([name]) => name === 'onboarding_done')?.[1] ?? 'false';
// Remove old tag, add new tag
tags = tags.filter(([name]) => name !== 'onboarding_done');
tags = updateBlobbonautTags(tags, { blobbi_onboarding_done: oldValue });
changed = true;
}
return changed ? tags : profile.allTags;
}
// ─── Query Helpers ────────────────────────────────────────────────────────────
+91 -2
View File
@@ -9,7 +9,7 @@
*/
import { useState, useCallback, useMemo } from 'react';
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
@@ -27,6 +27,18 @@ 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;
@@ -38,6 +50,8 @@ 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 */
@@ -170,6 +184,7 @@ export function BlobbiDevEditor({
companion,
onApply,
isUpdating = false,
tourDevActions,
}: BlobbiDevEditorProps) {
// ─── Local State ───
// Initialize from companion values
@@ -527,8 +542,82 @@ export function BlobbiDevEditor({
onCheckedChange={setBreedingReady}
/>
</div>
</div>
</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">
+283 -140
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import type { EggVisualBlobbi } from '../types/egg.types';
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
@@ -25,6 +25,29 @@ export interface EggStatusEffects {
happy?: boolean;
}
/**
* Tour visual states that the egg can display.
* Driven by the tour orchestration layer, not by EggGraphic itself.
*
* - idle: no tour effects
* - show_hatch_card: initial crack visible + auto-wiggle every 2.5s
* - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap
* - crack_stage_1: crack expands (click 1)
* - crack_stage_2: crack expands more (click 2)
* - crack_stage_3: final crack (click 3)
* - opening: shell splits open
* - hatching: bright light + reveal
*/
export type EggTourVisualState =
| 'idle'
| 'show_hatch_card'
| 'glowing_waiting_click'
| 'crack_stage_1'
| 'crack_stage_2'
| 'crack_stage_3'
| 'opening'
| 'hatching';
interface EggGraphicProps {
blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties
sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size
@@ -36,6 +59,10 @@ interface EggGraphicProps {
forceInlineSvg?: boolean; // New prop to guarantee inline SVG
/** Status effects for egg-stage visual feedback */
statusEffects?: EggStatusEffects;
/** Tour visual state - driven externally by the tour orchestration layer */
tourVisualState?: EggTourVisualState;
/** Callback when the egg is clicked during an interactive tour step */
onTourEggClick?: () => void;
}
/**
@@ -114,6 +141,8 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
warmth = 50,
forceInlineSvg: _forceInlineSvg = false,
statusEffects,
tourVisualState = 'idle',
onTourEggClick,
}) => {
// sizeVariant controls ONLY internal scaling/details, NOT layout dimensions
// Parent container controls actual rendered width/height via slot
@@ -152,14 +181,62 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
const [isTapWiggling, setIsTapWiggling] = useState(false);
const handleEggClick = useCallback(() => {
// Tour interactive steps: forward click to tour controller
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
setIsTapWiggling(true);
onTourEggClick();
return;
}
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
setIsTapWiggling(true);
}, [isTapWiggling, cracking]);
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
const handleWiggleEnd = useCallback(() => {
setIsTapWiggling(false);
}, []);
// Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states
const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click';
const autoWiggleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!shouldAutoWiggle) {
if (autoWiggleTimerRef.current) {
clearInterval(autoWiggleTimerRef.current);
autoWiggleTimerRef.current = null;
}
return;
}
// Trigger an immediate wiggle, then repeat every 2.5s
setIsTapWiggling(true);
autoWiggleTimerRef.current = setInterval(() => {
setIsTapWiggling((prev) => {
if (!prev) return true;
return prev;
});
}, 2500);
return () => {
if (autoWiggleTimerRef.current) {
clearInterval(autoWiggleTimerRef.current);
autoWiggleTimerRef.current = null;
}
};
}, [shouldAutoWiggle]);
// Tour: whether the egg should show crack overlay
// The crack stays visible during 'opening' so the shell fades out WITH its cracks intact.
// Only 'idle' and 'hatching' (shell already gone) hide the crack.
const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching';
// Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding)
// Level 0: small central crack (show_hatch_card, glowing_waiting_click)
// Level 1: crack expands left/right with small branches (crack_stage_1)
// Level 2: crack expands further toward edges, more branches (crack_stage_2)
// Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening)
const tourCrackLevel = tourVisualState === 'crack_stage_1' ? 1
: tourVisualState === 'crack_stage_2' ? 2
: (tourVisualState === 'crack_stage_3' || tourVisualState === 'opening') ? 3
: 0;
// Divine color constants
const DIVINE_PRIMARY_GREEN = '#55C4A2';
const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9';
@@ -440,18 +517,32 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
}}
>
{/* Glow effect based on warmth - relative sizing */}
<div
className={cn(
'absolute rounded-full blur-xl transition-all duration-1000',
animated && 'animate-pulse'
)}
style={{
width: '120%',
height: '120%',
background: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
zIndex: 0,
}}
/>
{(() => {
const isGlowingTour = tourVisualState === 'glowing_waiting_click'
|| tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2'
|| tourVisualState === 'crack_stage_3';
const isHatchLight = tourVisualState === 'opening' || tourVisualState === 'hatching';
return (
<div
className={cn(
'absolute rounded-full blur-xl transition-all duration-1000',
animated && !isGlowingTour && !isHatchLight && 'animate-pulse',
isGlowingTour && 'animate-egg-tour-glow',
isHatchLight && 'animate-egg-tour-glow',
)}
style={{
width: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
height: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
background: isHatchLight
? `radial-gradient(circle, #fff 0%, ${glowColor} 40%, transparent 70%)`
: isGlowingTour
? `radial-gradient(circle, ${glowColor} 0%, ${glowColor}80 30%, transparent 70%)`
: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
zIndex: 0,
}}
/>
);
})()}
{/* Main egg shape - uses percentage-based sizing */}
<div
@@ -468,8 +559,12 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
!isTapWiggling && reaction === 'singing' && 'animate-egg-bounce',
// Warmth effect only when animated AND warm
animated && actualWarmth > 60 && 'animate-egg-warmth',
// Cracking overrides other animations
cracking && 'animate-egg-crack'
// Cracking overrides other animations (legacy prop or tour crack stages)
// During 'opening' the shell runs its own open animation, so suppress the shake
(cracking || (tourCrackLevel >= 1 && tourVisualState !== 'opening')) && 'animate-egg-crack',
// Opening/hatching: fade out the egg shell (crack overlay stays inside and fades with it)
tourVisualState === 'opening' && 'animate-egg-tour-open',
tourVisualState === 'hatching' && 'opacity-0',
)}
style={{
width: '80%',
@@ -480,7 +575,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
inset -0.5em -0.5em 1em ${shadow}33,
inset 0.5em 0.5em 1em ${highlight}26
`,
filter: cracking ? 'brightness(1.1)' : 'brightness(1)',
filter: (cracking || tourCrackLevel >= 1) ? 'brightness(1.1)' : 'brightness(1)',
}}
>
{/* Highlight on the egg - uses color variants instead of white */}
@@ -538,133 +633,181 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
renderLegacySpecialMark(effectiveSpecialMark)
))}
{/* Crack pattern based on docs/aprovado.svg when cracking is true */}
{cracking && (
<svg
className="absolute inset-0 pointer-events-none w-full h-full"
viewBox="0 0 120 125"
preserveAspectRatio="xMidYMid meet"
style={{
height: '100%',
}}
>
{/* Main horizontal crack (adapted from aprovado.svg) */}
<path
d="M10 62
L20 60
L30 64
L40 59
L50 65
L60 58
L70 66
L80 57
L90 67
L100 59
L110 65"
stroke="rgba(0, 0, 0, 0.6)"
strokeWidth="2"
fill="none"
strokeLinecap="round"
/>
{/* Crack pattern - stage-specific paths that grow outward from center */}
{(cracking || tourShowCrack) && (() => {
// Legacy cracking shows full crack; tour uses progressive stage-specific paths
const level = cracking ? 3 : tourCrackLevel;
return (
<svg
className="absolute inset-0 pointer-events-none w-full h-full transition-opacity duration-300"
viewBox="0 0 120 125"
preserveAspectRatio="xMidYMid meet"
style={{ height: '100%' }}
>
{/*
Stage-specific crack paths.
Each level has its OWN distinct paths that expand outward from the egg center.
The crack grows from a small central cluster to full-width fracture.
{/* Secondary cracks (adapted from aprovado.svg) */}
<path
d="M30 64 L28 70"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M50 65 L53 71"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M60 58 L57 52"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M80 57 L82 50"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M90 67 L95 72"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M100 59 L97 53"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M110 65 L113 69"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
Viewbox center is roughly (60, 62).
Level 0: tiny central crack (~3-4 small connected segments near center)
Level 1: extends left/right from center, first branches
Level 2: reaches further toward edges, more fracture detail
Level 3: crack reaches near shell edges, dense branching
*/}
{/* Additional micro-cracks for detail */}
<path
d="M40 59 L38 55"
stroke="rgba(0, 0, 0, 0.25)"
strokeWidth="0.8"
strokeLinecap="round"
/>
<path
d="M70 66 L73 70"
stroke="rgba(0, 0, 0, 0.25)"
strokeWidth="0.8"
strokeLinecap="round"
/>
<path
d="M20 60 L18 56"
stroke="rgba(0, 0, 0, 0.2)"
strokeWidth="0.6"
strokeLinecap="round"
/>
{/* ── Level 0: Small central crack ── */}
{/* A few short connected segments clustered around the center of the egg */}
{level === 0 && (<>
{/* Main tiny crack: ~15px wide, centered */}
<path
d="M53 63 L57 60 L63 64 L67 61"
stroke="rgba(0, 0, 0, 0.5)"
strokeWidth="1.2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Tiny upward branch from center */}
<path
d="M57 60 L56 57"
stroke="rgba(0, 0, 0, 0.35)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Tiny downward branch */}
<path
d="M63 64 L65 67"
stroke="rgba(0, 0, 0, 0.35)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Subtle highlight alongside main crack */}
<path
d="M54 64 L58 61 L64 65"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.6"
fill="none"
strokeLinecap="round"
/>
</>)}
{/* Crack highlights for depth (following the main crack pattern) */}
<path
d="M10 63
L20 61
L30 65
L40 60
L50 66
L60 59
L70 67
L80 58
L90 68
L100 60
L110 66"
stroke="rgba(255, 255, 255, 0.15)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* ── Level 1: Medium crack expanding from center ── */}
{/* Crack extends ~30px wide, first real branches appear */}
{level === 1 && (<>
{/* Main crack: wider than level 0, extends left and right */}
<path
d="M42 61 L48 64 L53 60 L60 65 L67 59 L73 63 L78 60"
stroke="rgba(0, 0, 0, 0.55)"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M43 62 L49 65 L54 61 L61 66 L68 60 L74 64"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.6"
fill="none"
strokeLinecap="round"
/>
{/* Branch: upward left */}
<path d="M48 64 L46 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branch: upward from center-right */}
<path d="M67 59 L65 54" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branch: downward right */}
<path d="M73 63 L76 68" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
{/* Small micro-branch */}
<path d="M53 60 L51 56" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.7" strokeLinecap="round" />
</>)}
{/* Secondary crack highlights */}
<path
d="M30 65 L28 71"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth="0.4"
strokeLinecap="round"
/>
<path
d="M60 59 L57 53"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth="0.4"
strokeLinecap="round"
/>
</svg>
)}
{/* ── Level 2: Larger crack reaching toward sides ── */}
{/* Crack extends ~60px wide, more branching detail */}
{level === 2 && (<>
{/* Main crack: extends well toward both sides */}
<path
d="M30 63 L37 60 L44 65 L52 59 L60 64 L68 58 L76 63 L83 59 L90 64"
stroke="rgba(0, 0, 0, 0.6)"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M31 64 L38 61 L45 66 L53 60 L61 65 L69 59 L77 64 L84 60"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.7"
fill="none"
strokeLinecap="round"
/>
{/* Branches: left side */}
<path d="M37 60 L34 55" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
<path d="M44 65 L41 71" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branches: center */}
<path d="M52 59 L50 53" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
<path d="M60 64 L63 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branches: right side */}
<path d="M68 58 L66 52" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
<path d="M76 63 L79 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
<path d="M83 59 L86 54" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
{/* Micro-cracks */}
<path d="M50 53 L48 50" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M63 70 L66 73" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
</>)}
{/* ── Level 3: Full crack reaching shell edges ── */}
{/* Crack spans nearly the full width, dense fracture network */}
{level >= 3 && (<>
{/* Main crack: nearly full width of egg */}
<path
d="M15 62 L23 59 L32 64 L40 58 L50 65 L60 57 L70 64 L80 58 L88 63 L96 59 L105 64"
stroke="rgba(0, 0, 0, 0.65)"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M16 63 L24 60 L33 65 L41 59 L51 66 L61 58 L71 65 L81 59 L89 64 L97 60"
stroke="rgba(255, 255, 255, 0.13)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Heavy branches: left region */}
<path d="M23 59 L19 53" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M32 64 L28 72" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M28 72 L25 76" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.9" strokeLinecap="round" />
{/* Heavy branches: center-left */}
<path d="M40 58 L37 51" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M50 65 L47 73" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M37 51 L35 47" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
{/* Heavy branches: center */}
<path d="M60 57 L58 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M60 57 L63 68" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
{/* Heavy branches: center-right */}
<path d="M70 64 L73 71" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M80 58 L83 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M83 50 L86 46" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
{/* Heavy branches: right region */}
<path d="M88 63 L91 70" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M96 59 L99 52" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M105 64 L109 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
{/* Micro-cracks (tertiary detail) */}
<path d="M47 73 L44 77" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M73 71 L76 75" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M58 50 L55 46" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M19 53 L17 49" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
<path d="M99 52 L102 48" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
</>)}
</svg>
);
})()}
{/* Title display for special eggs */}
{blobbi?.title && (
+1 -1
View File
@@ -12,7 +12,7 @@
import './styles/egg-animations.css';
// Components
export { EggGraphic, type EggReactionState, type EggStatusEffects } from './components/EggGraphic';
export { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from './components/EggGraphic';
export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer';
// Hooks
+46 -1
View File
@@ -320,6 +320,49 @@
transform: translateZ(0);
}
/* ==========================================
Tour Visual State Animations
========================================== */
/* Shell opening: scale up slightly then fade out with blur */
@keyframes egg-tour-open {
0% {
transform: scale(1);
opacity: 1;
filter: brightness(1.1);
}
40% {
transform: scale(1.05);
opacity: 0.9;
filter: brightness(1.4);
}
100% {
transform: scale(1.15);
opacity: 0;
filter: brightness(2) blur(4px);
}
}
.animate-egg-tour-open {
animation: egg-tour-open 1.2s ease-in-out forwards;
}
/* Pulsing glow for the "waiting for click" tour state */
@keyframes egg-tour-glow {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
.animate-egg-tour-glow {
animation: egg-tour-glow 2s ease-in-out infinite;
}
/* ==========================================
Responsive adjustments
========================================== */
@@ -351,7 +394,9 @@
.animate-egg-sweat-drop,
.animate-egg-dust-particle,
.animate-egg-spiral,
.animate-egg-sparkle {
.animate-egg-sparkle,
.animate-egg-tour-glow,
.animate-egg-tour-open {
animation: none !important;
}
}
@@ -456,15 +456,18 @@ export function useBlobbiOnboarding({
updateCompanionEvent(eggEvent);
// 2. Update profile: deduct coins, add to has, set current_companion
// 2. Update profile: deduct coins, add to has list
// NOTE: We do NOT set current_companion here because the adopted Blobbi
// is still an egg. The companion mechanic only becomes available after hatching.
// Eggs should never be auto-assigned as the floating companion.
// NOTE: blobbi_onboarding_done is NOT set here — adoption alone does not
// complete onboarding. It is set when the first-hatch tour finishes.
const newCoins = coins - BLOBBI_ADOPTION_COST;
const newHas = [...profile.has, preview.d];
const profileUpdates: Record<string, string | string[]> = {
coins: newCoins.toString(),
has: newHas,
current_companion: preview.d,
onboarding_done: 'true',
};
const updatedProfileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
@@ -0,0 +1,133 @@
/**
* 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>
);
}
@@ -0,0 +1,119 @@
/**
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
*
* Tells the user their egg is about to hatch and guides them to create a post.
* Contains a single mission: create the hatch post.
*/
import { Egg, Send, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Advance the tour (called after post is confirmed complete) */
onContinue: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourModal({
open,
onOpenChange,
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
onContinue,
}: FirstHatchTourModalProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return (
<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>
);
}
+226
View File
@@ -0,0 +1,226 @@
/**
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
*
* Orchestration only -- no rendering, no animations.
* The hook manages:
* - Ordered step progression
* - Persisted state via localStorage (survives refresh / close)
* - Derived booleans for UI consumption
* - Safe advance / goTo / complete / reset actions
*
* Activation is handled separately by useFirstHatchTourActivation,
* which calls `start()` when all preconditions are met.
*
* ────────────────────────────────────────────────────────────────
* Future integration points
* ────────────────────────────────────────────────────────────────
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
* to decide whether to start the tour.
* 2. UI components read `state.currentStepId` and render overlays,
* spotlights, modals, or animation cues accordingly.
* 3. Animation components call `actions.advance()` when their
* sequence finishes (for autoAdvance steps).
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
* on the user interaction.
* 5. EggGraphic receives a visual-state prop derived from
* `state.currentStepId` -- it does NOT own the tour logic.
*/
import { useMemo, useCallback, useRef } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
type FirstHatchTourStepId,
type FirstHatchTourPersistedState,
type TourState,
type TourActions,
} from '../lib/tour-types';
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* localStorage key for the first hatch tour state.
* Not user-scoped because onboarding state is device-local and the tour
* is inherently tied to "first ever egg on this device". If multi-user
* support on the same device becomes a concern, scope by pubkey.
*/
const STORAGE_KEY = 'blobbi:tour:first-hatch';
/** Pre-computed lookup: stepId -> index */
const STEP_INDEX_MAP = new Map<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,
};
}
@@ -0,0 +1,142 @@
/**
* 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 = false,
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
// ── Precondition evaluation ──
const { shouldActivate, isEligible } = useMemo(() => {
// Can't evaluate until data is loaded
if (isLoading) {
return { shouldActivate: false, isEligible: false };
}
// Already completed — profile tag is the authoritative source,
// localStorage is a secondary fallback
if (profileOnboardingDone || tour.state.isCompleted) {
return { shouldActivate: false, isEligible: false };
}
// Must have exactly 1 companion
if (companions.length !== 1) {
return { shouldActivate: false, isEligible: false };
}
const onlyBlobbi = companions[0];
// That companion must be an egg
if (onlyBlobbi.stage !== 'egg') {
return { shouldActivate: false, isEligible: false };
}
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
// but kept explicit for clarity and future-proofing if rules change)
const hasBabyOrAdult = companions.some(
(c) => c.stage === 'baby' || c.stage === 'adult',
);
if (hasBabyOrAdult) {
return { shouldActivate: false, isEligible: false };
}
// All preconditions met
const eligible = true;
// Only activate if the tour is not already running
const activate = !tour.state.isActive;
return { shouldActivate: activate, isEligible: eligible };
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive, profileOnboardingDone]);
// ── 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 };
}
+46
View File
@@ -0,0 +1,46 @@
/**
* 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';
+140
View File
@@ -0,0 +1,140 @@
/**
* 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,
};
+10 -2
View File
@@ -13,7 +13,7 @@
import { useMemo } from 'react';
import { EggGraphic, type EggReactionState, type EggStatusEffects } from '@/blobbi/egg';
import { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from '@/blobbi/egg';
import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
@@ -23,7 +23,7 @@ import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
export type BlobbiEggSize = 'sm' | 'md' | 'lg';
// Re-export for convenience
export type { EggReactionState, EggStatusEffects } from '@/blobbi/egg';
export type { EggReactionState, EggStatusEffects, EggTourVisualState } from '@/blobbi/egg';
export interface BlobbiEggVisualProps {
/** The Blobbi companion data from parseBlobbiEvent */
@@ -36,6 +36,10 @@ export interface BlobbiEggVisualProps {
reaction?: EggReactionState;
/** Status effects for egg visual feedback (dirty, sick, happy) */
statusEffects?: EggStatusEffects;
/** Tour visual state - driven externally by the tour orchestration layer */
tourVisualState?: EggTourVisualState;
/** Callback when the egg is clicked during an interactive tour step */
onTourEggClick?: () => void;
/** Additional CSS classes for the container */
className?: string;
}
@@ -70,6 +74,8 @@ export function BlobbiEggVisual({
animated = false,
reaction = 'idle',
statusEffects,
tourVisualState,
onTourEggClick,
className,
}: BlobbiEggVisualProps) {
// Memoize adapter output to avoid unnecessary re-renders
@@ -103,6 +109,8 @@ export function BlobbiEggVisual({
animated={animated && !isSleeping}
reaction={effectiveReaction}
statusEffects={isSleeping ? undefined : statusEffects}
tourVisualState={tourVisualState}
onTourEggClick={onTourEggClick}
/>
</div>
);
+9 -1
View File
@@ -12,7 +12,7 @@
import { useMemo } from 'react';
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects } from './BlobbiEggVisual';
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects, type EggTourVisualState } from './BlobbiEggVisual';
import { BlobbiBabyVisual } from './BlobbiBabyVisual';
import { BlobbiAdultVisual } from './BlobbiAdultVisual';
import { FloatingMusicNotes } from './FloatingMusicNotes';
@@ -50,6 +50,10 @@ export interface BlobbiStageVisualProps {
* Status-reaction body effects are already in the recipe.
*/
bodyEffects?: BodyEffectsSpec;
/** Tour visual state for egg stage - driven by the tour orchestration layer */
tourVisualState?: EggTourVisualState;
/** Callback when the egg is clicked during an interactive tour step */
onTourEggClick?: () => void;
className?: string;
}
@@ -74,6 +78,8 @@ export function BlobbiStageVisual({
recipeLabel,
emotion = 'neutral',
bodyEffects,
tourVisualState,
onTourEggClick,
className,
}: BlobbiStageVisualProps) {
const { stage } = companion;
@@ -109,6 +115,8 @@ export function BlobbiStageVisual({
animated={animated}
reaction={effectiveReaction}
statusEffects={eggStatusEffects}
tourVisualState={tourVisualState}
onTourEggClick={onTourEggClick}
className="size-full"
/>
<FloatingMusicNotes active={showMusicNotes} />
@@ -0,0 +1,208 @@
/**
* ActionBarEditor - Lightweight modal for customizing the bottom action bar.
*
* Rules:
* - Main Action + More are fixed (always shown, not editable)
* - Up to 3 custom visible slots
* - User can toggle visibility, reorder, and highlight one item
*/
import { useCallback } from 'react';
import {
ChevronUp,
ChevronDown,
Eye,
EyeOff,
Star,
Egg,
Target,
Package,
Camera,
Footprints,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import {
type ActionBarPreferences,
type BarItemId,
BAR_ITEM_LABELS,
MAX_VISIBLE_SLOTS,
toggleSlotVisibility,
toggleSlotHighlight,
moveSlotUp,
moveSlotDown,
visibleCount,
DEFAULT_PREFERENCES,
} from '../lib/action-bar-preferences';
// ─── Icon Mapping ─────────────────────────────────────────────────────────────
const BAR_ITEM_ICONS: Record<BarItemId, React.ReactNode> = {
blobbies: <Egg className="size-4" />,
missions: <Target className="size-4" />,
items: <Package className="size-4" />,
take_photo: <Camera className="size-4" />,
set_companion: <Footprints className="size-4" />,
};
// ─── Props ────────────────────────────────────────────────────────────────────
interface ActionBarEditorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
preferences: ActionBarPreferences;
onUpdate: (prefs: ActionBarPreferences) => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ActionBarEditor({
open,
onOpenChange,
preferences,
onUpdate,
}: ActionBarEditorProps) {
const currentVisible = visibleCount(preferences);
const atMax = currentVisible >= MAX_VISIBLE_SLOTS;
const handleToggle = useCallback(
(id: BarItemId) => onUpdate(toggleSlotVisibility(preferences, id)),
[preferences, onUpdate],
);
const handleHighlight = useCallback(
(id: BarItemId) => onUpdate(toggleSlotHighlight(preferences, id)),
[preferences, onUpdate],
);
const handleUp = useCallback(
(id: BarItemId) => onUpdate(moveSlotUp(preferences, id)),
[preferences, onUpdate],
);
const handleDown = useCallback(
(id: BarItemId) => onUpdate(moveSlotDown(preferences, id)),
[preferences, onUpdate],
);
const handleReset = useCallback(
() => onUpdate(DEFAULT_PREFERENCES),
[onUpdate],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle className="text-base">Edit Action Bar</DialogTitle>
<DialogDescription className="text-xs">
Choose up to {MAX_VISIBLE_SLOTS} items. Main Action and More are always shown.
</DialogDescription>
</DialogHeader>
<div className="space-y-1 py-2">
{preferences.slots.map((slot, idx) => {
const isFirst = idx === 0;
const isLast = idx === preferences.slots.length - 1;
const canTurnOn = slot.visible || !atMax;
return (
<div
key={slot.id}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 transition-colors',
slot.visible
? 'bg-accent/60'
: 'bg-muted/30 opacity-60',
)}
>
{/* Icon + Label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{BAR_ITEM_ICONS[slot.id]}
<span className="text-sm font-medium truncate">
{BAR_ITEM_LABELS[slot.id]}
</span>
</div>
{/* Highlight toggle */}
{slot.visible && (
<Button
variant="ghost"
size="icon"
className={cn('size-7', slot.highlighted && 'text-amber-500')}
onClick={() => handleHighlight(slot.id)}
title={slot.highlighted ? 'Remove highlight' : 'Highlight'}
>
<Star className={cn('size-3.5', slot.highlighted && 'fill-current')} />
</Button>
)}
{/* Reorder controls */}
<div className="flex flex-col">
<Button
variant="ghost"
size="icon"
className="size-5"
disabled={isFirst}
onClick={() => handleUp(slot.id)}
>
<ChevronUp className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-5"
disabled={isLast}
onClick={() => handleDown(slot.id)}
>
<ChevronDown className="size-3" />
</Button>
</div>
{/* Visibility toggle */}
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={!canTurnOn && !slot.visible}
onClick={() => handleToggle(slot.id)}
title={slot.visible ? 'Hide' : 'Show'}
>
{slot.visible ? (
<Eye className="size-3.5" />
) : (
<EyeOff className="size-3.5" />
)}
</Button>
</div>
);
})}
</div>
{/* Slot counter + reset */}
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-muted-foreground">
{currentVisible}/{MAX_VISIBLE_SLOTS} slots used
</span>
<Button
variant="ghost"
size="sm"
className="text-xs h-7"
onClick={handleReset}
>
Reset to default
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,301 @@
/**
* MissionSurfaceCard - Compact inline card that surfaces ONE relevant
* mission/task at a time below the Blobbi visual.
*
* Priority:
* 1. Hatch / Evolve tasks (lifecycle progression)
* 2. Daily missions (engagement / coin loop)
*
* Carousel:
* - Auto-rotates every ~5s when > 1 card available
* - Manual tap cycles to the next card
* - Auto-advances when the current card's mission completes
* - Single card = no rotation
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Target,
ChevronRight,
Egg,
Sparkles,
Coins,
CircleDot,
X,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import type { HatchTask } from '@/blobbi/actions/hooks/useHatchTasks';
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
// ─── Card Item Types ──────────────────────────────────────────────────────────
interface TaskCardItem {
kind: 'task';
badge: 'Hatch' | 'Evolve';
title: string;
description: string;
progress: number; // 0-100
progressLabel: string;
}
interface DailyCardItem {
kind: 'daily';
badge: 'Daily';
title: string;
description: string;
progress: number;
progressLabel: string;
reward: number;
claimed: boolean;
}
type CardItem = TaskCardItem | DailyCardItem;
// ─── Props ────────────────────────────────────────────────────────────────────
interface MissionSurfaceCardProps {
/** Hatch or evolve tasks (from useActiveTaskProcess) */
tasks: HatchTask[];
/** Whether a task process (incubating/evolving) is active */
isInTaskProcess: boolean;
/** Process type for badge label */
processType: 'hatch' | 'evolve' | null;
/** Daily missions */
dailyMissions: DailyMission[];
/** Called when user taps "View all" */
onViewAll: () => void;
/** Called when user dismisses the card */
onHide?: () => void;
/** Additional className */
className?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function buildTaskCards(
tasks: HatchTask[],
processType: 'hatch' | 'evolve' | null,
): TaskCardItem[] {
if (!processType) return [];
const badge = processType === 'hatch' ? 'Hatch' : 'Evolve';
// Show only incomplete tasks, or the first completed one if all are done
const incomplete = tasks.filter((t) => !t.completed);
const toShow = incomplete.length > 0 ? incomplete : tasks.slice(0, 1);
return toShow.map((t) => ({
kind: 'task',
badge: badge as 'Hatch' | 'Evolve',
title: t.name,
description: t.description,
progress: t.required > 0 ? Math.min(100, Math.round((t.current / t.required) * 100)) : 0,
progressLabel: `${t.current}/${t.required}`,
}));
}
function buildDailyCards(missions: DailyMission[]): DailyCardItem[] {
// Show unclaimed missions first, then claimed ones
const unclaimed = missions.filter((m) => !m.claimed);
const toShow = unclaimed.length > 0 ? unclaimed : [];
return toShow.map((m) => ({
kind: 'daily',
badge: 'Daily',
title: m.title,
description: m.description,
progress: m.requiredCount > 0
? Math.min(100, Math.round((m.currentCount / m.requiredCount) * 100))
: 0,
progressLabel: `${m.currentCount}/${m.requiredCount}`,
reward: m.reward,
claimed: m.claimed,
}));
}
// ─── Auto-rotate interval ─────────────────────────────────────────────────────
const ROTATE_INTERVAL_MS = 5000;
// ─── Component ────────────────────────────────────────────────────────────────
export function MissionSurfaceCard({
tasks,
isInTaskProcess,
processType,
dailyMissions,
onViewAll,
onHide,
className,
}: MissionSurfaceCardProps) {
const [activeIndex, setActiveIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
// Build card list: tasks first (priority), then daily
const cards = useMemo<CardItem[]>(() => {
const taskCards = isInTaskProcess ? buildTaskCards(tasks, processType) : [];
const dailyCards = buildDailyCards(dailyMissions);
return [...taskCards, ...dailyCards];
}, [tasks, isInTaskProcess, processType, dailyMissions]);
// Clamp index if cards shrink
useEffect(() => {
if (activeIndex >= cards.length && cards.length > 0) {
setActiveIndex(0);
}
}, [cards.length, activeIndex]);
// Auto-rotate (only when > 1 card)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (cards.length <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
return;
}
timerRef.current = setInterval(() => {
setIsAnimating(true);
setTimeout(() => {
setActiveIndex((prev) => (prev + 1) % cards.length);
setIsAnimating(false);
}, 150);
}, ROTATE_INTERVAL_MS);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [cards.length]);
// Manual cycle
const handleCycle = useCallback(() => {
if (cards.length <= 1) return;
// Reset auto-rotate timer
if (timerRef.current) clearInterval(timerRef.current);
setIsAnimating(true);
setTimeout(() => {
setActiveIndex((prev) => (prev + 1) % cards.length);
setIsAnimating(false);
// Restart timer
timerRef.current = setInterval(() => {
setIsAnimating(true);
setTimeout(() => {
setActiveIndex((prev) => (prev + 1) % cards.length);
setIsAnimating(false);
}, 150);
}, ROTATE_INTERVAL_MS);
}, 150);
}, [cards.length]);
// Nothing to show
if (cards.length === 0) return null;
const card = cards[Math.min(activeIndex, cards.length - 1)];
const badgeColor =
card.badge === 'Hatch'
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
: card.badge === 'Evolve'
? 'bg-purple-500/15 text-purple-600 dark:text-purple-400'
: 'bg-primary/10 text-primary';
const badgeIcon =
card.badge === 'Hatch' ? (
<Egg className="size-3" />
) : card.badge === 'Evolve' ? (
<Sparkles className="size-3" />
) : (
<Target className="size-3" />
);
return (
<div className={cn('w-full', className)}>
<button
onClick={handleCycle}
className={cn(
'w-full text-left rounded-xl border border-border/60 bg-card/80 backdrop-blur-sm',
'px-3.5 py-2.5 transition-all duration-200',
'hover:bg-accent/40 active:scale-[0.99]',
isAnimating && 'opacity-0 translate-x-2',
!isAnimating && 'opacity-100 translate-x-0',
)}
>
{/* Top row: badge + title + view all */}
<div className="flex items-center gap-2 mb-1.5">
<Badge
variant="secondary"
className={cn('text-[10px] font-medium px-1.5 py-0 h-4 gap-1', badgeColor)}
>
{badgeIcon}
{card.badge}
</Badge>
<span className="text-sm font-medium truncate flex-1">
{card.title}
</span>
{/* Dot indicators when multiple cards */}
{cards.length > 1 && (
<div className="flex gap-0.5 items-center shrink-0">
{cards.map((_, i) => (
<CircleDot
key={i}
className={cn(
'size-2 transition-colors',
i === activeIndex
? 'text-primary'
: 'text-muted-foreground/30',
)}
/>
))}
</div>
)}
{/* Dismiss button */}
{onHide && (
<button
onClick={(e) => {
e.stopPropagation();
onHide();
}}
className="shrink-0 p-0.5 -m-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground transition-colors"
title="Hide mission card"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mb-2 line-clamp-1">
{card.description}
</p>
{/* Bottom row: progress bar + label + reward/view all */}
<div className="flex items-center gap-2">
<Progress
value={card.progress}
className="h-1.5 flex-1"
/>
<span className="text-[10px] text-muted-foreground font-mono shrink-0">
{card.progressLabel}
</span>
{card.kind === 'daily' && !card.claimed && (
<span className="flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium shrink-0">
<Coins className="size-2.5" />
{card.reward}
</span>
)}
</div>
</button>
{/* View all link */}
<button
onClick={onViewAll}
className="flex items-center gap-1 mx-auto mt-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
View all missions
<ChevronRight className="size-3" />
</button>
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
/**
* Action Bar Preferences
*
* Lightweight localStorage-backed model controlling which items are
* visible in the BlobbiBottomBar and in which order.
*
* Fixed items (cannot be hidden or reordered by the user):
* - Main Action (center button) -- always present
* - More (right-most button) -- always present
*
* Customizable items (up to 3 visible slots):
* Candidates: Blobbies, Missions, Items, Take Photo, Set as Companion
*
* Persistence: localStorage only for now. Shape is designed so it can
* later migrate to a Nostr event tag.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
/** Identifiers for customizable bottom-bar items */
export type BarItemId =
| 'blobbies'
| 'missions'
| 'items'
| 'take_photo'
| 'set_companion';
/** A single customizable bar slot */
export interface BarItemSlot {
id: BarItemId;
visible: boolean;
/** If true, this item receives a subtle highlight ring in the bar */
highlighted?: boolean;
}
/** Full persisted preference shape */
export interface ActionBarPreferences {
/** Ordered list of customizable items. Visible items render in array order. */
slots: BarItemSlot[];
}
// ─── Constants ────────────────────────────────────────────────────────────────
/** Max visible customizable items (Main Action + More are fixed) */
export const MAX_VISIBLE_SLOTS = 3;
/** localStorage key for bar slot preferences */
export const STORAGE_KEY = 'blobbi:action-bar-prefs';
/** localStorage key for inline mission surface card visibility */
export const MISSION_CARD_STORAGE_KEY = 'blobbi:mission-card-visible';
/** Human-readable labels */
export const BAR_ITEM_LABELS: Record<BarItemId, string> = {
blobbies: 'Blobbies',
missions: 'Missions',
items: 'Items',
take_photo: 'Take Photo',
set_companion: 'Companion',
};
/** Default preferences: only Blobbies visible, others hidden */
export const DEFAULT_PREFERENCES: ActionBarPreferences = {
slots: [
{ id: 'blobbies', visible: true },
{ id: 'missions', visible: false },
{ id: 'items', visible: false },
{ id: 'take_photo', visible: false },
{ id: 'set_companion', visible: false },
],
};
// ─── Utilities ────────────────────────────────────────────────────────────────
/** Return only visible slots, in order */
export function getVisibleSlots(prefs: ActionBarPreferences): BarItemSlot[] {
return prefs.slots.filter((s) => s.visible);
}
/** Count of currently visible custom items */
export function visibleCount(prefs: ActionBarPreferences): number {
return prefs.slots.filter((s) => s.visible).length;
}
/** Can we show one more item? */
export function canShowMore(prefs: ActionBarPreferences): boolean {
return visibleCount(prefs) < MAX_VISIBLE_SLOTS;
}
/** Toggle visibility of a slot. Enforces MAX_VISIBLE_SLOTS. */
export function toggleSlotVisibility(
prefs: ActionBarPreferences,
id: BarItemId,
): ActionBarPreferences {
const slot = prefs.slots.find((s) => s.id === id);
if (!slot) return prefs;
// If turning ON and already at max, reject
if (!slot.visible && !canShowMore(prefs)) return prefs;
return {
slots: prefs.slots.map((s) =>
s.id === id ? { ...s, visible: !s.visible } : s,
),
};
}
/** Toggle highlight on a slot (only one can be highlighted at a time) */
export function toggleSlotHighlight(
prefs: ActionBarPreferences,
id: BarItemId,
): ActionBarPreferences {
return {
slots: prefs.slots.map((s) =>
s.id === id
? { ...s, highlighted: !s.highlighted }
: { ...s, highlighted: false },
),
};
}
/** Move a slot up (earlier) in the list */
export function moveSlotUp(
prefs: ActionBarPreferences,
id: BarItemId,
): ActionBarPreferences {
const idx = prefs.slots.findIndex((s) => s.id === id);
if (idx <= 0) return prefs;
const newSlots = [...prefs.slots];
[newSlots[idx - 1], newSlots[idx]] = [newSlots[idx], newSlots[idx - 1]];
return { slots: newSlots };
}
/** Move a slot down (later) in the list */
export function moveSlotDown(
prefs: ActionBarPreferences,
id: BarItemId,
): ActionBarPreferences {
const idx = prefs.slots.findIndex((s) => s.id === id);
if (idx < 0 || idx >= prefs.slots.length - 1) return prefs;
const newSlots = [...prefs.slots];
[newSlots[idx], newSlots[idx + 1]] = [newSlots[idx + 1], newSlots[idx]];
return { slots: newSlots };
}
/**
* Validate and repair preferences loaded from localStorage.
* Adds missing candidates, removes unknown ids, preserves order.
*/
export function validatePreferences(raw: unknown): ActionBarPreferences {
if (!raw || typeof raw !== 'object' || !('slots' in raw)) {
return DEFAULT_PREFERENCES;
}
const obj = raw as { slots: unknown };
if (!Array.isArray(obj.slots)) return DEFAULT_PREFERENCES;
const knownIds = new Set<BarItemId>(DEFAULT_PREFERENCES.slots.map((s) => s.id));
const seenIds = new Set<BarItemId>();
// Keep valid existing entries
const cleaned: BarItemSlot[] = [];
for (const item of obj.slots) {
if (
item &&
typeof item === 'object' &&
'id' in item &&
typeof (item as BarItemSlot).id === 'string' &&
knownIds.has((item as BarItemSlot).id) &&
!seenIds.has((item as BarItemSlot).id)
) {
const slot = item as BarItemSlot;
seenIds.add(slot.id);
cleaned.push({
id: slot.id,
visible: typeof slot.visible === 'boolean' ? slot.visible : false,
highlighted: typeof slot.highlighted === 'boolean' ? slot.highlighted : false,
});
}
}
// Add any missing candidates (new features added after user saved prefs)
for (const def of DEFAULT_PREFERENCES.slots) {
if (!seenIds.has(def.id)) {
cleaned.push({ ...def });
}
}
return { slots: cleaned };
}
/**
* Load preferences from localStorage with validation.
*/
export function loadPreferences(): ActionBarPreferences {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_PREFERENCES;
return validatePreferences(JSON.parse(raw));
} catch {
return DEFAULT_PREFERENCES;
}
}
/**
* Save preferences to localStorage.
*/
export function savePreferences(prefs: ActionBarPreferences): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Silently fail (quota, SSR, etc.)
}
}
// ─── Mission Surface Card Visibility ──────────────────────────────────────────
/**
* Load the inline mission card visibility preference.
* Defaults to `true` (visible).
*/
export function loadMissionCardVisible(): boolean {
try {
const raw = localStorage.getItem(MISSION_CARD_STORAGE_KEY);
if (raw === null) return true;
return raw === 'true';
} catch {
return true;
}
}
/**
* Save the inline mission card visibility preference.
*/
export function saveMissionCardVisible(visible: boolean): void {
try {
localStorage.setItem(MISSION_CARD_STORAGE_KEY, String(visible));
} catch {
// Silently fail
}
}
// ─── Visible-in-bar Set Helper ────────────────────────────────────────────────
/**
* Return the set of BarItemIds currently visible in the bottom bar.
* Used by the More dropdown to skip items that are already in the bar.
*/
export function getVisibleBarIds(prefs: ActionBarPreferences): Set<BarItemId> {
return new Set(prefs.slots.filter((s) => s.visible).map((s) => s.id));
}
-299
View File
@@ -1,299 +0,0 @@
import { useCallback } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { toast } from './useToast';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
buildMigrationTags,
generatePetId10,
getCanonicalBlobbiD,
migratePetInHas,
updateBlobbonautTags,
parseBlobbiEvent,
parseStorageTags,
type BlobbiCompanion,
type BlobbonautProfile,
type StorageItem,
} from '@/lib/blobbi';
/**
* Result of a successful migration.
*/
export interface MigrationResult {
/** The new canonical d-tag */
canonicalD: string;
/** The published canonical Blobbi event */
event: NostrEvent;
/** The parsed canonical BlobbiCompanion */
companion: BlobbiCompanion;
/** The updated profile event */
profileEvent: NostrEvent;
/** The updated profile tags (canonical has, current_companion, etc.) */
profileTags: string[][];
/** The profile storage (unchanged during migration, but fresh from migrated profile) */
profileStorage: StorageItem[];
}
/**
* Options for the migration helper.
*/
export interface EnsureCanonicalOptions {
/** The companion to check/migrate */
companion: BlobbiCompanion;
/** The user's profile */
profile: BlobbonautProfile;
/** Callback to update the profile event in query cache */
updateProfileEvent: (event: NostrEvent) => void;
/** Callback to update the companion event in query cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Callback to update localStorage selection if it was pointing to legacy d */
updateStoredSelectedD?: (newD: string) => void;
/** Callback to invalidate companion query */
invalidateCompanion?: () => void;
/** Callback to invalidate profile query */
invalidateProfile?: () => void;
}
/**
* Result of ensureCanonicalBlobbiBeforeAction.
*/
export interface EnsureCanonicalResult {
/** Whether the companion was migrated */
wasMigrated: boolean;
/** The canonical companion (either the original or the migrated one) */
companion: BlobbiCompanion;
/** The canonical event tags to use for the action */
allTags: string[][];
/** The event content to use */
content: string;
/**
* The latest profile tags to use for profile updates.
* IMPORTANT: Always use these instead of profile.allTags from hook closure
* to avoid restoring stale/legacy values after migration.
*/
profileAllTags: string[][];
/**
* The latest profile storage to use.
* Use this as the base for storage modifications.
*/
profileStorage: StorageItem[];
}
/**
* Hook providing centralized migration logic for Blobbi companions.
*
* This hook should be used by all action handlers to ensure legacy Blobbis
* are automatically migrated before any interaction.
*
* Usage:
* ```ts
* const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
*
* const handleFeed = async () => {
* const result = await ensureCanonicalBlobbiBeforeAction({
* companion,
* profile,
* updateProfileEvent,
* updateCompanionEvent,
* updateStoredSelectedD: setStoredSelectedD,
* });
*
* if (!result) return; // Migration failed
*
* // Continue with the action using result.companion and result.allTags
* const newTags = updateBlobbiTags(result.allTags, { ... });
* // ... publish event
* };
* ```
*/
export function useBlobbiMigration() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
/**
* Migrate a legacy Blobbi to canonical format.
*
* This function:
* 1. Generates a canonical d-tag
* 2. Ensures a seed exists (generates one if missing)
* 3. Preserves name, stage, stats, state, timestamps
* 4. Publishes a canonical 31124 event
* 5. Updates the Blobbonaut profile (kind 11125)
* 6. Updates local state (query cache, localStorage)
*/
const migrateLegacyBlobbi = useCallback(async (
options: EnsureCanonicalOptions
): Promise<MigrationResult | null> => {
const {
companion,
profile,
updateProfileEvent,
updateCompanionEvent,
updateStoredSelectedD,
invalidateCompanion,
invalidateProfile,
} = options;
if (!user?.pubkey) {
console.error('[Blobbi Migration] No user pubkey');
return null;
}
console.log('[Blobbi Migration] Starting migration for:', companion.d);
try {
// Generate new canonical d-tag
const newPetId = generatePetId10();
const canonicalD = getCanonicalBlobbiD(user.pubkey, newPetId);
// Build migration tags (preserves name, stage, stats, generates seed if missing)
const migrationTags = buildMigrationTags(companion.event, newPetId, user.pubkey);
console.log('[Blobbi Migration] Publishing canonical event with d:', canonicalD);
// Publish the canonical Blobbi state
const canonicalEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content || `${companion.name} is a ${companion.stage} Blobbi.`,
tags: migrationTags,
});
// Parse the new event to get the canonical companion
const canonicalCompanion = parseBlobbiEvent(canonicalEvent);
if (!canonicalCompanion) {
throw new Error('Failed to parse migrated event');
}
// Update profile: replace legacy d with canonical d in has[], update current_companion
const updatedHas = migratePetInHas(profile.has, companion.d, canonicalD);
const shouldUpdateCurrentCompanion = profile.currentCompanion === companion.d;
const profileUpdates: Record<string, string | string[]> = {
has: updatedHas,
};
if (shouldUpdateCurrentCompanion) {
profileUpdates.current_companion = canonicalD;
}
const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
console.log('[Blobbi Migration] Publishing updated profile');
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
// Update query caches
updateProfileEvent(profileEvent);
updateCompanionEvent(canonicalEvent);
// Update localStorage selection if it was pointing to legacy d
if (updateStoredSelectedD) {
console.log('[Blobbi Migration] Updating localStorage selection:', canonicalD);
updateStoredSelectedD(canonicalD);
}
// Invalidate queries to refetch fresh data
invalidateCompanion?.();
invalidateProfile?.();
toast({
title: 'Pet upgraded!',
description: `${companion.name} has been migrated to the new format.`,
});
console.log('[Blobbi Migration] Migration complete:', {
legacyD: companion.d,
canonicalD,
});
// Parse storage from the migrated profile tags
// Storage itself doesn't change during migration, but we need fresh tags
const migratedStorage = parseStorageTags(profileTags);
return {
canonicalD,
event: canonicalEvent,
companion: canonicalCompanion,
profileEvent,
profileTags,
profileStorage: migratedStorage,
};
} catch (error) {
console.error('[Blobbi Migration] Migration failed:', error);
toast({
title: 'Migration failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
return null;
}
}, [user?.pubkey, publishEvent]);
/**
* Ensure a Blobbi is in canonical format before performing an action.
*
* If the companion is legacy, it will be migrated first.
* Returns the canonical companion to use for the action.
*
* Flow:
* 1. Check if Blobbi is legacy
* 2. If legacy: migrate Blobbi
* 3. Return the resolved canonical Blobbi
*
* All interaction handlers should call this before publishing events.
*/
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
options: EnsureCanonicalOptions
): Promise<EnsureCanonicalResult | null> => {
const { companion, profile } = options;
// Check if the companion needs migration
if (companion.isLegacy) {
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
const migrationResult = await migrateLegacyBlobbi(options);
if (!migrationResult) {
// Migration failed, cannot proceed with action
return null;
}
// Return the canonical companion AND migrated profile context
// CRITICAL: Consumers must use profileAllTags instead of profile.allTags
// to avoid restoring stale/legacy values
return {
wasMigrated: true,
companion: migrationResult.companion,
allTags: migrationResult.event.tags,
content: migrationResult.event.content,
profileAllTags: migrationResult.profileTags,
profileStorage: migrationResult.profileStorage,
};
}
// Companion is already canonical, return profile as-is
return {
wasMigrated: false,
companion,
allTags: companion.allTags,
content: companion.event.content,
profileAllTags: profile.allTags,
profileStorage: profile.storage,
};
}, [migrateLegacyBlobbi]);
return {
/** Migrate a legacy Blobbi to canonical format */
migrateLegacyBlobbi,
/** Ensure a Blobbi is canonical before an action, migrating if necessary */
ensureCanonicalBlobbiBeforeAction,
};
}
-198
View File
@@ -1,198 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import {
KIND_BLOBBI_STATE,
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiCompanion,
} from '@/lib/blobbi';
/** Maximum number of d-tags per query chunk to avoid relay issues */
const CHUNK_SIZE = 20;
/**
* Split an array into chunks of a given size.
*/
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
/**
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
*
* Features:
* - Fetches ALL pets by d-tag list (no limit: 1)
* - Chunks large d-lists into multiple queries for relay compatibility
* - Keeps only the newest event per d-tag
* - Returns both a lookup record and array of companions
* - Provides invalidation and optimistic update helpers
*/
export function useBlobbisCollection(dList: string[] | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// Create a stable query key based on sorted d-tags
const sortedDList = useMemo(() => {
if (!dList || dList.length === 0) return null;
return [...dList].sort();
}, [dList]);
const queryKeyDTags = sortedDList?.join(',') ?? '';
// Main query to fetch all companions from relays
const query = useQuery({
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
return { companionsByD: {}, companions: [] };
}
// Log the dList we're about to query
console.log('[Blobbi] dList:', sortedDList);
// Chunk the d-list for relay compatibility
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
// Query all chunks in parallel
const allEvents: NostrEvent[] = [];
for (const chunk of chunks) {
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
};
// Log the filter immediately before query
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
const events = await nostr.query([filter], { signal });
allEvents.push(...events);
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
}
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
// Filter to valid events
const validEvents = allEvents.filter(isValidBlobbiEvent);
console.log('[useBlobbisCollection] Valid events:', validEvents.length);
// Group events by d-tag and keep only the newest per d
const eventsByD = new Map<string, NostrEvent>();
for (const event of validEvents) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (!dTag) continue;
const existing = eventsByD.get(dTag);
if (!existing || event.created_at > existing.created_at) {
eventsByD.set(dTag, event);
}
}
// Parse all events into BlobbiCompanion objects
const companionsByD: Record<string, BlobbiCompanion> = {};
const companions: BlobbiCompanion[] = [];
for (const [dTag, event] of eventsByD) {
const parsed = parseBlobbiEvent(event);
if (parsed) {
companionsByD[dTag] = parsed;
companions.push(parsed);
}
}
console.log('[useBlobbisCollection] Parsed companions:', {
count: companions.length,
dTags: Object.keys(companionsByD),
});
return { companionsByD, companions };
},
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
staleTime: 30_000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Helper to invalidate and refetch after publishing
const invalidate = useCallback(() => {
if (user?.pubkey && queryKeyDTags) {
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
});
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Update a single companion event in the query cache (optimistic update)
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (!parsed || !user?.pubkey) return;
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
['blobbi-collection', user.pubkey, queryKeyDTags],
(prev) => {
if (!prev) {
return {
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
};
}
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
}
);
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Memoize return values for stability
const companionsByD = query.data?.companionsByD ?? {};
const companions = query.data?.companions ?? [];
return {
/** Record of companions keyed by d-tag */
companionsByD,
/** Array of all companions (newest per d-tag) */
companions,
/** True only when query is loading and no data available */
isLoading: query.isLoading,
/** True when actively fetching */
isFetching: query.isFetching,
/** True when data is stale */
isStale: query.isStale,
/** Query error if any */
error: query.error,
/** Invalidate and refetch the collection */
invalidate,
/** Optimistically update a single companion in the cache */
updateCompanionEvent,
};
}
@@ -17,6 +17,7 @@ import { useNostrPublish } from './useNostrPublish';
import {
KIND_BLOBBONAUT_PROFILE,
profileNeedsPettingLevelNormalization,
profileNeedsOnboardingTagMigration,
buildNormalizedProfileTags,
isLegacyBlobbonautKind,
type BlobbonautProfile,
@@ -55,9 +56,10 @@ export function useBlobbonautProfileNormalization({
// Check what normalization is needed
const needsTagNormalization = profileNeedsPettingLevelNormalization(profile);
const needsKindMigration = isLegacyBlobbonautKind(profile.event);
const needsOnboardingMigration = profileNeedsOnboardingTagMigration(profile);
// If no normalization needed, mark as seen and return
if (!needsTagNormalization && !needsKindMigration) {
if (!needsTagNormalization && !needsKindMigration && !needsOnboardingMigration) {
normalizedEventIds.current.add(profile.event.id);
return;
}
@@ -68,6 +70,7 @@ export function useBlobbonautProfileNormalization({
const reasons: string[] = [];
if (needsTagNormalization) reasons.push('missing pettingLevel');
if (needsKindMigration) reasons.push('legacy kind 31125 → 11125');
if (needsOnboardingMigration) reasons.push('onboarding_done → blobbi_onboarding_done');
console.log(`[ProfileNormalization] Profile needs normalization: ${reasons.join(', ')}`);
-122
View File
@@ -1,122 +0,0 @@
/**
* Hook for projecting Blobbi decay state in the UI.
*
* This hook provides a local projection of decay without publishing events.
* It recalculates every 60 seconds while the component is mounted.
*
* The projected state is for UI display only. Actual mutations must
* recalculate from the persisted state before publishing.
*
* @see docs/blobbi/decay-system.md
*/
import { useState, useEffect, useMemo } from 'react';
import type { BlobbiCompanion, BlobbiStats } from '@/lib/blobbi';
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/lib/blobbi-decay';
/** UI refresh interval in milliseconds (60 seconds) */
const UI_REFRESH_INTERVAL_MS = 60_000;
/**
* Projected Blobbi state for UI display.
*/
export interface ProjectedBlobbiState {
/** Stats after applying projected decay */
stats: BlobbiStats;
/** Visible stats for the current stage with status indicators */
visibleStats: Array<{
stat: keyof BlobbiStats;
value: number;
status: 'critical' | 'warning' | 'normal';
}>;
/** Time elapsed since last decay (seconds) */
elapsedSeconds: number;
/** Timestamp of the projection calculation */
projectedAt: number;
/** Whether this is a fresh projection (recalculated this render) */
isFresh: boolean;
}
/**
* Hook to get a projected Blobbi state with decay applied.
*
* Features:
* - Immediately calculates projected state on mount/companion change
* - Recalculates every 60 seconds while mounted
* - Pure calculation - does not publish any events
* - Returns both full stats and stage-appropriate visible stats
*
* @param companion - The persisted Blobbi companion (source of truth)
* @returns Projected state with decay applied, or null if no companion
*/
export function useProjectedBlobbiState(
companion: BlobbiCompanion | null
): ProjectedBlobbiState | null {
// Track when we last recalculated
const [refreshTick, setRefreshTick] = useState(0);
// Set up 60-second refresh interval
useEffect(() => {
if (!companion) return;
const interval = setInterval(() => {
setRefreshTick(t => t + 1);
}, UI_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [companion]);
// Calculate projected state
const projectedState = useMemo((): ProjectedBlobbiState | null => {
if (!companion) return null;
const now = Math.floor(Date.now() / 1000);
// Apply decay from persisted state
const decayResult: DecayResult = applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now,
});
// Get visible stats for the stage
const visibleStats = getVisibleStatsWithValues(companion.stage, decayResult.stats);
return {
stats: decayResult.stats,
visibleStats,
elapsedSeconds: decayResult.elapsedSeconds,
projectedAt: now,
isFresh: true,
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTick triggers recalculation
}, [companion, refreshTick]);
return projectedState;
}
/**
* Calculate projected decay for a companion at a specific timestamp.
*
* This is a utility function for use outside of React components,
* such as in mutation handlers before publishing.
*
* @param companion - The persisted Blobbi companion
* @param now - Unix timestamp to calculate decay to (defaults to current time)
* @returns Decay result with updated stats
*/
export function calculateProjectedDecay(
companion: BlobbiCompanion,
now?: number
): DecayResult {
return applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now: now ?? Math.floor(Date.now() / 1000),
});
}
-500
View File
@@ -1,500 +0,0 @@
/**
* Blobbi Decay System
*
* This module implements the continuous proportional decay system for Blobbi stats.
*
* Key principles:
* - Pure, deterministic calculation based on elapsed time
* - Floored stat changes before application
* - Stats clamped to 0-100 range
* - Stage-specific decay rates and health modifiers
* - Persisted state is the source of truth
*
* @see docs/blobbi/decay-system.md for full documentation
*/
import type { BlobbiStage, BlobbiState, BlobbiStats } from './blobbi';
import { STAT_MIN, STAT_MAX } from './blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of applying decay to a Blobbi.
* Contains updated stats and metadata about the calculation.
*/
export interface DecayResult {
/** Updated stats after decay (clamped to 0-100) */
stats: BlobbiStats;
/** Elapsed time in seconds that was used for decay calculation */
elapsedSeconds: number;
/** The timestamp that should be set as the new last_decay_at */
newDecayTimestamp: number;
}
/**
* Input parameters for decay calculation.
* Uses the persisted Blobbi state as source of truth.
*/
export interface DecayInput {
/** Current life stage */
stage: BlobbiStage;
/** Current activity state (awake/sleeping) */
state: BlobbiState;
/** Current stats from persisted state */
stats: Partial<BlobbiStats>;
/** Unix timestamp of last decay application */
lastDecayAt: number | undefined;
/** Current unix timestamp (defaults to now) */
now?: number;
}
// ─── Constants: Decay Rates ───────────────────────────────────────────────────
/**
* Baby stage decay rates (per hour).
*
* Design goal: Needs attention every 3-5 hours.
*/
const BABY_DECAY = {
hunger: -7.0,
happiness: -4.0,
hygiene: -5.0,
energy: {
awake: -8.0,
sleeping: 6.0, // Regeneration
},
health: {
base: -0.75,
hungerBelow70: -0.75,
hungerBelow40: -1.25,
hygieneBelow70: -0.75,
hygieneBelow40: -1.25,
energyBelow50: -0.5,
energyBelow25: -1.0,
happinessBelow50: -0.5,
happinessBelow25: -1.0,
// Regeneration when all stats are >= 80
regenThreshold: 80,
regenRate: 1.5,
},
} as const;
/**
* Adult stage decay rates (per hour).
*
* Design goal: Needs attention every 5-7 hours.
*/
const ADULT_DECAY = {
hunger: -4.5,
happiness: -2.5,
hygiene: -3.5,
energy: {
awake: -5.0,
sleeping: 5.0, // Regeneration
},
health: {
base: -0.4,
hungerBelow60: -0.5,
hungerBelow30: -1.0,
hygieneBelow60: -0.5,
hygieneBelow30: -1.0,
energyBelow40: -0.4,
energyBelow20: -0.8,
happinessBelow40: -0.4,
happinessBelow20: -0.8,
// Regeneration when all stats are >= 80
regenThreshold: 80,
regenRate: 1.0,
},
} as const;
// ─── Constants: Warning Thresholds ────────────────────────────────────────────
/**
* Warning thresholds by stage.
* Warning = stat below this value indicates the Blobbi needs attention.
*/
export const WARNING_THRESHOLDS = {
egg: {
hygiene: 75,
health: 75,
happiness: 75,
},
baby: {
hunger: 65,
happiness: 65,
hygiene: 65,
energy: 65,
health: 65,
},
adult: {
hunger: 60,
happiness: 60,
hygiene: 60,
energy: 60,
health: 60,
},
} as const;
/**
* Critical thresholds by stage.
* Critical = stat below this value indicates urgent attention needed.
*/
export const CRITICAL_THRESHOLDS = {
egg: {
hygiene: 45,
health: 45,
happiness: 45,
},
baby: {
hunger: 35,
happiness: 35,
hygiene: 35,
energy: 25,
health: 35,
},
adult: {
hunger: 30,
happiness: 30,
hygiene: 30,
energy: 20,
health: 30,
},
} as const;
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Clamp a value to the STAT_MIN-STAT_MAX range (1-100).
* Stats can never reach true zero - minimum is always 1.
*/
function clamp(value: number): number {
return Math.max(STAT_MIN, Math.min(STAT_MAX, value));
}
/**
* Get stat value with fallback to 100 (full).
*/
function getStat(stats: Partial<BlobbiStats>, key: keyof BlobbiStats): number {
return stats[key] ?? 100;
}
/**
* Convert hours to the elapsed time unit for calculation.
* @param hours - Elapsed hours
* @returns Rate multiplier for the elapsed time
*/
function hoursFromSeconds(seconds: number): number {
return seconds / 3600;
}
// ─── Stage-Specific Decay Calculators ─────────────────────────────────────────
/**
* Calculate egg stage decay.
*
* Eggs only decay hygiene, health, and happiness.
* Hunger and energy are fixed at 100.
*/
function calculateEggDecay(
stats: Partial<BlobbiStats>,
_elapsedHours: number
): BlobbiStats {
// Eggs do not decay — all stats remain fixed until hatching.
return {
hunger: 100,
energy: 100,
hygiene: getStat(stats, 'hygiene'),
health: getStat(stats, 'health'),
happiness: getStat(stats, 'happiness'),
};
}
/**
* Calculate baby stage decay.
*/
function calculateBabyDecay(
stats: Partial<BlobbiStats>,
state: BlobbiState,
elapsedHours: number
): BlobbiStats {
const isSleeping = state === 'sleeping';
// Get current values
let hunger = getStat(stats, 'hunger');
let happiness = getStat(stats, 'happiness');
let hygiene = getStat(stats, 'hygiene');
let energy = getStat(stats, 'energy');
let health = getStat(stats, 'health');
// Calculate basic stat decay/regen
const hungerDelta = BABY_DECAY.hunger * elapsedHours;
const happinessDelta = BABY_DECAY.happiness * elapsedHours;
const hygieneDelta = BABY_DECAY.hygiene * elapsedHours;
const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + Math.floor(hungerDelta));
happiness = clamp(happiness + Math.floor(happinessDelta));
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
energy = clamp(energy + Math.floor(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = BABY_DECAY.health.base * elapsedHours;
// Hunger penalties
if (hunger < 70) healthDelta += BABY_DECAY.health.hungerBelow70 * elapsedHours;
if (hunger < 40) healthDelta += BABY_DECAY.health.hungerBelow40 * elapsedHours;
// Hygiene penalties
if (hygiene < 70) healthDelta += BABY_DECAY.health.hygieneBelow70 * elapsedHours;
if (hygiene < 40) healthDelta += BABY_DECAY.health.hygieneBelow40 * elapsedHours;
// Energy penalties
if (energy < 50) healthDelta += BABY_DECAY.health.energyBelow50 * elapsedHours;
if (energy < 25) healthDelta += BABY_DECAY.health.energyBelow25 * elapsedHours;
// Happiness penalties
if (happiness < 50) healthDelta += BABY_DECAY.health.happinessBelow50 * elapsedHours;
if (happiness < 25) healthDelta += BABY_DECAY.health.happinessBelow25 * elapsedHours;
// Health regeneration (all stats >= 80)
const threshold = BABY_DECAY.health.regenThreshold;
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
healthDelta += BABY_DECAY.health.regenRate * elapsedHours;
}
health = clamp(health + Math.floor(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
/**
* Calculate adult stage decay.
*/
function calculateAdultDecay(
stats: Partial<BlobbiStats>,
state: BlobbiState,
elapsedHours: number
): BlobbiStats {
const isSleeping = state === 'sleeping';
// Get current values
let hunger = getStat(stats, 'hunger');
let happiness = getStat(stats, 'happiness');
let hygiene = getStat(stats, 'hygiene');
let energy = getStat(stats, 'energy');
let health = getStat(stats, 'health');
// Calculate basic stat decay/regen
const hungerDelta = ADULT_DECAY.hunger * elapsedHours;
const happinessDelta = ADULT_DECAY.happiness * elapsedHours;
const hygieneDelta = ADULT_DECAY.hygiene * elapsedHours;
const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + Math.floor(hungerDelta));
happiness = clamp(happiness + Math.floor(happinessDelta));
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
energy = clamp(energy + Math.floor(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = ADULT_DECAY.health.base * elapsedHours;
// Hunger penalties
if (hunger < 60) healthDelta += ADULT_DECAY.health.hungerBelow60 * elapsedHours;
if (hunger < 30) healthDelta += ADULT_DECAY.health.hungerBelow30 * elapsedHours;
// Hygiene penalties
if (hygiene < 60) healthDelta += ADULT_DECAY.health.hygieneBelow60 * elapsedHours;
if (hygiene < 30) healthDelta += ADULT_DECAY.health.hygieneBelow30 * elapsedHours;
// Energy penalties
if (energy < 40) healthDelta += ADULT_DECAY.health.energyBelow40 * elapsedHours;
if (energy < 20) healthDelta += ADULT_DECAY.health.energyBelow20 * elapsedHours;
// Happiness penalties
if (happiness < 40) healthDelta += ADULT_DECAY.health.happinessBelow40 * elapsedHours;
if (happiness < 20) healthDelta += ADULT_DECAY.health.happinessBelow20 * elapsedHours;
// Health regeneration (all stats >= 80)
const threshold = ADULT_DECAY.health.regenThreshold;
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
healthDelta += ADULT_DECAY.health.regenRate * elapsedHours;
}
health = clamp(health + Math.floor(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
// ─── Main Decay Function ──────────────────────────────────────────────────────
/**
* Apply decay to a Blobbi based on elapsed time since last decay.
*
* This is a pure, deterministic function that:
* 1. Calculates elapsed time from lastDecayAt to now
* 2. Applies stage-specific decay rates
* 3. Floors all stat deltas before application
* 4. Clamps final stats to 0-100 range
* 5. Returns updated stats without side effects
*
* @param input - Decay input parameters from persisted state
* @returns DecayResult with updated stats and new decay timestamp
*/
export function applyBlobbiDecay(input: DecayInput): DecayResult {
const now = input.now ?? Math.floor(Date.now() / 1000);
const lastDecayAt = input.lastDecayAt ?? now;
// Calculate elapsed time
const elapsedSeconds = Math.max(0, now - lastDecayAt);
const elapsedHours = hoursFromSeconds(elapsedSeconds);
// If no time has passed, return current stats unchanged
if (elapsedSeconds === 0) {
return {
stats: {
hunger: getStat(input.stats, 'hunger'),
happiness: getStat(input.stats, 'happiness'),
health: getStat(input.stats, 'health'),
hygiene: getStat(input.stats, 'hygiene'),
energy: getStat(input.stats, 'energy'),
},
elapsedSeconds: 0,
newDecayTimestamp: now,
};
}
// Apply stage-specific decay
let newStats: BlobbiStats;
switch (input.stage) {
case 'egg':
newStats = calculateEggDecay(input.stats, elapsedHours);
break;
case 'baby':
newStats = calculateBabyDecay(input.stats, input.state, elapsedHours);
break;
case 'adult':
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
break;
default:
// Fallback to adult decay for unknown stages
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
}
return {
stats: newStats,
elapsedSeconds,
newDecayTimestamp: now,
};
}
// ─── Threshold Checkers ───────────────────────────────────────────────────────
/**
* Check if a stat is at warning level for the given stage.
*/
export function isStatAtWarning(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): boolean {
const thresholds = WARNING_THRESHOLDS[stage];
const threshold = (thresholds as Record<string, number>)[stat];
if (threshold === undefined) return false;
return value < threshold;
}
/**
* Check if a stat is at critical level for the given stage.
*/
export function isStatAtCritical(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): boolean {
const thresholds = CRITICAL_THRESHOLDS[stage];
const threshold = (thresholds as Record<string, number>)[stat];
if (threshold === undefined) return false;
return value < threshold;
}
/**
* Get the status level for a stat.
* @returns 'critical' | 'warning' | 'normal'
*/
export function getStatStatus(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): 'critical' | 'warning' | 'normal' {
if (isStatAtCritical(stage, stat, value)) return 'critical';
if (isStatAtWarning(stage, stat, value)) return 'warning';
return 'normal';
}
/**
* Get all stats that are at warning or critical level.
*/
export function getStatsNeedingAttention(
stage: BlobbiStage,
stats: Partial<BlobbiStats>
): Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> {
const results: Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> = [];
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
// For eggs, only check relevant stats
const relevantStats = stage === 'egg'
? ['health', 'hygiene', 'happiness'] as (keyof BlobbiStats)[]
: statKeys;
for (const stat of relevantStats) {
const value = stats[stat] ?? 100;
const status = getStatStatus(stage, stat, value);
if (status !== 'normal') {
results.push({ stat, value, status });
}
}
return results;
}
// ─── Visible Stats Helper ─────────────────────────────────────────────────────
/**
* Visibility threshold: stats at or above this value are hidden in the UI.
* Only stats below this threshold are displayed.
*/
export const STAT_VISIBILITY_THRESHOLD = 70;
/**
* Get the stats that should be visible for a given stage.
* Eggs only show health, hygiene, happiness.
* Baby/adult show all stats.
*/
export function getVisibleStats(stage: BlobbiStage): (keyof BlobbiStats)[] {
if (stage === 'egg') {
return ['health', 'hygiene', 'happiness'];
}
return ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
}
/**
* Get visible stats with their values for display.
* Stats at or above STAT_VISIBILITY_THRESHOLD are filtered out.
*/
export function getVisibleStatsWithValues(
stage: BlobbiStage,
stats: Partial<BlobbiStats>
): Array<{ stat: keyof BlobbiStats; value: number; status: 'critical' | 'warning' | 'normal' }> {
const visibleStats = getVisibleStats(stage);
return visibleStats
.map(stat => ({
stat,
value: stats[stat] ?? 100,
status: getStatStatus(stage, stat, stats[stat] ?? 100),
}))
.filter(entry => entry.value < STAT_VISIBILITY_THRESHOLD);
}
-156
View File
@@ -1,156 +0,0 @@
/**
* Blobbi → EggGraphic Adapter
*
* This module provides a translation layer between the Blobbi domain model
* and the portable EggGraphic visual module.
*
* PURPOSE:
* - Keep the game/domain visual model decoupled from EggGraphic internals
* - Provide explicit mappings between vocabularies
* - Act as the single translation boundary for visual rendering
*
* USAGE:
* ```ts
* const eggVisual = toEggGraphicVisualBlobbi(companion);
* // Pass eggVisual to EggGraphic component
* ```
*/
import type { EggVisualBlobbi } from '@/blobbi/egg';
import {
type BlobbiCompanion,
type BlobbiPattern,
type BlobbiSpecialMark,
type BlobbiStage,
getTagValue,
} from './blobbi';
// ─── Egg Module Types (derived from EggVisualBlobbi) ──────────────────────────
/** Life stage values accepted by EggGraphic */
type EggLifeStage = NonNullable<EggVisualBlobbi['lifeStage']>;
/** Pattern values accepted by EggGraphic */
type EggPattern = NonNullable<EggVisualBlobbi['pattern']>;
/** Special mark values accepted by EggGraphic */
type EggSpecialMark = NonNullable<EggVisualBlobbi['specialMark']>;
/** Theme variant values accepted by EggGraphic */
type EggThemeVariant = NonNullable<EggVisualBlobbi['themeVariant']>;
// ─── Mapping Tables ───────────────────────────────────────────────────────────
/**
* Maps Blobbi pattern values to EggGraphic pattern values.
* Explicit mapping allows vocabularies to diverge in the future.
*/
const PATTERN_MAP: Record<BlobbiPattern, EggPattern> = {
'solid': 'solid',
'spotted': 'spotted',
'striped': 'striped',
'gradient': 'gradient',
};
/**
* Maps Blobbi special mark values to EggGraphic special mark values.
*/
const SPECIAL_MARK_MAP: Record<BlobbiSpecialMark, EggSpecialMark> = {
'none': 'none',
'star': 'star',
'heart': 'heart',
'sparkle': 'sparkle',
'blush': 'blush',
};
/**
* Maps Blobbi stage values to EggGraphic life stage values.
*/
const LIFE_STAGE_MAP: Record<BlobbiStage, EggLifeStage> = {
'egg': 'egg',
'baby': 'baby',
'adult': 'adult',
};
// ─── Fallback Values ──────────────────────────────────────────────────────────
const DEFAULT_PATTERN: EggPattern = 'solid';
const DEFAULT_SPECIAL_MARK: EggSpecialMark = 'none';
const DEFAULT_LIFE_STAGE: EggLifeStage = 'egg';
const DEFAULT_THEME_VARIANT: EggThemeVariant = 'default';
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Extract crossover app identifier from companion tags.
*/
function extractCrossoverApp(allTags: string[][]): string | undefined {
return getTagValue(allTags, 'crossover_app');
}
// ─── Main Adapter Function ────────────────────────────────────────────────────
/**
* Convert a BlobbiCompanion to EggVisualBlobbi for rendering.
*
* This is the TRANSLATION BOUNDARY between the Blobbi domain model
* and the EggGraphic visual module.
*
* The adapter:
* - Maps vocabulary values through explicit mapping tables
* - Passes through full tags for EggGraphic metadata lookups
* - Provides safe fallbacks for any missing/invalid data
* - Does NOT leak app-specific assumptions into EggGraphic
*
* @param companion - The parsed BlobbiCompanion from parseBlobbiEvent
* @param themeVariant - Optional theme variant override
* @returns Visual data compatible with EggVisualBlobbi
*/
export function toEggGraphicVisualBlobbi(
companion: BlobbiCompanion,
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
): EggVisualBlobbi {
const { visualTraits, stage, allTags } = companion;
return {
// Colors pass through directly (already CSS hex values)
baseColor: visualTraits.baseColor,
secondaryColor: visualTraits.secondaryColor,
// Mapped through explicit tables with fallbacks
pattern: PATTERN_MAP[visualTraits.pattern] ?? DEFAULT_PATTERN,
specialMark: SPECIAL_MARK_MAP[visualTraits.specialMark] ?? DEFAULT_SPECIAL_MARK,
lifeStage: LIFE_STAGE_MAP[stage] ?? DEFAULT_LIFE_STAGE,
// Theme variant
themeVariant,
// Pass through full tags for EggGraphic metadata lookups
tags: allTags,
// Extracted convenience values
crossoverApp: extractCrossoverApp(allTags),
// NOTE: We intentionally do NOT pass companion.name as title here.
// The EggGraphic 'title' field is for special designations (e.g., "Divine"),
// not the pet's name. The pet name is displayed separately by the parent component.
};
}
/**
* Check if two EggVisualBlobbi configurations are visually equivalent.
* Useful for memoization and avoiding unnecessary re-renders.
*/
export function areEggGraphicVisualsEqual(
a: EggVisualBlobbi,
b: EggVisualBlobbi
): boolean {
return (
a.baseColor === b.baseColor &&
a.secondaryColor === b.secondaryColor &&
a.pattern === b.pattern &&
a.specialMark === b.specialMark &&
a.lifeStage === b.lifeStage &&
a.themeVariant === b.themeVariant
);
}
File diff suppressed because it is too large Load Diff
-1568
View File
File diff suppressed because it is too large Load Diff
+410 -116
View File
@@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Egg, Moon, Sun, Loader2, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, ArrowLeft, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink } from 'lucide-react';
import { Egg, Moon, Sun, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink, Settings2 } from 'lucide-react';
// Note: Sparkles kept for BlobbiBottomBar center action button
// Note: Plus kept for AdoptAnotherBlobbiCard
// Note: AlertTriangle kept for stat warning indicators
@@ -80,6 +80,7 @@ import {
type SelectedTrack,
type BlobbiReactionState,
type StartIncubationMode,
useDailyMissions,
} from '@/blobbi/actions';
import { BlobbiOnboardingFlow } from '@/blobbi/onboarding';
import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/companion/interaction';
@@ -88,6 +89,23 @@ import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { MissionSurfaceCard } from '@/blobbi/ui/components/MissionSurfaceCard';
import { ActionBarEditor } from '@/blobbi/ui/components/ActionBarEditor';
import {
type ActionBarPreferences,
type BarItemId,
getVisibleSlots,
getVisibleBarIds,
loadPreferences,
savePreferences,
loadMissionCardVisible,
saveMissionCardVisible,
} from '@/blobbi/ui/lib/action-bar-preferences';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourCard } from '@/blobbi/tour';
import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions';
import type { EggTourVisualState } from '@/blobbi/egg';
/**
* Get the localStorage key for the selected Blobbi.
@@ -871,6 +889,24 @@ function BlobbiDashboard({
const [showMissionsModal, setShowMissionsModal] = useState(false);
const [showShopModal, setShowShopModal] = useState(false);
const [showPhotoModal, setShowPhotoModal] = useState(false);
const [showBarEditor, setShowBarEditor] = useState(false);
// ─── Action Bar Preferences ───
const [barPrefs, setBarPrefs] = useState<ActionBarPreferences>(loadPreferences);
const handleBarPrefsUpdate = useCallback((prefs: ActionBarPreferences) => {
setBarPrefs(prefs);
savePreferences(prefs);
}, []);
// ─── Mission Surface Card Visibility ───
const [missionCardVisible, setMissionCardVisible] = useState(loadMissionCardVisible);
const handleToggleMissionCard = useCallback((visible: boolean) => {
setMissionCardVisible(visible);
saveMissionCardVisible(visible);
}, []);
// ─── Daily Missions (for surface card) ───
const dailyMissions = useDailyMissions({ availableStages });
// DEV ONLY: Emotion panel state
const [showEmotionPanel, setShowEmotionPanel] = useState(false);
@@ -934,6 +970,168 @@ function BlobbiDashboard({
const [showIncubationDialog, setShowIncubationDialog] = useState(false);
const [showEvolutionDialog, setShowEvolutionDialog] = useState(false);
// ─── First Hatch Tour ───
const firstHatchTour = useFirstHatchTour();
useFirstHatchTourActivation({
companions,
isLoading: false, // companions are already loaded at this point
tour: firstHatchTour,
profileOnboardingDone: profile?.onboardingDone,
});
const isFirstHatchTourActive = firstHatchTour.state.isActive;
// The required phrase for the first-hatch post
const firstHatchPhrase = useMemo(() => buildHatchPhrase(companion.name), [companion.name]);
// Auto-advance from idle -> show_hatch_card (immediately)
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('idle')) {
firstHatchTour.actions.advance(); // -> show_hatch_card
}
}, [isFirstHatchTourActive, firstHatchTour]);
// Show the inline first-hatch card for all pre-hatch steps
const showFirstHatchCard = isFirstHatchTourActive && firstHatchTour.isAnyStep(
'show_hatch_card', 'egg_glowing_waiting_click',
'egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3',
);
// Detect hatch post completion for the first-hatch tour
const { user } = useCurrentUser();
const { nostr } = useNostr();
const tourAwaitingPost = isFirstHatchTourActive && firstHatchTour.isStep('show_hatch_card');
const { data: tourPostFound } = useQuery({
queryKey: ['first-hatch-tour-post', user?.pubkey, companion.name],
queryFn: async () => {
if (!user?.pubkey) return false;
const events = await nostr.query([{
kinds: [1],
authors: [user.pubkey],
limit: 20,
}]);
return events.some(e => isValidHatchPost(e, companion.name));
},
enabled: tourAwaitingPost && !!user?.pubkey,
refetchInterval: 5000,
staleTime: 3000,
});
// When the post is found during show_hatch_card, show the completed state
// for 2 seconds so the user sees the checkmark, then auto-advance to glowing.
useEffect(() => {
if (!tourPostFound || !isFirstHatchTourActive) return;
if (firstHatchTour.isStep('show_hatch_card')) {
const timer = setTimeout(() => {
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
}, 2000);
return () => clearTimeout(timer);
}
}, [tourPostFound, isFirstHatchTourActive, firstHatchTour]);
// Fake pointer hint: after 10s on glowing_waiting_click, show hint; repeat every 5s
const [showClickHint, setShowClickHint] = useState(false);
useEffect(() => {
if (!isFirstHatchTourActive || !firstHatchTour.isStep('egg_glowing_waiting_click')) {
setShowClickHint(false);
return;
}
const initial = setTimeout(() => setShowClickHint(true), 10000);
const repeat = setInterval(() => setShowClickHint(true), 5000);
return () => { clearTimeout(initial); clearInterval(repeat); };
}, [isFirstHatchTourActive, firstHatchTour]);
// Handle egg click during the tour (advance crack stages)
const handleTourEggClick = useCallback(() => {
if (!isFirstHatchTourActive) return;
setShowClickHint(false);
if (firstHatchTour.isStep('egg_glowing_waiting_click')) {
firstHatchTour.actions.advance(); // -> egg_crack_stage_1
} else if (firstHatchTour.isStep('egg_crack_stage_1')) {
firstHatchTour.actions.advance(); // -> egg_crack_stage_2
} else if (firstHatchTour.isStep('egg_crack_stage_2')) {
firstHatchTour.actions.advance(); // -> egg_crack_stage_3
} else if (firstHatchTour.isStep('egg_crack_stage_3')) {
firstHatchTour.actions.advance(); // -> egg_opening
}
}, [isFirstHatchTourActive, firstHatchTour]);
// Auto-advance for opening -> hatching -> complete (with hatch mutation)
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('egg_opening')) {
const timer = setTimeout(() => {
firstHatchTour.actions.advance(); // -> egg_hatching
}, 1500);
return () => clearTimeout(timer);
}
}, [isFirstHatchTourActive, firstHatchTour]);
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('egg_hatching')) {
// Execute the actual hatch mutation, mark onboarding complete on the
// profile event, then complete the tour's local state.
const doHatch = async () => {
try {
await onHatch();
// Persist blobbi_onboarding_done to the Blobbonaut profile (authoritative)
if (profile) {
try {
const updatedTags = updateBlobbonautTags(profile.allTags, {
blobbi_onboarding_done: 'true',
});
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(event);
} catch (e) {
console.error('[FirstHatchTour] Failed to persist onboarding completion to profile:', e);
}
}
} finally {
firstHatchTour.actions.complete();
}
};
const timer = setTimeout(doHatch, 1200);
return () => clearTimeout(timer);
}
}, [isFirstHatchTourActive, firstHatchTour, onHatch, profile, publishEvent, updateProfileEvent]);
// Derive tourVisualState for the egg visual
const tourVisualState = useMemo((): EggTourVisualState => {
if (!isFirstHatchTourActive) return 'idle';
const step = firstHatchTour.state.currentStepId;
switch (step) {
case 'show_hatch_card': return 'show_hatch_card';
case 'egg_glowing_waiting_click': return 'glowing_waiting_click';
case 'egg_crack_stage_1': return 'crack_stage_1';
case 'egg_crack_stage_2': return 'crack_stage_2';
case 'egg_crack_stage_3': return 'crack_stage_3';
case 'egg_opening': return 'opening';
case 'egg_hatching': return 'hatching';
default: return 'idle';
}
}, [isFirstHatchTourActive, firstHatchTour.state.currentStepId]);
// DEV ONLY: Build tour dev actions for the state editor
const tourDevActions = useMemo(() => ({
skipPostRequirement: () => {
if (firstHatchTour.isStep('show_hatch_card')) {
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
}
},
resetTour: () => {
firstHatchTour.actions.reset();
},
currentStepId: firstHatchTour.state.currentStepId,
isCompleted: firstHatchTour.state.isCompleted,
}), [firstHatchTour]);
// State detection for tasks
// Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state
const isIncubating = companion.state === 'incubating';
@@ -1348,8 +1546,6 @@ 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 />
{/* Blobbi Name */}
<div className="flex items-center gap-2 mb-6">
@@ -1363,7 +1559,6 @@ function BlobbiDashboard({
{/* Main Blobbi Visual */}
{isActiveFloatingCompanion ? (
// Show message when Blobbi is active as floating companion
<div className="flex flex-col items-center justify-center size-48 sm:size-56 text-center">
<Footprints className="size-12 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground text-sm">
@@ -1372,7 +1567,6 @@ function BlobbiDashboard({
</div>
) : (
<div className="relative transition-all duration-500">
{/* Subtle glow effect behind the egg */}
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
@@ -1383,15 +1577,34 @@ function BlobbiDashboard({
recipe={hasDevOverride ? undefined : statusRecipe}
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
emotion={effectiveEmotion}
tourVisualState={tourVisualState}
onTourEggClick={handleTourEggClick}
className="size-48 sm:size-56"
/>
{showClickHint && firstHatchTour.isStep('egg_glowing_waiting_click') && (
<div className="absolute bottom-14 inset-x-0 flex items-center justify-center pointer-events-none select-none">
<span className="text-4xl animate-bounce drop-shadow-lg">👆</span>
</div>
)}
</div>
)}
</div>
{/* Stats Section */}
{/* First Hatch Tour: inline card directly below egg (above stats) */}
{showFirstHatchCard && (
<div className="px-4 sm:px-6 mt-2">
<FirstHatchTourCard
blobbiName={companion.name}
requiredPhrase={firstHatchPhrase}
postCompleted={!!tourPostFound || !firstHatchTour.isStep('show_hatch_card')}
onCreatePost={() => setShowPostModal(true)}
currentStep={firstHatchTour.state.currentStepId}
/>
</div>
)}
{/* Stats Section - hidden during first-hatch tour */}
{!isFirstHatchTourActive && (
<div className="px-4 sm:px-6">
{/* Stats Grid - shows projected decay state */}
{/* Only stats below the visibility threshold are shown (centralized in getVisibleStatsWithValues) */}
@@ -1421,9 +1634,7 @@ function BlobbiDashboard({
);
})()}
{/* Inline Activity Area - inside padded container for proper spacing above bottom bar */}
{/* Inline Activity Area */}
{inlineActivity.type === 'music' && (
<div className="mt-6">
<InlineMusicPlayer
@@ -1450,39 +1661,66 @@ function BlobbiDashboard({
</div>
)}
</div>
)}
{/* Bottom Action Bar */}
<BlobbiBottomBar
onBlobbiesClick={() => setShowSelector(true)}
onMissionsClick={() => setShowMissionsModal(true)}
onActionsClick={() => setShowActionsModal(true)}
onShopClick={() => setShowShopModal(true)}
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
isInTaskProcess={isInTaskProcess}
remainingTasksCount={remainingTasksCount}
allTasksComplete={allTasksComplete}
stage={companion.stage}
blobbiNaddr={blobbiNaddr}
onSetAsCompanion={handleSetAsCompanion}
isCurrentCompanion={isCurrentCompanion}
isUpdatingCompanion={isUpdatingCompanion}
onTakePhoto={() => setShowPhotoModal(true)}
onEvolve={
canStartIncubation
? () => setShowIncubationDialog(true)
: canStartEvolution
? () => setShowEvolutionDialog(true)
: isEgg
? onHatch
: onEvolve
}
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
hideEvolveButton={isIncubating || isEvolvingState}
isIncubationAction={canStartIncubation}
isEvolutionAction={canStartEvolution}
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
onDevOpenEditor={() => setShowDevEditor(true)}
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
{/* Mission Surface Card - hidden during first-hatch tour or when user dismissed */}
{!isFirstHatchTourActive && missionCardVisible && (
<div className="px-4 sm:px-6 mt-3">
<MissionSurfaceCard
tasks={taskProcess.tasks}
isInTaskProcess={taskProcess.config.isActive}
processType={taskProcess.config.type}
dailyMissions={dailyMissions.missions}
onViewAll={() => setShowMissionsModal(true)}
onHide={() => handleToggleMissionCard(false)}
/>
</div>
)}
{/* Bottom Action Bar - hidden during first-hatch tour */}
{!isFirstHatchTourActive && (
<BlobbiBottomBar
onBlobbiesClick={() => setShowSelector(true)}
onMissionsClick={() => setShowMissionsModal(true)}
onActionsClick={() => setShowActionsModal(true)}
onShopClick={() => setShowShopModal(true)}
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
isInTaskProcess={isInTaskProcess}
remainingTasksCount={remainingTasksCount}
allTasksComplete={allTasksComplete}
stage={companion.stage}
blobbiNaddr={blobbiNaddr}
onSetAsCompanion={handleSetAsCompanion}
isCurrentCompanion={isCurrentCompanion}
isUpdatingCompanion={isUpdatingCompanion}
onTakePhoto={() => setShowPhotoModal(true)}
onEvolve={
canStartIncubation
? () => setShowIncubationDialog(true)
: canStartEvolution
? () => setShowEvolutionDialog(true)
: isEgg
? onHatch
: onEvolve
}
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
hideEvolveButton={isIncubating || isEvolvingState || isFirstHatchTourActive}
isIncubationAction={canStartIncubation}
isEvolutionAction={canStartEvolution}
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
onDevOpenEditor={() => setShowDevEditor(true)}
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
barPreferences={barPrefs}
onEditBar={() => setShowBarEditor(true)}
/>
)}
{/* Action Bar Editor */}
<ActionBarEditor
open={showBarEditor}
onOpenChange={setShowBarEditor}
preferences={barPrefs}
onUpdate={handleBarPrefsUpdate}
/>
{/* Blobbi Selector Modal */}
@@ -1596,6 +1834,8 @@ function BlobbiDashboard({
onStopEvolution={handleStopEvolution}
isStoppingEvolution={isStoppingEvolution}
availableStages={availableStages}
showMissionCard={missionCardVisible}
onToggleMissionCard={handleToggleMissionCard}
/>
{/* Shop & Inventory Modal (unified) */}
@@ -1617,6 +1857,7 @@ function BlobbiDashboard({
onSuccess={refetchCurrentTasks}
/>
{/* Blobbi Photo Modal - polaroid-style photo capture */}
<BlobbiPhotoModal
open={showPhotoModal}
@@ -1651,6 +1892,7 @@ function BlobbiDashboard({
companion={companion}
onApply={onDevEditorApply}
isUpdating={isDevUpdating}
tourDevActions={tourDevActions}
/>
)}
@@ -1665,39 +1907,6 @@ function BlobbiDashboard({
);
}
// ─── Quick Action Button ──────────────────────────────────────────────────────
interface QuickActionButtonProps {
children: React.ReactNode;
tooltip: string;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
}
function QuickActionButton({ children, tooltip, onClick, disabled, loading }: QuickActionButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={onClick}
disabled={disabled}
className="size-10 rounded-full bg-background/80 backdrop-blur-sm border-border hover:bg-accent hover:border-border transition-all shadow-sm"
>
{loading ? <Loader2 className="size-4 animate-spin" /> : children}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
);
}
// ─── Dashboard Floating Controls ──────────────────────────────────────────────
/**
* Get the appropriate tooltip for the evolve/hatch button based on stage and action state.
*/
@@ -1715,18 +1924,6 @@ function getEvolveTooltip(
return 'Evolve';
}
/** Floating back button for the Blobbi dashboard. */
function BlobbiDashboardFloatingControls({ onBack }: { onBack?: () => void }) {
if (!onBack) return null;
return (
<div className="absolute top-28 sm:top-32 left-4 sm:left-6 flex flex-col gap-2 z-20">
<QuickActionButton tooltip="Go Back" onClick={onBack}>
<ArrowLeft className="size-4" />
</QuickActionButton>
</div>
);
}
// ─── Stat Indicator ───────────────────────────────────────────────────────────
interface StatIndicatorProps {
@@ -2065,12 +2262,18 @@ interface BlobbiBottomBarProps {
hideEvolveButton?: boolean;
isIncubationAction?: boolean;
isEvolutionAction?: boolean;
// ── Action bar preferences ──
barPreferences: ActionBarPreferences;
onEditBar: () => void;
// ── Dev-only actions ──
onDevInstantTransition?: () => void;
onDevOpenEditor?: () => void;
onDevOpenEmotionPanel?: () => void;
}
/** Handler map keyed by BarItemId so the bar can generically call the right action */
type BarItemHandlers = Record<BarItemId, () => void>;
function BlobbiBottomBar({
onBlobbiesClick,
onMissionsClick,
@@ -2092,21 +2295,74 @@ function BlobbiBottomBar({
hideEvolveButton = false,
isIncubationAction = false,
isEvolutionAction = false,
// Bar preferences
barPreferences,
onEditBar,
// Dev-only props
onDevInstantTransition,
onDevOpenEditor,
onDevOpenEmotionPanel,
}: BlobbiBottomBarProps) {
// Determine what to show on missions badge:
// - If all tasks complete during active process: show "!"
// - If tasks remaining during active process: show count
// - Otherwise: no badge
// Works for BOTH incubating (hatch) and evolving processes
const missionsBadge = allTasksComplete ? '!' : (isInTaskProcess && remainingTasksCount && remainingTasksCount > 0 ? remainingTasksCount : undefined);
const canBeCompanion = stage !== 'egg';
const showEvolveButton = stage !== 'adult' && !hideEvolveButton;
// Handler map for customizable items
const handlers: BarItemHandlers = useMemo(() => ({
blobbies: onBlobbiesClick,
missions: onMissionsClick,
items: onShopClick,
take_photo: onTakePhoto,
set_companion: onSetAsCompanion,
}), [onBlobbiesClick, onMissionsClick, onShopClick, onTakePhoto, onSetAsCompanion]);
// Icon map for customizable items
const iconMap: Record<BarItemId, React.ReactNode> = useMemo(() => ({
blobbies: <Egg className="size-4" />,
missions: <Target className="size-4" />,
items: <Package className="size-4" />,
take_photo: <Camera className="size-4" />,
set_companion: <Footprints className={cn('size-4', isCurrentCompanion && 'text-green-500')} />,
}), [isCurrentCompanion]);
// Label map
const labelMap: Record<BarItemId, string> = {
blobbies: 'Blobbies',
missions: 'Missions',
items: 'Items',
take_photo: 'Photo',
set_companion: isCurrentCompanion ? 'Companion' : 'Companion',
};
// Badge map
const badgeMap: Record<BarItemId, { badge?: number | string; variant?: 'default' | 'warning' | 'success' }> = useMemo(() => ({
blobbies: {
badge: needyBlobbiesCount && needyBlobbiesCount > 0 ? needyBlobbiesCount : undefined,
variant: needyBlobbiesCount && needyBlobbiesCount > 0 ? 'warning' as const : 'default' as const,
},
missions: {
badge: missionsBadge,
variant: allTasksComplete ? 'success' as const : 'default' as const,
},
items: {},
take_photo: {},
set_companion: {},
}), [needyBlobbiesCount, missionsBadge, allTasksComplete]);
// Visible custom slots from preferences
const visibleSlots = getVisibleSlots(barPreferences);
// Set of item IDs currently visible in the bar (used to skip duplicates in More)
const visibleBarIds = useMemo(() => getVisibleBarIds(barPreferences), [barPreferences]);
// Split into left group (before center) and right group (after center)
// Distribute: first half to left, rest to right
const halfIdx = Math.ceil(visibleSlots.length / 2);
const leftSlots = visibleSlots.slice(0, halfIdx);
const rightSlots = visibleSlots.slice(halfIdx);
return (
<div className="mt-6 pt-2">
<div className="bg-card/95 backdrop-blur-md border border-border rounded-2xl px-1.5 sm:px-3 py-2 shadow-lg overflow-hidden">
@@ -2114,23 +2370,20 @@ function BlobbiBottomBar({
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-0.5 sm:gap-2">
{/* Left Group - aligned to end (closer to center) */}
<div className="flex items-center justify-end gap-0 sm:gap-1 overflow-hidden">
<BottomBarButton
onClick={onBlobbiesClick}
icon={<Egg className="size-4" />}
label="Blobbies"
badge={needyBlobbiesCount && needyBlobbiesCount > 0 ? needyBlobbiesCount : undefined}
badgeVariant={needyBlobbiesCount && needyBlobbiesCount > 0 ? 'warning' : 'default'}
/>
<BottomBarButton
onClick={onMissionsClick}
icon={<Target className="size-4" />}
label="Missions"
badge={missionsBadge}
badgeVariant={allTasksComplete ? 'success' : 'default'}
/>
{leftSlots.map((slot) => (
<BottomBarButton
key={slot.id}
onClick={handlers[slot.id]}
icon={iconMap[slot.id]}
label={labelMap[slot.id]}
badge={badgeMap[slot.id].badge}
badgeVariant={badgeMap[slot.id].variant}
highlighted={slot.highlighted}
/>
))}
</div>
{/* Center Action Button */}
{/* Center Action Button (fixed) */}
<button
onClick={onActionsClick}
className="flex items-center justify-center size-11 sm:size-12 -mt-3 sm:-mt-4 mx-1 sm:mx-2 rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 active:scale-95 transition-all border-4 border-background shrink-0"
@@ -2140,9 +2393,19 @@ function BlobbiBottomBar({
{/* Right Group - aligned to start (closer to center) */}
<div className="flex items-center justify-start gap-0 sm:gap-1 overflow-hidden">
<BottomBarButton onClick={onShopClick} icon={<Package className="size-4" />} label="Items" />
{rightSlots.map((slot) => (
<BottomBarButton
key={slot.id}
onClick={handlers[slot.id]}
icon={iconMap[slot.id]}
label={labelMap[slot.id]}
badge={badgeMap[slot.id].badge}
badgeVariant={badgeMap[slot.id].variant}
highlighted={slot.highlighted}
/>
))}
{/* 3-dots menu */}
{/* More dropdown (fixed) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@@ -2153,11 +2416,32 @@ function BlobbiBottomBar({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem onClick={onTakePhoto}>
<Camera className="size-4 mr-2" />
Take a Photo
</DropdownMenuItem>
{canBeCompanion && (
{/* Show items in More only when they're NOT already visible in the bar */}
{!visibleBarIds.has('blobbies') && (
<DropdownMenuItem onClick={onBlobbiesClick}>
<Egg className="size-4 mr-2" />
Blobbies
</DropdownMenuItem>
)}
{!visibleBarIds.has('items') && (
<DropdownMenuItem onClick={onShopClick}>
<Package className="size-4 mr-2" />
Items
</DropdownMenuItem>
)}
{!visibleBarIds.has('missions') && (
<DropdownMenuItem onClick={onMissionsClick}>
<Target className="size-4 mr-2" />
Missions
</DropdownMenuItem>
)}
{!visibleBarIds.has('take_photo') && (
<DropdownMenuItem onClick={onTakePhoto}>
<Camera className="size-4 mr-2" />
Take a Photo
</DropdownMenuItem>
)}
{canBeCompanion && !visibleBarIds.has('set_companion') && (
<DropdownMenuItem onClick={onSetAsCompanion} disabled={isUpdatingCompanion}>
<Footprints className={cn('size-4 mr-2', isCurrentCompanion && 'text-green-500')} />
{isCurrentCompanion ? 'Current Companion' : 'Set as Companion'}
@@ -2175,6 +2459,11 @@ function BlobbiBottomBar({
View Blobbi
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEditBar}>
<Settings2 className="size-4 mr-2" />
Edit action bar
</DropdownMenuItem>
{/* DEV ONLY: Developer tools */}
{isLocalhostDev() && (onDevInstantTransition || onDevOpenEditor || onDevOpenEmotionPanel) && (
<>
@@ -2218,9 +2507,11 @@ interface BottomBarButtonProps {
badge?: number | string;
/** Badge color variant */
badgeVariant?: 'default' | 'warning' | 'success';
/** Show subtle highlight ring around this button */
highlighted?: boolean;
}
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default' }: BottomBarButtonProps) {
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default', highlighted }: BottomBarButtonProps) {
// Determine if badge should show
const showBadge = badge !== undefined && (typeof badge === 'string' || badge > 0);
@@ -2234,7 +2525,10 @@ function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default'
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]"
className={cn(
"flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]",
highlighted && "ring-1 ring-primary/30 bg-accent/30",
)}
>
<div className="relative">
{icon}
+11 -1
View File
@@ -109,6 +109,14 @@ export default {
'highlight-fade': {
from: { backgroundColor: 'hsl(var(--primary) / 0.10)' },
to: { backgroundColor: 'transparent' }
},
'collapsible-down': {
from: { height: '0' },
to: { height: 'var(--radix-collapsible-content-height)' }
},
'collapsible-up': {
from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: '0' }
}
},
animation: {
@@ -116,7 +124,9 @@ export default {
'accordion-up': 'accordion-up 0.2s ease-out',
'pending-glow': 'pending-glow 2.5s ease-in-out infinite',
'badge-spotlight': 'badge-spotlight 8s linear infinite',
'highlight-fade': 'highlight-fade 1.5s ease-out forwards'
'highlight-fade': 'highlight-fade 1.5s ease-out forwards',
'collapsible-down': 'collapsible-down 0.2s ease-out',
'collapsible-up': 'collapsible-up 0.2s ease-out'
}
}
},