Compare commits
No commits in common. "79be46c33d7bbdc879d694afbf3c406a81b01047" and "b9bca36b506f8c911bec366668bfcab7cb4adff1" have entirely different histories.
79be46c33d
...
b9bca36b50
8 changed files with 84 additions and 100 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
||||||
|
|
@ -7,7 +7,6 @@ import AppShell from '@/components/layout/AppShell.vue'
|
||||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useActivitiesStore } from '@/modules/activities/stores/activities'
|
import { useActivitiesStore } from '@/modules/activities/stores/activities'
|
||||||
import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
||||||
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
|
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
|
||||||
|
|
@ -17,7 +16,26 @@ const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
const activitiesStore = useActivitiesStore()
|
const activitiesStore = useActivitiesStore()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
|
||||||
|
// Probe LNbits admin status + extension auto_approve once at auth-ready
|
||||||
|
// so the shell-mounted dialog renders the right warning copy when an
|
||||||
|
// owner edits their own event.
|
||||||
|
const isAdmin = ref(false)
|
||||||
|
const autoApprove = ref(false)
|
||||||
|
async function probeApprovalState() {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
const wallet = currentUser.value?.wallets?.[0]
|
||||||
|
if (!wallet?.inkey) return
|
||||||
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
autoApprove.value = await ticketApi.getAutoApprove(wallet.inkey)
|
||||||
|
if (wallet.adminkey) {
|
||||||
|
isAdmin.value = await ticketApi.isAdmin(wallet.adminkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(probeApprovalState)
|
||||||
|
watch(isAuthenticated, (yes) => {
|
||||||
|
if (yes) probeApprovalState()
|
||||||
|
})
|
||||||
|
|
||||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||||
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
|
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
|
||||||
|
|
@ -29,12 +47,7 @@ const tabs = computed<BottomTab[]>(() => [
|
||||||
{
|
{
|
||||||
name: t('activities.createNew'),
|
name: t('activities.createNew'),
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
onClick: () => {
|
onClick: () => { activitiesStore.showCreateDialog = true },
|
||||||
// Defensively clear any lingering edit selection so the Create
|
|
||||||
// tap always opens in Create mode regardless of a prior Edit.
|
|
||||||
activitiesStore.editingEvent = null
|
|
||||||
activitiesStore.showCreateDialog = true
|
|
||||||
},
|
|
||||||
disabled: !isAuthenticated.value,
|
disabled: !isAuthenticated.value,
|
||||||
},
|
},
|
||||||
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
@ -171,7 +171,7 @@ const availableCurrencies = ref<string[]>(['sat'])
|
||||||
const loadingCurrencies = ref(false)
|
const loadingCurrencies = ref(false)
|
||||||
const selectedCategories = ref<string[]>([])
|
const selectedCategories = ref<string[]>([])
|
||||||
|
|
||||||
async function populateFromEvent(event: TicketedEvent) {
|
function populateFromEvent(event: TicketedEvent) {
|
||||||
isPopulating.value = true
|
isPopulating.value = true
|
||||||
const start = splitDateTime(event.event_start_date)
|
const start = splitDateTime(event.event_start_date)
|
||||||
const end = splitDateTime(event.event_end_date)
|
const end = splitDateTime(event.event_end_date)
|
||||||
|
|
@ -189,24 +189,25 @@ async function populateFromEvent(event: TicketedEvent) {
|
||||||
})
|
})
|
||||||
selectedCategories.value = [...(event.categories ?? [])]
|
selectedCategories.value = [...(event.categories ?? [])]
|
||||||
if (event.banner) {
|
if (event.banner) {
|
||||||
// Re-render the stored banner via its pict-rs file ID. delete_token
|
// Mirror the URL-to-alias bridge from market's CreateProductDialog
|
||||||
// is intentionally empty: we don't own the original upload's token
|
// so the <ImageUpload> renders the existing banner via its pict-rs
|
||||||
// and removing the image on the client should NOT delete the
|
// file ID. delete_token is unknown for already-uploaded images, so
|
||||||
// server-side file (it may be the user changing their mind about
|
// removal just clears the slot client-side.
|
||||||
// re-using it, or the same image referenced elsewhere).
|
const url = event.banner
|
||||||
const alias = imageService.extractFileId(event.banner)
|
const alias = url.includes('/image/original/')
|
||||||
|
? url.split('/image/original/')[1]
|
||||||
|
: url
|
||||||
bannerImages.value = [
|
bannerImages.value = [
|
||||||
{ alias, isPrimary: true, delete_token: '', details: {} as any },
|
{ alias, isPrimary: true, delete_token: '', details: {} as any },
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
bannerImages.value = []
|
bannerImages.value = []
|
||||||
}
|
}
|
||||||
// Release the watcher guard after Vue's microtask queue drains so
|
// Release the watcher guard on the next tick so vee-validate's batched
|
||||||
// vee-validate's batched setValues lands before user input can drive
|
// updates settle before user input can drive the auto-mirror.
|
||||||
// the auto-mirror. nextTick is more reliable than setTimeout(0) here
|
setTimeout(() => {
|
||||||
// — it waits for the DOM tick *after* all current microtasks.
|
|
||||||
await nextTick()
|
|
||||||
isPopulating.value = false
|
isPopulating.value = false
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.open, async (isOpen) => {
|
watch(() => props.open, async (isOpen) => {
|
||||||
|
|
@ -222,7 +223,7 @@ watch(() => props.open, async (isOpen) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (props.event) {
|
if (props.event) {
|
||||||
await populateFromEvent(props.event)
|
populateFromEvent(props.event)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { onMounted, ref, watch } from 'vue'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe the events extension for the caller's approval-flow context:
|
|
||||||
*
|
|
||||||
* - `autoApprove` — is the global `auto_approve` toggle on? Reads the
|
|
||||||
* invoice-key-gated public probe (v1.3.0-aio.5+), so non-admin
|
|
||||||
* wallet holders get an accurate answer.
|
|
||||||
* - `isAdmin` — is the caller an LNbits admin? Tries the admin-only
|
|
||||||
* `/events/all` endpoint; a 200 means yes.
|
|
||||||
*
|
|
||||||
* Both refs default to `false` (the safer assumption for warning UI
|
|
||||||
* — biased toward showing the "edit will go back to pending" copy
|
|
||||||
* when in doubt). Probe re-runs whenever auth flips to authenticated.
|
|
||||||
*
|
|
||||||
* Used by every surface that opens the edit-mode CreateEventDialog
|
|
||||||
* (activities-app/App.vue shell mount, activities EventsPage). Keeps
|
|
||||||
* the probe logic single-source-of-truth.
|
|
||||||
*/
|
|
||||||
export function useApprovalState() {
|
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
|
||||||
const isAdmin = ref(false)
|
|
||||||
const autoApprove = ref(false)
|
|
||||||
|
|
||||||
async function probe() {
|
|
||||||
if (!isAuthenticated.value) {
|
|
||||||
isAdmin.value = false
|
|
||||||
autoApprove.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const wallet = currentUser.value?.wallets?.[0]
|
|
||||||
if (!wallet?.inkey) return
|
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
|
||||||
autoApprove.value = await ticketApi.getAutoApprove(wallet.inkey)
|
|
||||||
if (wallet.adminkey) {
|
|
||||||
isAdmin.value = await ticketApi.isAdmin(wallet.adminkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(probe)
|
|
||||||
watch(isAuthenticated, (yes) => {
|
|
||||||
if (yes) probe()
|
|
||||||
})
|
|
||||||
|
|
||||||
return { isAdmin, autoApprove, probe }
|
|
||||||
}
|
|
||||||
|
|
@ -29,12 +29,9 @@ export function useEvents() {
|
||||||
const seen = new Set(publicEvents.map((e) => e.id))
|
const seen = new Set(publicEvents.map((e) => e.id))
|
||||||
const own = myEvents.filter((e) => !seen.has(e.id))
|
const own = myEvents.filter((e) => !seen.has(e.id))
|
||||||
return [...publicEvents, ...own]
|
return [...publicEvents, ...own]
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Falling back to just the public feed is acceptable — the user
|
// Falling back to just the public feed is acceptable — the user
|
||||||
// can still browse, they just won't see their own pending events.
|
// can still browse, they just won't see their own pending events.
|
||||||
// Log so a flaky probe is debuggable from the console without
|
|
||||||
// toast-spamming the user on every transient failure.
|
|
||||||
console.warn('[useEvents] fetchMyEvents failed, showing public feed only:', err)
|
|
||||||
return publicEvents
|
return publicEvents
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useEvents } from '../composables/useEvents'
|
import { useEvents } from '../composables/useEvents'
|
||||||
import { useApprovalState } from '../composables/useApprovalState'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
@ -19,7 +18,6 @@ import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
||||||
|
|
||||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
||||||
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
|
||||||
|
|
||||||
const showPurchaseDialog = ref(false)
|
const showPurchaseDialog = ref(false)
|
||||||
const selectedEvent = ref<{
|
const selectedEvent = ref<{
|
||||||
|
|
@ -33,6 +31,13 @@ const showEventDialog = ref(false)
|
||||||
// `null` ↔ create mode; populated ↔ edit mode.
|
// `null` ↔ create mode; populated ↔ edit mode.
|
||||||
const editingEvent = ref<TicketedEvent | null>(null)
|
const editingEvent = ref<TicketedEvent | null>(null)
|
||||||
|
|
||||||
|
// Probe once at mount so the dialog can render the "going to pending"
|
||||||
|
// warning accurately. Both probes degrade to safe defaults on failure
|
||||||
|
// (not admin, not auto-approved), which biases the warning toward
|
||||||
|
// being shown when in doubt.
|
||||||
|
const isAdmin = ref(false)
|
||||||
|
const autoApprove = ref(false)
|
||||||
|
|
||||||
const myWalletIds = computed(
|
const myWalletIds = computed(
|
||||||
() => new Set((currentUser.value?.wallets ?? []).map((w) => w.id))
|
() => new Set((currentUser.value?.wallets ?? []).map((w) => w.id))
|
||||||
)
|
)
|
||||||
|
|
@ -41,6 +46,17 @@ function canEdit(event: TicketedEvent): boolean {
|
||||||
return isAuthenticated.value && myWalletIds.value.has(event.wallet)
|
return isAuthenticated.value && myWalletIds.value.has(event.wallet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
const wallet = currentUser.value?.wallets?.[0]
|
||||||
|
if (!wallet?.inkey) return
|
||||||
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
autoApprove.value = await ticketApi.getAutoApprove(wallet.inkey)
|
||||||
|
if (wallet.adminkey) {
|
||||||
|
isAdmin.value = await ticketApi.isAdmin(wallet.adminkey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function formatDate(dateStr: string | null | undefined) {
|
function formatDate(dateStr: string | null | undefined) {
|
||||||
if (!dateStr) return 'Date not available'
|
if (!dateStr) return 'Date not available'
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
|
|
|
||||||
|
|
@ -449,11 +449,7 @@ const removeImage = async (imageToRemove: ImageWithMetadata) => {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Server-side delete only when we have a delete_token (newly
|
// Only try to delete from pict-rs if we have a delete token (newly uploaded images)
|
||||||
// uploaded this session). Pre-existing images re-populated from a
|
|
||||||
// stored URL ship `delete_token: ''` by convention — we don't own
|
|
||||||
// the original upload's one-time token, and removing on the client
|
|
||||||
// shouldn't reach back and wipe the server-side file.
|
|
||||||
if (imageToRemove.delete_token) {
|
if (imageToRemove.delete_token) {
|
||||||
await imageService.deleteImage(imageToRemove.delete_token, imageToRemove.alias)
|
await imageService.deleteImage(imageToRemove.delete_token, imageToRemove.alias)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,12 +300,9 @@ export class ImageUploadService extends BaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a pict-rs file ID from an alias, accepting both bare IDs
|
* Extract file ID from alias, handling both file IDs and full URLs
|
||||||
* and full `/image/original/<id>` URLs. Public so callers
|
|
||||||
* re-populating uploads from stored URLs (edit flows) don't have to
|
|
||||||
* re-implement the parse.
|
|
||||||
*/
|
*/
|
||||||
extractFileId(alias: string): string {
|
private extractFileId(alias: string): string {
|
||||||
if (!alias) {
|
if (!alias) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -516,17 +516,30 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||||
// Reset form with appropriate initial values
|
// Reset form with appropriate initial values
|
||||||
resetForm({ values: initialValues })
|
resetForm({ values: initialValues })
|
||||||
|
|
||||||
// Convert existing image URLs to the format expected by ImageUpload.
|
// Convert existing image URLs to the format expected by ImageUpload component
|
||||||
// delete_token is intentionally empty for pre-existing images: see
|
|
||||||
// ImageUploadService.deleteImage gate — removing on the client
|
|
||||||
// should not delete the server-side file.
|
|
||||||
if (props.product?.images && props.product.images.length > 0) {
|
if (props.product?.images && props.product.images.length > 0) {
|
||||||
uploadedImages.value = props.product.images.map((url, index) => ({
|
// For existing products, we need to convert URLs back to a format ImageUpload can display
|
||||||
alias: imageService.extractFileId(url),
|
uploadedImages.value = props.product.images.map((url, index) => {
|
||||||
|
let alias = url
|
||||||
|
|
||||||
|
// If it's a full pict-rs URL, extract just the file ID
|
||||||
|
if (url.includes('/image/original/')) {
|
||||||
|
const parts = url.split('/image/original/')
|
||||||
|
if (parts.length > 1 && parts[1]) {
|
||||||
|
alias = parts[1]
|
||||||
|
}
|
||||||
|
} else if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
// Keep full URLs as-is
|
||||||
|
alias = url
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
alias: alias,
|
||||||
delete_token: '',
|
delete_token: '',
|
||||||
isPrimary: index === 0,
|
isPrimary: index === 0,
|
||||||
details: {} as any,
|
details: {}
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
uploadedImages.value = []
|
uploadedImages.value = []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue