e159e5bb6d
- Add mission pool with 8 missions (interact, feed, clean, sing, play_music, sleep, take_photo, change_shape) - Implement weighted random selection for 3 daily missions per user - Add localStorage persistence with automatic daily reset - Track progress from all Blobbi actions (inventory, direct actions, sleep, photo, profile shape) - Add DailyMissionsPanel UI component with progress bars and claim buttons - Integrate daily missions section into BlobbiMissionsModal - Each mission rewards 80-110 coins depending on difficulty
650 lines
21 KiB
TypeScript
650 lines
21 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useForm, useFieldArray } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useToast } from '@/hooks/useToast';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Loader2, Upload, ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react';
|
|
import { NSchema as n } from '@nostrify/nostrify';
|
|
import type { NostrMetadata } from '@nostrify/nostrify';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import { z } from 'zod';
|
|
import { IntroImage } from '@/components/IntroImage';
|
|
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
|
import { isValidAvatarShape } from '@/lib/avatarShape';
|
|
import { trackDailyMissionProgress } from '@/blobbi/actions';
|
|
|
|
// Extended form schema that includes custom fields and avatar shape
|
|
const formSchema = n.metadata().extend({
|
|
fields: z.array(z.object({
|
|
label: z.string(),
|
|
value: z.string(),
|
|
})).optional(),
|
|
shape: z.string().optional(),
|
|
});
|
|
|
|
type ExtendedMetadata = z.infer<typeof formSchema>;
|
|
|
|
interface EditProfileFormProps {
|
|
/** Called whenever form values change — used by the live preview */
|
|
onValuesChange?: (values: Partial<NostrMetadata>) => void;
|
|
}
|
|
|
|
export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange }) => {
|
|
const queryClient = useQueryClient();
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
|
|
const { user, metadata, event } = useCurrentUser();
|
|
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
|
const { toast } = useToast();
|
|
|
|
// Crop dialog state
|
|
const [cropState, setCropState] = useState<{
|
|
open: boolean;
|
|
imageSrc: string;
|
|
aspect: number;
|
|
field: 'picture' | 'banner';
|
|
title: string;
|
|
} | null>(null);
|
|
|
|
// Parse existing fields from raw event content
|
|
const parseFields = (): Array<{ label: string; value: string }> => {
|
|
if (!event) return [];
|
|
try {
|
|
const parsed = JSON.parse(event.content);
|
|
if (Array.isArray(parsed.fields)) {
|
|
return parsed.fields
|
|
.filter((f: unknown) => Array.isArray(f) && f.length >= 2)
|
|
.map((f: string[]) => ({ label: f[0], value: f[1] }));
|
|
}
|
|
} catch {
|
|
// Invalid JSON or no fields
|
|
}
|
|
return [];
|
|
};
|
|
|
|
// Parse existing shape from raw event content
|
|
const parseShape = (): string => {
|
|
if (!event) return '';
|
|
try {
|
|
const parsed = JSON.parse(event.content);
|
|
if (isValidAvatarShape(parsed.shape)) return parsed.shape;
|
|
} catch { /* ignore */ }
|
|
return '';
|
|
};
|
|
|
|
// Initialize the form with default values
|
|
const form = useForm<ExtendedMetadata>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
name: '',
|
|
about: '',
|
|
picture: '',
|
|
banner: '',
|
|
website: '',
|
|
nip05: '',
|
|
lud16: '',
|
|
bot: false,
|
|
fields: [],
|
|
shape: '',
|
|
},
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const { fields, append, remove } = useFieldArray({ control: form.control as any, name: 'fields' });
|
|
|
|
// Update form values when user data is loaded
|
|
useEffect(() => {
|
|
if (metadata) {
|
|
const existingFields = parseFields();
|
|
form.reset({
|
|
name: metadata.name || '',
|
|
about: metadata.about || '',
|
|
picture: metadata.picture || '',
|
|
banner: metadata.banner || '',
|
|
website: metadata.website || '',
|
|
nip05: metadata.nip05 || '',
|
|
lud16: metadata.lud16 || '',
|
|
bot: metadata.bot || false,
|
|
fields: existingFields,
|
|
shape: parseShape(),
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [metadata, event]);
|
|
|
|
// Propagate live preview changes
|
|
const notifyChange = useCallback(() => {
|
|
if (!onValuesChange) return;
|
|
const v = form.getValues();
|
|
onValuesChange({
|
|
name: v.name,
|
|
display_name: v.display_name,
|
|
about: v.about,
|
|
picture: v.picture,
|
|
banner: v.banner,
|
|
website: v.website,
|
|
nip05: v.nip05,
|
|
lud16: v.lud16,
|
|
bot: v.bot,
|
|
shape: v.shape,
|
|
} as Partial<NostrMetadata>);
|
|
}, [form, onValuesChange]);
|
|
|
|
// Watch all fields and propagate
|
|
useEffect(() => {
|
|
const sub = form.watch(() => notifyChange());
|
|
return () => sub.unsubscribe();
|
|
}, [form, notifyChange]);
|
|
|
|
// Open crop dialog when user picks a file
|
|
const openCropDialog = (file: File, field: 'picture' | 'banner') => {
|
|
const objectUrl = URL.createObjectURL(file);
|
|
setCropState({
|
|
open: true,
|
|
imageSrc: objectUrl,
|
|
aspect: field === 'picture' ? 1 : 3,
|
|
field,
|
|
title: field === 'picture' ? 'Crop Profile Picture' : 'Crop Banner Image',
|
|
});
|
|
};
|
|
|
|
// Handle cropped blob — upload it
|
|
const handleCropConfirm = async (blob: Blob) => {
|
|
if (!cropState) return;
|
|
const { field, imageSrc } = cropState;
|
|
setCropState(null);
|
|
URL.revokeObjectURL(imageSrc);
|
|
|
|
const file = new File([blob], `${field}.jpg`, { type: 'image/jpeg' });
|
|
try {
|
|
const [[, url]] = await uploadFile(file);
|
|
form.setValue(field, url);
|
|
notifyChange();
|
|
toast({
|
|
title: 'Success',
|
|
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
|
|
});
|
|
} catch (error) {
|
|
console.error(`Failed to upload ${field}:`, error);
|
|
toast({
|
|
title: 'Error',
|
|
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCropCancel = () => {
|
|
if (cropState) {
|
|
URL.revokeObjectURL(cropState.imageSrc);
|
|
setCropState(null);
|
|
}
|
|
};
|
|
|
|
const onSubmit = async (values: ExtendedMetadata) => {
|
|
if (!user) {
|
|
toast({
|
|
title: 'Error',
|
|
description: 'You must be logged in to update your profile',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Extract fields, shape, and other metadata
|
|
const { fields: customFields, shape, ...standardMetadata } = values;
|
|
|
|
// Combine existing metadata with new values
|
|
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
|
|
|
|
// Add shape only if set (an emoji string)
|
|
if (shape && isValidAvatarShape(shape)) {
|
|
data.shape = shape;
|
|
} else {
|
|
delete data.shape;
|
|
}
|
|
|
|
// Clean up empty values in standard metadata
|
|
for (const key in data) {
|
|
if (data[key] === '') {
|
|
delete data[key];
|
|
}
|
|
}
|
|
|
|
// Add custom fields if they exist (convert to array format)
|
|
if (customFields && customFields.length > 0) {
|
|
const nonEmptyFields = customFields.filter(f => f.label.trim() && f.value.trim());
|
|
if (nonEmptyFields.length > 0) {
|
|
data.fields = nonEmptyFields.map(f => [f.label, f.value]);
|
|
}
|
|
}
|
|
|
|
// Publish the metadata event (kind 0)
|
|
await publishEvent({
|
|
kind: 0,
|
|
content: JSON.stringify(data),
|
|
});
|
|
|
|
// Track daily mission progress if user set a blobbi shape as their avatar
|
|
if (shape && shape.startsWith('blobbi:')) {
|
|
trackDailyMissionProgress('change_shape', 1, user.pubkey);
|
|
}
|
|
|
|
// Invalidate queries to refresh the data
|
|
queryClient.invalidateQueries({ queryKey: ['logins'] });
|
|
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
|
|
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Your profile has been updated',
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to update profile:', error);
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to update your profile. Please try again.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Intro */}
|
|
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
|
|
<IntroImage src="/profile-intro.png" />
|
|
<div className="min-w-0">
|
|
<h2 className="text-sm font-semibold">Your Identity</h2>
|
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Crop dialog */}
|
|
{cropState && (
|
|
<ImageCropDialog
|
|
open={cropState.open}
|
|
imageSrc={cropState.imageSrc}
|
|
aspect={cropState.aspect}
|
|
title={cropState.title}
|
|
onCancel={handleCropCancel}
|
|
onCrop={handleCropConfirm}
|
|
/>
|
|
)}
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5 px-3">
|
|
<div className="border-b border-border pb-5">
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-xs font-medium">Name</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="Your name" {...field} className="h-9" />
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
This is your display name that will be displayed to others.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-b border-border pb-5">
|
|
<FormField
|
|
control={form.control}
|
|
name="about"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-xs font-medium">Bio</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Tell others about yourself"
|
|
className="resize-none min-h-20"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
A short description about yourself.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-b border-border pb-5">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
<FormField
|
|
control={form.control}
|
|
name="picture"
|
|
render={({ field }) => (
|
|
<ImageUploadField
|
|
field={field}
|
|
label="Profile Picture"
|
|
placeholder="https://example.com/profile.jpg"
|
|
description="Upload an image or provide a URL"
|
|
previewType="square"
|
|
onPickFile={(file) => openCropDialog(file, 'picture')}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="banner"
|
|
render={({ field }) => (
|
|
<ImageUploadField
|
|
field={field}
|
|
label="Banner Image"
|
|
placeholder="https://example.com/banner.jpg"
|
|
description="Wide banner image for your profile"
|
|
previewType="wide"
|
|
onPickFile={(file) => openCropDialog(file, 'banner')}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-b border-border pb-5">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
<FormField
|
|
control={form.control}
|
|
name="website"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-xs font-medium">Website</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="https://yourwebsite.com" {...field} className="h-9" />
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
Your personal website or social link
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="nip05"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-xs font-medium">NIP-05 Identifier</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="you@example.com" {...field} className="h-9" />
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
Your verified Nostr identifier
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="lud16"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-xs font-medium">Lightning Address</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="you@walletofsatoshi.com" {...field} className="h-9" />
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
Your lightning address for receiving zaps
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Profile Fields */}
|
|
<div className="border-b border-border pb-5">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<FormLabel className="text-xs font-medium">Profile Fields</FormLabel>
|
|
<FormDescription className="text-xs mt-1">
|
|
Add custom fields like social links, Bitcoin address, or other info
|
|
</FormDescription>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => append({ label: '', value: '' })}
|
|
className="h-8 text-xs"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
Add Field
|
|
</Button>
|
|
</div>
|
|
|
|
{fields.length > 0 && (
|
|
<div className="space-y-3 pt-2">
|
|
{fields.map((field, index) => (
|
|
<div key={field.id} className="grid grid-cols-[1fr,2fr,auto] gap-2 items-start">
|
|
<FormField
|
|
control={form.control}
|
|
name={`fields.${index}.label`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="Label"
|
|
{...field}
|
|
className="h-9"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name={`fields.${index}.value`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="Value or URL"
|
|
{...field}
|
|
className="h-9"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => remove(index)}
|
|
className="h-9 w-9 text-destructive hover:text-destructive"
|
|
title="Remove field"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Advanced Settings */}
|
|
<div className="border-b border-border pb-5">
|
|
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="w-full justify-between p-0 h-auto hover:bg-transparent"
|
|
>
|
|
<span className="text-xs font-medium text-muted-foreground">Advanced Settings</span>
|
|
{showAdvanced ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="bot"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
|
<div className="space-y-0.5">
|
|
<FormLabel className="text-sm">Bot Account</FormLabel>
|
|
<FormDescription className="text-xs">
|
|
Mark this account as automated or a bot
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
className="scale-90"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
<div className="pt-2 pb-4">
|
|
<Button
|
|
type="submit"
|
|
className="w-full md:w-auto"
|
|
disabled={isPending || isUploading}
|
|
>
|
|
{(isPending || isUploading) && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
Save Profile
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Reusable component for image upload fields
|
|
interface ImageUploadFieldProps {
|
|
field: {
|
|
value: string | undefined;
|
|
onChange: (value: string) => void;
|
|
name: string;
|
|
onBlur: () => void;
|
|
};
|
|
label: string;
|
|
placeholder: string;
|
|
description: string;
|
|
previewType: 'square' | 'wide';
|
|
onPickFile: (file: File) => void;
|
|
}
|
|
|
|
const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
|
|
field,
|
|
label,
|
|
placeholder,
|
|
description,
|
|
previewType,
|
|
onPickFile,
|
|
}) => {
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
return (
|
|
<FormItem>
|
|
<FormLabel className="text-xs font-medium">{label}</FormLabel>
|
|
<div className="flex flex-col gap-2">
|
|
<FormControl>
|
|
<Input
|
|
placeholder={placeholder}
|
|
name={field.name}
|
|
value={field.value ?? ''}
|
|
onChange={e => field.onChange(e.target.value)}
|
|
onBlur={field.onBlur}
|
|
className="h-9"
|
|
/>
|
|
</FormControl>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
onPickFile(file);
|
|
// Reset input so re-selecting same file triggers onChange
|
|
e.target.value = '';
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="h-8 text-xs"
|
|
>
|
|
<Upload className="h-3 w-3 mr-1.5" />
|
|
Upload & Crop
|
|
</Button>
|
|
{field.value && (
|
|
<div className={`h-8 ${previewType === 'square' ? 'w-8' : 'w-20'} rounded overflow-hidden border`}>
|
|
<img
|
|
src={field.value}
|
|
alt={`${label} preview`}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<FormDescription className="text-xs">
|
|
{description}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
};
|