Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 853b5ead9c | |||
| a5746ee915 | |||
| fa3376ac4f | |||
| 6f0c10fe9b | |||
| 2f1bf0bca5 | |||
| 9be98d9a8d | |||
| c4dd8e7c3d | |||
| 42832b72e3 | |||
| e77436d02a | |||
| 302d7732ef | |||
| b09b4938d2 | |||
| 0a0d6de111 | |||
| 4e9b893822 | |||
| c60e87ad65 | |||
| 8e07ad515a | |||
| b4c4b8eb21 | |||
| 23ee6f1196 | |||
| 4b97baa428 | |||
| ade9eb4999 | |||
| 0f02563d3a | |||
| 38630be23d | |||
| 9b8cff63da | |||
| e13473809d | |||
| 00a9ad20de | |||
| d28364531b | |||
| f3eb4adba5 | |||
| 0487586af9 | |||
| 2c737ca322 | |||
| c9823055fd | |||
| d2cd5f22bf |
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.3.1"
|
||||
versionName "2.4.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.3.1;
|
||||
MARKETING_VERSION = 2.4.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,7 +325,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.3.1;
|
||||
MARKETING_VERSION = 2.4.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.1",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.3.1",
|
||||
"version": "2.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for wall edit events */
|
||||
export const KIND_WALL_EDIT = 16769;
|
||||
/** Kind for custom profile tabs event */
|
||||
export const KIND_PROFILE_TABS = 16769;
|
||||
|
||||
/** Required themes for evolve task */
|
||||
export const EVOLVE_REQUIRED_THEMES = 3;
|
||||
@@ -117,7 +117,7 @@ export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolea
|
||||
* 2. Create 3 Color Moments (kind 3367)
|
||||
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
|
||||
* 4. Interact 21 times (tracked via companion.tasks cache)
|
||||
* 5. Edit Wall once (kind 16769)
|
||||
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 6. Maintain All Stats >= 80
|
||||
@@ -165,14 +165,14 @@ export function useEvolveTasks(
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Only need 1 valid evolve post
|
||||
},
|
||||
// Wall edits after start
|
||||
// Custom profile tabs after start
|
||||
{
|
||||
kinds: [KIND_WALL_EDIT],
|
||||
kinds: [KIND_PROFILE_TABS],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need 1
|
||||
},
|
||||
// Profile metadata after start (for Blobbi shape check)
|
||||
// Profile metadata after start (for Blobbi shape check + profile edit mission)
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
@@ -197,8 +197,8 @@ export function useEvolveTasks(
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const wallEditEvents = events.filter(e =>
|
||||
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
|
||||
const profileTabsEvents = events.filter(e =>
|
||||
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Get latest profile after start
|
||||
@@ -211,7 +211,7 @@ export function useEvolveTasks(
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
wallEditEvents,
|
||||
profileTabsEvents,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
@@ -287,20 +287,21 @@ export function useEvolveTasks(
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// 5. Edit Wall once (PERSISTENT)
|
||||
const wallEditCount = data?.wallEditEvents?.length ?? 0;
|
||||
const hasWallEdit = wallEditCount >= 1;
|
||||
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
|
||||
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
|
||||
const hasMetadataEdit = !!data?.profileAfter;
|
||||
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
|
||||
tasks.push({
|
||||
id: 'edit_wall',
|
||||
name: 'Edit Your Wall',
|
||||
description: 'Customize your profile wall',
|
||||
current: hasWallEdit ? 1 : 0,
|
||||
id: 'edit_profile',
|
||||
name: 'Edit Your Profile',
|
||||
description: 'Update your profile info or customize your profile tabs',
|
||||
current: hasProfileEdit ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasWallEdit,
|
||||
completed: hasProfileEdit,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/settings/profile',
|
||||
actionLabel: 'Edit Wall',
|
||||
actionLabel: 'Edit Profile',
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,7 @@ export {
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
buildHatchPhrase,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
@@ -70,7 +71,7 @@ export {
|
||||
useEvolveTasks,
|
||||
getEvolveInteractionCount,
|
||||
isValidEvolvePost,
|
||||
KIND_WALL_EDIT,
|
||||
KIND_PROFILE_TABS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_POSTS,
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { useKeyboardVisible } from '@/hooks/useKeyboardVisible';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Skeleton shown in the content area while a lazy page chunk is loading. */
|
||||
@@ -109,10 +108,6 @@ function MainLayoutInner() {
|
||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||
const { config } = useAppContext();
|
||||
const { hidden: navHidden } = useScrollDirection(scrollContainer);
|
||||
// Mounts the keyboard detector globally so the `keyboard-visible` class
|
||||
// on <html> is always up-to-date for CSS-only consumers (Dialog, AlertDialog).
|
||||
useKeyboardVisible();
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={openDrawer}>
|
||||
<NavHiddenContext.Provider value={navHidden}>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { AddrCoords } from '@/hooks/useEvent';
|
||||
import QRCode from 'qrcode';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getContentWarning } from '@/lib/contentWarning';
|
||||
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
|
||||
@@ -23,6 +25,9 @@ import { parseDimToAspectRatio } from '@/components/MediaCollage';
|
||||
import { isWeatherFieldLabel } from '@/lib/weatherStation';
|
||||
import { WeatherStationCard } from '@/components/WeatherStationCard';
|
||||
|
||||
/** Media-native kinds shown in the sidebar (excludes kind 1 text notes and kind 1111 comments). */
|
||||
const SIDEBAR_MEDIA_KINDS = [20, 21, 22, 34236, 36787, 34139, 30054, 30055];
|
||||
|
||||
/** Simple email regex for display purposes. */
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
@@ -65,10 +70,8 @@ interface ProfileField {
|
||||
|
||||
interface ProfileRightSidebarProps {
|
||||
fields?: ProfileField[];
|
||||
/** Media events fetched via a dedicated search query (video:true image:true). */
|
||||
mediaEvents?: NostrEvent[];
|
||||
/** Whether the media events are still loading. */
|
||||
mediaLoading?: boolean;
|
||||
/** Pubkey whose media-native events to display in the sidebar. */
|
||||
pubkey?: string;
|
||||
/** Called when a media tile is clicked. If provided, tiles don't navigate. */
|
||||
onMediaClick?: (url: string) => void;
|
||||
/** Override the root element's className (e.g. to show on mobile). */
|
||||
@@ -485,20 +488,37 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp, onMediaClick, className }: ProfileRightSidebarProps) {
|
||||
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) {
|
||||
const { config } = useAppContext();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Fetch media-native events directly using a kind whitelist (no search extension).
|
||||
const { data: sidebarEvents, isPending: mediaLoading } = useQuery({
|
||||
queryKey: ['sidebar-media', pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const events = await nostr.query(
|
||||
[{ kinds: SIDEBAR_MEDIA_KINDS, authors: [pubkey!], limit: 20 }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return events.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const media = useMemo(
|
||||
() => extractMedia(mediaEvents ?? [], config.contentWarningPolicy),
|
||||
[mediaEvents, config.contentWarningPolicy],
|
||||
() => extractMedia(sidebarEvents ?? [], config.contentWarningPolicy),
|
||||
[sidebarEvents, config.contentWarningPolicy],
|
||||
);
|
||||
const mediaLoading = mediaLoadingProp ?? false;
|
||||
|
||||
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
|
||||
|
||||
return (
|
||||
<aside className={cn("w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
|
||||
{/* Media Section — only shown when mediaEvents prop is provided */}
|
||||
{mediaEvents !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
{/* Media Section — only shown when pubkey prop is provided */}
|
||||
{pubkey !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
|
||||
{mediaLoading ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -608,7 +628,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
|
||||
)}
|
||||
|
||||
{/* Footer — hidden when used as a fields-only preview */}
|
||||
{mediaEvents !== undefined && <LinkFooter />}
|
||||
{pubkey !== undefined && <LinkFooter />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"dialog-keyboard-aware fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"dialog-keyboard-aware fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(', ')}`);
|
||||
|
||||
|
||||
@@ -9,9 +9,6 @@ import { useEffect, useState } from 'react';
|
||||
*
|
||||
* A threshold of 0.75 (75%) is used — if the visible area is less than 75% of
|
||||
* the layout viewport, we assume the keyboard is open.
|
||||
*
|
||||
* Also toggles a `keyboard-visible` class on `<html>` so pure-CSS components
|
||||
* (e.g. Dialog, AlertDialog) can react to keyboard state without a hook.
|
||||
*/
|
||||
export function useKeyboardVisible(): boolean {
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
@@ -26,7 +23,6 @@ export function useKeyboardVisible(): boolean {
|
||||
const ratio = vv.height / window.innerHeight;
|
||||
const visible = ratio < THRESHOLD;
|
||||
setIsKeyboardVisible(visible);
|
||||
document.documentElement.classList.toggle('keyboard-visible', visible);
|
||||
};
|
||||
|
||||
vv.addEventListener('resize', check);
|
||||
@@ -34,7 +30,6 @@ export function useKeyboardVisible(): boolean {
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener('resize', check);
|
||||
document.documentElement.classList.remove('keyboard-visible');
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -419,11 +419,19 @@ export function useNotifications(): NotificationData {
|
||||
// match because useHasUnreadNotifications uses a 4-element key
|
||||
// ['notifications-unread', pubkey, kindsKey, authorsKey] and setQueryData
|
||||
// requires an exact match (which silently misses the real cache entry).
|
||||
//
|
||||
// NOTE: We intentionally do NOT call invalidateQueries here. Invalidation
|
||||
// triggers an immediate refetch whose queryFn closure may still hold the
|
||||
// old notificationsCursor (from a render before the settings cache update
|
||||
// propagates). That stale refetch re-queries the relay with the old
|
||||
// `since` value, finds the same "unread" events, returns `true`, and
|
||||
// overwrites the `false` we just set — causing the dot to reappear.
|
||||
// The 60-second poll (or real-time subscription) will naturally
|
||||
// re-evaluate once the cursor has fully propagated.
|
||||
queryClient.setQueriesData<boolean>(
|
||||
{ queryKey: ['notifications-unread', user.pubkey] },
|
||||
false,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications-unread', user.pubkey] });
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notifications as read:', error);
|
||||
optimisticCursor.current = null;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -55,13 +55,6 @@
|
||||
bottom: calc(1.5rem + var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* When the virtual keyboard is open, push dialogs toward the top of the
|
||||
viewport so they aren't hidden behind the keyboard. */
|
||||
.keyboard-visible .dialog-keyboard-aware {
|
||||
top: 2rem !important;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
|
||||
/* Position above mobile bottom nav + safe area + arc overhang (28px) */
|
||||
.bottom-mobile-nav {
|
||||
bottom: calc(var(--bottom-nav-height) + 28px + env(safe-area-inset-bottom, 0px));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+410
-116
@@ -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}
|
||||
|
||||
@@ -1793,7 +1793,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
}, []);
|
||||
|
||||
useLayoutOptions(pubkey ? {
|
||||
rightSidebar: <ProfileRightSidebar fields={fields} mediaEvents={mediaEvents} mediaLoading={mediaPending} onMediaClick={handleSidebarMediaClick} />,
|
||||
rightSidebar: <ProfileRightSidebar fields={fields} pubkey={pubkey} onMediaClick={handleSidebarMediaClick} />,
|
||||
showFAB: !(activeTab === 'wall' && !profileFollowsMe),
|
||||
onFabClick: activeTab === 'wall' ? openWallCompose : undefined,
|
||||
hasSubHeader: true,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useProfileMedia } from '@/hooks/useProfileMedia';
|
||||
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -423,26 +423,6 @@ export function ProfileSettings() {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch media events for the sidebar preview (same query as profile page)
|
||||
const {
|
||||
data: mediaData,
|
||||
isPending: mediaPending,
|
||||
} = useProfileMedia(user?.pubkey);
|
||||
const mediaEvents = useMemo(() => {
|
||||
if (!mediaData?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
const events: import('@nostrify/nostrify').NostrEvent[] = [];
|
||||
for (const page of mediaData.pages) {
|
||||
for (const event of page.events) {
|
||||
if (!seen.has(event.id)) {
|
||||
seen.add(event.id);
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}, [mediaData?.pages]);
|
||||
|
||||
const [cropState, setCropState] = useState<CropState | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showMobilePreview, setShowMobilePreview] = useState(false);
|
||||
@@ -676,7 +656,7 @@ export function ProfileSettings() {
|
||||
|
||||
// Inject live sidebar preview into the app's right sidebar slot
|
||||
useLayoutOptions({
|
||||
rightSidebar: <ProfileRightSidebar fields={previewFields} mediaEvents={mediaEvents} mediaLoading={mediaPending} />,
|
||||
rightSidebar: <ProfileRightSidebar fields={previewFields} pubkey={user?.pubkey} />,
|
||||
});
|
||||
|
||||
if (!user) return <Navigate to="/settings" replace />;
|
||||
|
||||
+11
-1
@@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user