web-app/src/modules/nostr-feed/components/RideshareComposer.vue

447 lines
No EOL
14 KiB
Vue

<template>
<Card class="w-full">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">
<Car class="h-5 w-5" />
Create Rideshare Post
</CardTitle>
<Button variant="ghost" size="sm" @click="emit('close')" class="h-8 w-8 p-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription>
Share or find rides with the community
</CardDescription>
</CardHeader>
<CardContent>
<form @submit="onSubmit" class="space-y-4">
<!-- Rideshare Type -->
<FormField v-slot="{ componentField }" name="type">
<FormItem>
<FormLabel>Type *</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select rideshare type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="offering">Offering a ride</SelectItem>
<SelectItem value="seeking">Looking for a ride</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Are you offering a ride or looking for one?
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- From Location -->
<FormField v-slot="{ componentField }" name="fromLocation">
<FormItem>
<FormLabel>From *</FormLabel>
<FormControl>
<Input
placeholder="Pickup location"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Starting location or pickup point
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- To Location -->
<FormField v-slot="{ componentField }" name="toLocation">
<FormItem>
<FormLabel>To *</FormLabel>
<FormControl>
<Input
placeholder="Destination"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Destination or drop-off point
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Date and Time -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="date">
<FormItem>
<FormLabel>Date *</FormLabel>
<FormControl>
<Input
type="date"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="time">
<FormItem>
<FormLabel>Time *</FormLabel>
<FormControl>
<Input
type="time"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Available Seats (for offering) -->
<FormField v-slot="{ componentField }" name="seats">
<FormItem v-show="watchedType === 'offering'">
<FormLabel>Available Seats</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Number of seats" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 seat</SelectItem>
<SelectItem value="2">2 seats</SelectItem>
<SelectItem value="3">3 seats</SelectItem>
<SelectItem value="4">4 seats</SelectItem>
<SelectItem value="5">5+ seats</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
How many passengers can you take?
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Price/Cost -->
<FormField v-slot="{ componentField }" name="price">
<FormItem>
<FormLabel>{{ watchedType === 'offering' ? 'Price per person (sats)' : 'Willing to pay (sats)' }}</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground"></span>
<Input
type="number"
placeholder="0"
step="1"
min="0"
v-bind="componentField"
/>
<span class="text-sm text-muted-foreground">sats</span>
</div>
</FormControl>
<FormDescription>
{{ watchedType === 'offering' ? 'Cost per passenger in satoshis (optional)' : 'Maximum satoshis you\'re willing to pay (optional)' }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Additional Details -->
<FormField v-slot="{ componentField }" name="details">
<FormItem>
<FormLabel>Additional Details</FormLabel>
<FormControl>
<Textarea
placeholder="Any additional information, requirements, or preferences..."
class="min-h-[80px] resize-y"
v-bind="componentField"
:maxlength="200"
/>
</FormControl>
<div class="flex items-center justify-between">
<FormDescription>
Extra details, preferences, or contact info
</FormDescription>
<span class="text-xs text-muted-foreground">
{{ detailsCharacterCount }}/200
</span>
</div>
<FormMessage />
</FormItem>
</FormField>
<!-- Contact Method -->
<FormField v-slot="{ componentField }" name="contactMethod">
<FormItem>
<FormLabel>Contact Method</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="How should people contact you?" />
</SelectTrigger>
<SelectContent>
<SelectItem value="nostr-dm">Nostr DM</SelectItem>
<SelectItem value="replies">Replies to this post</SelectItem>
<SelectItem value="other">Other (specify in details)</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Preferred way for people to reach you
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Actions -->
<div class="flex justify-end pt-4 border-t">
<div class="flex gap-2">
<Button
type="button"
variant="outline"
@click="emit('close')"
>
Cancel
</Button>
<Button
type="submit"
:disabled="isPublishing || !isFormValid"
class="gap-2"
>
<Send class="h-4 w-4" />
{{ isPublishing ? 'Publishing...' : 'Publish Rideshare' }}
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Car, Send, X } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useToast } from '@/core/composables/useToast'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
// Emits
interface Emits {
(e: 'close'): void
(e: 'rideshare-published', noteId: string): void
}
const emit = defineEmits<Emits>()
// Services
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
const toast = useToast()
// Form schema
const rideshareSchema = toTypedSchema(z.object({
type: z.enum(['offering', 'seeking'], {
required_error: "Please select rideshare type"
}),
fromLocation: z.string().min(1, "From location is required").max(100, "Location too long"),
toLocation: z.string().min(1, "To location is required").max(100, "Location too long"),
date: z.string().min(1, "Date is required").refine(
(date) => new Date(date) >= new Date(new Date().toDateString()),
"Date cannot be in the past"
),
time: z.string().min(1, "Time is required"),
seats: z.string().optional(),
price: z.number().int().min(0, "Price must be positive").optional().or(z.literal('')).transform(val => val === '' ? undefined : val),
details: z.string().max(200, "Details too long").optional(),
contactMethod: z.enum(['nostr-dm', 'replies', 'other']).optional(),
}))
// Form setup
const form = useForm({
validationSchema: rideshareSchema,
initialValues: {
type: undefined as 'offering' | 'seeking' | undefined,
fromLocation: '',
toLocation: '',
date: '',
time: '',
seats: '',
price: undefined as number | undefined,
details: '',
contactMethod: 'nostr-dm'
}
})
const { values, meta, resetForm } = form
const isFormValid = computed(() => meta.value.valid)
// State
const isPublishing = ref(false)
// Computed properties
const watchedType = computed(() => values.type)
const detailsCharacterCount = computed(() => (values.details || '').length)
// Helper function to convert hex string to Uint8Array
const hexToUint8Array = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
// Generate rideshare post content
const generateRideshareContent = (values: any): string => {
const type = values.type === 'offering' ? '🚗 OFFERING RIDE' : '🚶 SEEKING RIDE'
const dateTime = `${values.date} at ${values.time}`
let content = `${type}\n\n`
content += `📍 From: ${values.fromLocation}\n`
content += `🎯 To: ${values.toLocation}\n`
content += `📅 When: ${dateTime}\n`
if (values.type === 'offering' && values.seats) {
content += `👥 Seats available: ${values.seats}\n`
}
if (values.price) {
const priceLabel = values.type === 'offering' ? 'Price per person' : 'Willing to pay'
content += `💰 ${priceLabel}: ${values.price} sats\n`
}
if (values.contactMethod) {
const contactLabels = {
'nostr-dm': 'DM me on Nostr',
'replies': 'Reply to this post',
'other': 'See details below'
}
content += `📞 Contact: ${contactLabels[values.contactMethod as keyof typeof contactLabels]}\n`
}
if (values.details?.trim()) {
content += `\n📝 Details: ${values.details.trim()}`
}
return content
}
// Generate rideshare tags following Nostr best practices
const generateRideshareTags = (values: any): string[][] => {
const tags: string[][] = []
// NIP-12: Generic tags for rideshare content
tags.push(['t', 'rideshare'])
tags.push(['t', 'carpool'])
tags.push(['t', 'transport'])
// Rideshare-specific tags (custom)
// Note: All tag values must be strings per Nostr protocol
tags.push(['rideshare_type', String(values.type)]) // 'offering' or 'seeking'
tags.push(['rideshare_from', String(values.fromLocation)])
tags.push(['rideshare_to', String(values.toLocation)])
tags.push(['rideshare_date', String(values.date)])
tags.push(['rideshare_time', String(values.time)])
if (values.type === 'offering' && values.seats) {
tags.push(['rideshare_seats', String(values.seats)])
}
if (values.price) {
tags.push(['rideshare_price', String(values.price)])
}
if (values.contactMethod) {
tags.push(['rideshare_contact', String(values.contactMethod)])
}
return tags
}
// Submit handler
const onSubmit = form.handleSubmit(async (values) => {
if (!authService?.isAuthenticated.value || !authService?.user.value) {
toast.error("Please sign in to publish rideshare posts")
return
}
if (!relayHub?.isConnected.value) {
toast.error("Not connected to Nostr relays")
return
}
const userPubkey = authService.user.value.pubkey
const userPrivkey = authService.user.value.prvkey
if (!userPubkey || !userPrivkey) {
toast.error("User keys not available - please sign in again")
return
}
isPublishing.value = true
try {
// Generate content and tags
const content = generateRideshareContent(values)
const tags = generateRideshareTags(values)
// Create rideshare event template
// Using kind 1 (text note) with rideshare tags for maximum compatibility
// Could also use custom kind like 31001 for structured rideshare events
const eventTemplate: EventTemplate = {
kind: 1, // Standard text note with rideshare tags
content,
tags,
created_at: Math.floor(Date.now() / 1000)
}
console.log('Creating rideshare event:', eventTemplate)
// Sign the event
const privkeyBytes = hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
console.log('Publishing signed rideshare post:', signedEvent)
// Publish the rideshare post
const result = await relayHub.publishEvent(signedEvent)
toast.success(`Rideshare post published to ${result.success}/${result.total} relays!`)
// Reset form
resetForm()
// Emit success event
emit('rideshare-published', signedEvent.id)
console.log('Rideshare post published with ID:', signedEvent.id)
} catch (error) {
console.error('Failed to publish rideshare post:', error)
toast.error(error instanceof Error ? error.message : "Failed to publish rideshare post")
} finally {
isPublishing.value = false
}
})
</script>