447 lines
No EOL
14 KiB
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> |