426 lines
20 KiB
TypeScript
426 lines
20 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { Camera, Check, ChevronRight, Clock, Info, Loader2, Megaphone, Palette, Plus, Upload } from 'lucide-react';
|
|
|
|
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
import type { Action } from '@/hooks/useActions';
|
|
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
|
import { countryCodeToFlag, getAllCountries, getGeoDisplayName } from '@/lib/countries';
|
|
import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
|
import { formatSats, satsToUSDWhole, usdToSats } from '@/lib/bitcoin';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface CreateActionDialogProps {
|
|
countryCode?: string;
|
|
communityATag?: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
interface CreateActionFormState {
|
|
title: string;
|
|
description: string;
|
|
type: Action['type'];
|
|
pledgeUsd: string;
|
|
startDate: string;
|
|
startTime: string;
|
|
deadline: string;
|
|
time: string;
|
|
coverImage: string;
|
|
selectedCountry: string;
|
|
timezone: string;
|
|
}
|
|
|
|
function unixSecondsInTimezone(year: number, month: number, day: number, hours: number, minutes: number, timezone: string): number {
|
|
const utcGuess = Date.UTC(year, month - 1, day, hours, minutes, 0);
|
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: timezone,
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
const parts = Object.fromEntries(
|
|
formatter.formatToParts(new Date(utcGuess)).map((p) => [p.type, p.value]),
|
|
);
|
|
const asWallClock = Date.UTC(
|
|
Number(parts.year),
|
|
Number(parts.month) - 1,
|
|
Number(parts.day),
|
|
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
|
Number(parts.minute),
|
|
Number(parts.second),
|
|
);
|
|
return Math.floor((utcGuess + (utcGuess - asWallClock)) / 1000);
|
|
}
|
|
|
|
function parseCommunityAuthor(communityATag: string): string | undefined {
|
|
const [, pubkey] = communityATag.split(':');
|
|
return pubkey || undefined;
|
|
}
|
|
|
|
function CreateActionForm({
|
|
formData,
|
|
setFormData,
|
|
isSubmitting,
|
|
handleSubmit,
|
|
onCancel,
|
|
pageCountryCode,
|
|
}: {
|
|
formData: CreateActionFormState;
|
|
setFormData: (data: CreateActionFormState) => void;
|
|
isSubmitting: boolean;
|
|
handleSubmit: () => void;
|
|
onCancel: () => void;
|
|
pageCountryCode?: string;
|
|
}) {
|
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
|
const { data: btcPrice } = useBtcPrice();
|
|
const allCountries = useMemo(() => getAllCountries(), []);
|
|
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
|
|
const [selectedDefaultId, setSelectedDefaultId] = useState<string | null>(() => {
|
|
const match = DEFAULT_ACTION_COVERS.find((c) => c.url === formData.coverImage);
|
|
return match?.id ?? null;
|
|
});
|
|
|
|
const countryOptions = useMemo(() => {
|
|
const options: Array<{ value: string; label: string; flag: string }> = [
|
|
{ value: 'none', label: 'No country', flag: '🌍' },
|
|
];
|
|
if (pageCountryCode) {
|
|
options.push({
|
|
value: pageCountryCode,
|
|
label: getGeoDisplayName(pageCountryCode),
|
|
flag: countryCodeToFlag(pageCountryCode),
|
|
});
|
|
}
|
|
allCountries.forEach((country) => {
|
|
if (country.code !== pageCountryCode) {
|
|
options.push({ value: country.code, label: country.name, flag: countryCodeToFlag(country.code) });
|
|
}
|
|
});
|
|
return options;
|
|
}, [pageCountryCode, allCountries]);
|
|
|
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
try {
|
|
const [[, url]] = await uploadFile(file);
|
|
setFormData({ ...formData, coverImage: url });
|
|
setSelectedDefaultId(null);
|
|
} catch (error) {
|
|
console.error('Failed to upload cover image:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-4 py-2 px-4 max-w-full overflow-hidden">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="country">Country (optional)</Label>
|
|
<Popover open={countryPickerOpen} onOpenChange={setCountryPickerOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" role="combobox" aria-expanded={countryPickerOpen} className="w-full justify-between">
|
|
{formData.selectedCountry ? (
|
|
<span className="flex items-center gap-2">
|
|
<span>{countryCodeToFlag(formData.selectedCountry)}</span>
|
|
<span>{getGeoDisplayName(formData.selectedCountry)}</span>
|
|
</span>
|
|
) : (
|
|
<span>No country</span>
|
|
)}
|
|
<ChevronRight className="ml-2 h-4 w-4 shrink-0 opacity-50 rotate-90" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[280px] p-0" align="start" sideOffset={4}>
|
|
<Command>
|
|
<CommandInput placeholder="Search..." />
|
|
<CommandList>
|
|
<CommandEmpty>No results found.</CommandEmpty>
|
|
<CommandGroup>
|
|
{countryOptions.map((option) => (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={`${option.label} ${option.value}`}
|
|
onSelect={() => {
|
|
setFormData({ ...formData, selectedCountry: option.value === 'none' ? '' : option.value });
|
|
setCountryPickerOpen(false);
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<span>{option.flag}</span>
|
|
<span className="flex-1">{option.label}</span>
|
|
<Check className={cn('h-4 w-4', (formData.selectedCountry || 'none') === option.value ? 'opacity-100' : 'opacity-0')} />
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Cover image</Label>
|
|
<div className="relative w-full h-32 rounded-lg overflow-hidden border border-border">
|
|
<img src={formData.coverImage || DEFAULT_COVER_IMAGE} alt="Cover preview" className="w-full h-full object-cover" />
|
|
</div>
|
|
<div className="relative w-full overflow-hidden">
|
|
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1">
|
|
{DEFAULT_ACTION_COVERS.map((cover) => {
|
|
const isActive = selectedDefaultId === cover.id || formData.coverImage === cover.url;
|
|
return (
|
|
<button
|
|
key={cover.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setFormData({ ...formData, coverImage: cover.url });
|
|
setSelectedDefaultId(cover.id);
|
|
}}
|
|
className={cn('relative h-20 w-28 flex-shrink-0 rounded-md overflow-hidden border-2 transition-all', isActive ? 'border-primary ring-2 ring-primary/50' : 'border-border hover:border-primary/50')}
|
|
title={cover.name}
|
|
aria-label={`Select ${cover.name} cover`}
|
|
>
|
|
<img src={cover.url} alt={cover.name} className="w-full h-full object-cover" />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label htmlFor="cover-upload" className="flex-1 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 border border-border rounded-lg hover:bg-primary/10 transition-colors">
|
|
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
|
<span className="text-sm">Upload custom</span>
|
|
</Label>
|
|
<input id="cover-upload" type="file" accept="image/*" className="hidden" onChange={handleFileUpload} disabled={isUploading} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="title">Title</Label>
|
|
<Input id="title" placeholder="What needs to happen?" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
placeholder="Explain the action, evidence, or outcome you want to inspire and what submissions should include..."
|
|
className="min-h-[80px]"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="type">Type</Label>
|
|
<Select value={formData.type} onValueChange={(value) => setFormData({ ...formData, type: value as Action['type'] })}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="photo"><div className="flex items-center gap-2"><Camera className="h-4 w-4" /> Photo</div></SelectItem>
|
|
<SelectItem value="art"><div className="flex items-center gap-2"><Palette className="h-4 w-4" /> Art</div></SelectItem>
|
|
<SelectItem value="info"><div className="flex items-center gap-2"><Info className="h-4 w-4" /> Info</div></SelectItem>
|
|
<SelectItem value="action"><div className="flex items-center gap-2"><Megaphone className="h-4 w-4" /> Direct action</div></SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="pledgeUsd">Pledge amount (USD)</Label>
|
|
<Input id="pledgeUsd" type="number" min={1} step="0.01" placeholder="100" value={formData.pledgeUsd} onChange={(e) => setFormData({ ...formData, pledgeUsd: e.target.value })} />
|
|
<p className="text-xs text-muted-foreground">
|
|
{usdToSats(Number(formData.pledgeUsd), btcPrice) > 0 && btcPrice
|
|
? `${formatSats(usdToSats(Number(formData.pledgeUsd), btcPrice))} sats will be stored (${satsToUSDWhole(usdToSats(Number(formData.pledgeUsd), btcPrice), btcPrice)} at the current rate).`
|
|
: 'Agora stores the pledge in sats on Nostr.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="startDate">Start date (optional)</Label>
|
|
<Input id="startDate" type="date" className="w-full min-w-0" value={formData.startDate} onChange={(e) => setFormData({ ...formData, startDate: e.target.value })} />
|
|
{formData.startDate && <Input id="startTime" type="time" className="w-full min-w-0" value={formData.startTime} onChange={(e) => setFormData({ ...formData, startTime: e.target.value })} />}
|
|
<p className="text-xs text-muted-foreground">
|
|
{!formData.startDate && 'Defaults to now if not specified'}
|
|
{formData.startDate && !formData.startTime && ' • Starts at midnight'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="deadline">Deadline (optional)</Label>
|
|
<Input id="deadline" type="date" className="w-full min-w-0" value={formData.deadline} onChange={(e) => setFormData({ ...formData, deadline: e.target.value })} />
|
|
{formData.deadline && <Input id="time" type="time" className="w-full min-w-0" value={formData.time} onChange={(e) => setFormData({ ...formData, time: e.target.value })} />}
|
|
<p className="text-xs text-muted-foreground">
|
|
{!formData.deadline && 'Open-ended. Add a deadline if urgency matters.'}
|
|
{formData.deadline && !formData.time && ' • Ends at 23:59 local time'}
|
|
</p>
|
|
</div>
|
|
|
|
{(formData.startDate || formData.deadline) && (
|
|
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/50 animate-in slide-in-from-top-2 duration-200">
|
|
<Label className="text-sm font-medium flex items-center gap-2"><Clock className="h-4 w-4" /> Timezone</Label>
|
|
<TimezoneSwitcher value={formData.timezone} onChange={(timezone) => setFormData({ ...formData, timezone })} />
|
|
<p className="text-xs text-muted-foreground">Start and deadline times will be interpreted in this timezone.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-2 p-4 pt-2">
|
|
<Button onClick={handleSubmit} disabled={!formData.title || !formData.description || !formData.pledgeUsd || usdToSats(Number(formData.pledgeUsd), btcPrice) <= 0 || isSubmitting} className="gap-2 w-full">
|
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
|
Create pledge
|
|
</Button>
|
|
<Button variant="outline" onClick={onCancel} className="w-full">Cancel</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function CreateActionDialog({ countryCode, communityATag, open, onOpenChange }: CreateActionDialogProps) {
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const { user } = useCurrentUser();
|
|
const { mutateAsync: createEvent } = useNostrPublish();
|
|
const { data: btcPrice } = useBtcPrice();
|
|
const queryClient = useQueryClient();
|
|
const isMobile = useIsMobile();
|
|
const { toast } = useToast();
|
|
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
|
|
const [formData, setFormData] = useState<CreateActionFormState>({
|
|
title: '',
|
|
description: '',
|
|
type: 'photo',
|
|
pledgeUsd: '',
|
|
startDate: '',
|
|
startTime: '',
|
|
deadline: '',
|
|
time: '',
|
|
coverImage: DEFAULT_COVER_IMAGE,
|
|
selectedCountry: countryCode || '',
|
|
timezone: browserTimezone,
|
|
});
|
|
|
|
const handleSubmit = async () => {
|
|
if (!user) return;
|
|
setIsSubmitting(true);
|
|
try {
|
|
const now = Date.now();
|
|
const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
const dTag = `${slug || 'pledge'}-${now}`;
|
|
const pledgeSats = usdToSats(Number(formData.pledgeUsd), btcPrice);
|
|
if (pledgeSats <= 0) throw new Error('Waiting for BTC/USD price to calculate the pledge amount.');
|
|
const tags: string[][] = [
|
|
['d', dTag],
|
|
['title', formData.title],
|
|
['challenge-type', formData.type],
|
|
['bounty', String(pledgeSats)],
|
|
['t', 'agora-action'],
|
|
['alt', `Agora pledge: ${formData.title}`],
|
|
];
|
|
if (formData.selectedCountry) tags.push(['i', createCountryIdentifier(formData.selectedCountry.toUpperCase())]);
|
|
if (communityATag) {
|
|
const communityAuthor = parseCommunityAuthor(communityATag);
|
|
tags.push(['A', communityATag], ['K', '34550']);
|
|
if (communityAuthor) tags.push(['P', communityAuthor]);
|
|
}
|
|
if (formData.coverImage) tags.push(['image', formData.coverImage]);
|
|
|
|
if (formData.startDate) {
|
|
const [year, month, day] = formData.startDate.split('-').map(Number);
|
|
const [hours, minutes] = formData.startTime ? formData.startTime.split(':').map(Number) : [0, 0];
|
|
tags.push(['start', String(unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone))]);
|
|
}
|
|
if (formData.deadline) {
|
|
const [year, month, day] = formData.deadline.split('-').map(Number);
|
|
const [hours, minutes] = formData.time ? formData.time.split(':').map(Number) : [23, 59];
|
|
tags.push(['deadline', String(unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone))]);
|
|
}
|
|
|
|
await createEvent({ kind: 36639, content: formData.description, tags });
|
|
|
|
await queryClient.invalidateQueries({ queryKey: ['agora-actions'] });
|
|
await queryClient.refetchQueries({ queryKey: ['agora-actions'] });
|
|
if (communityATag) {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['community-actions', communityATag] }),
|
|
queryClient.invalidateQueries({
|
|
predicate: (q) => {
|
|
const [root, aTagsKey] = q.queryKey;
|
|
return root === 'community-activity-feed'
|
|
&& typeof aTagsKey === 'string'
|
|
&& aTagsKey.split(',').includes(communityATag);
|
|
},
|
|
}),
|
|
]);
|
|
}
|
|
|
|
setFormData({
|
|
title: '', description: '', type: 'photo', pledgeUsd: '',
|
|
startDate: '', startTime: '', deadline: '', time: '',
|
|
coverImage: DEFAULT_COVER_IMAGE,
|
|
selectedCountry: countryCode || '',
|
|
timezone: browserTimezone,
|
|
});
|
|
onOpenChange(false);
|
|
toast({ title: 'Pledge created' });
|
|
} catch (error) {
|
|
console.error('Failed to create pledge:', error);
|
|
toast({ title: 'Failed to create pledge', variant: 'destructive' });
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (!user) return null;
|
|
|
|
const description = communityATag
|
|
? 'New community pledge. You can optionally choose a country below.'
|
|
: countryCode
|
|
? `New pledge for ${getGeoDisplayName(countryCode)}.`
|
|
: 'New pledge. You can optionally choose a country below.';
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
|
<DrawerContent className="h-[85dvh] max-h-[85dvh]">
|
|
<DrawerHeader className="text-left">
|
|
<DrawerTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DrawerTitle>
|
|
<DrawerDescription>{description}</DrawerDescription>
|
|
</DrawerHeader>
|
|
<div className="overflow-y-auto flex-1 pb-safe">
|
|
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-md sm:max-w-lg md:max-w-2xl max-h-[85vh] w-[calc(100vw-2rem)] sm:w-full overflow-hidden flex flex-col p-0">
|
|
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
|
|
<DialogTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="overflow-y-auto overflow-x-hidden flex-1 min-h-0">
|
|
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|