Repository: we-dance/v4
Pull request #330: feat: list of venues
Author: login: KindImagination
Created: 2025-04-07T14:55:58Z | Merged: 2025-04-09T14:30:42Z

closes #218 

How to test:
Open Venues, you can see the first 9 venues. 
you can load another 9 venues by clicking the "load more venues" button. 
you can filter by "dance style", "location" and "name".


ISSUE: if you flter by Locations: Munich - Germany 

one of the vanue hase name: "64 Rue Orfila" and Adress: "64 Rue Orfila, 75020 Paris, France"

this is Backup Error, this Venue hase City: "Munich" 

Linked issue #218: Import Venues

Diff:
diff --git a/components/LocationPanel.vue b/components/LocationPanel.vue
index 4c80bad96..4c697b766 100644
--- a/components/LocationPanel.vue
+++ b/components/LocationPanel.vue
@@ -11,29 +11,51 @@ const props = defineProps({
 })
 
 const locationSearch = ref('')
+const loadingCities = ref(false)
 
-const regions = [
-  { name: 'Europe', count: '15k+' },
-  { name: 'North America', count: '12k+' },
-  { name: 'Latin America', count: '8k+' },
-  { name: 'Asia', count: '5k+' },
-]
+const cities = ref<
+  Array<{
+    name: string
+    displayName: string
+    country: string | null
+    countryCode: string | null
+    count: string
+    type: string
+  }>
+>([])
 
-const cities = [
-  { name: 'New York', count: '2.5k+' },
-  { name: 'London', count: '2k+' },
-  { name: 'Madrid', count: '1.8k+' },
-  { name: 'Berlin', count: '1.5k+' },
-]
+const fetchLocations = async () => {
+  loadingCities.value = true
 
-const filteredRegions = computed(() => {
-  const search = locationSearch.value.toLowerCase()
-  return regions.filter((region) => region.name.toLowerCase().includes(search))
-})
+  try {
+    const venueLocations = await trpc.profiles.venueLocations.query()
+    cities.value = venueLocations.cities.map((city) => ({
+      name: city.name,
+      displayName: city.displayName || city.name,
+      country: city.country || null,
+      countryCode: city.countryCode || null,
+      count: `${city.count}`,
+      type: city.type || 'address',
+    }))
+  } catch (error) {
+    console.error('Error fetching venue locations:', error)
+    cities.value = []
+  } finally {
+    loadingCities.value = false
+  }
+}
 
 const filteredCities = computed(() => {
   const search = locationSearch.value.toLowerCase()
-  return cities.filter((city) => city.name.toLowerCase().includes(search))
+  return cities.value.filter(
+    (city) =>
+      city.displayName.toLowerCase().includes(search) ||
+      (city.country && city.country.toLowerCase().includes(search))
+  )
+})
+
+onMounted(async () => {
+  await fetchLocations()
 })
 </script>
 
@@ -49,39 +71,37 @@ const filteredCities = computed(() => {
     </Input>
 
     <div class="flex flex-col gap-1">
-      <div class="font-medium text-sm text-muted-foreground">
-        Popular Regions
+      <div
+        class="font-medium text-sm text-muted-foreground flex items-center justify-between"
+      >
+        Cities
+        <span v-if="loadingCities" class="text-xs text-muted-foreground">
+          <Icon name="ph:spinner-gap" class="w-3 h-3 animate-spin" />
+        </span>
       </div>
-      <Button
-        v-for="region in filteredRegions"
-        :key="region.name"
-        variant="ghost"
-        class="justify-start"
-        :class="{ 'bg-accent': props.location === region.name }"
-        @click="emit('update:location', region.name)"
+
+      <div
+        v-if="!loadingCities && filteredCities.length === 0"
+        class="text-sm text-muted-foreground py-2 px-3"
       >
-        {{ region.name }}
-        <span class="ml-auto text-sm text-muted-foreground">{{
-          region.count
-        }}</span>
-      </Button>
+        No cities found
+      </div>
 
-      <div class="font-medium text-sm text-muted-foreground mt-2">
-        Popular Cities
+      <div class="flex flex-col w-full">
+        <Button
+          v-for="city in filteredCities"
+          :key="city.name"
+          variant="ghost"
+          class="justify-start w-full"
+          :class="{ 'bg-accent': props.location === city.name }"
+          @click="emit('update:location', city.name)"
+        >
+          {{ city.displayName }}
+          <span class="ml-auto text-sm text-muted-foreground">{{
+            city.count
+          }}</span>
+        </Button>
       </div>
-      <Button
-        v-for="city in filteredCities"
-        :key="city.name"
-        variant="ghost"
-        class="justify-start"
-        :class="{ 'bg-accent': props.location === city.name }"
-        @click="emit('update:location', city.name)"
-      >
-        {{ city.name }}
-        <span class="ml-auto text-sm text-muted-foreground">{{
-          city.count
-        }}</span>
-      </Button>
     </div>
   </div>
 </template>
diff --git a/components/VenuesView.vue b/components/VenuesView.vue
index 8500e6e4e..0c8537780 100644
--- a/components/VenuesView.vue
+++ b/components/VenuesView.vue
@@ -1,77 +1,210 @@
 <script setup lang="ts">
-import { mockVenues } from '~/data/mockVenues'
-const venues = mockVenues
+import { useDebounceFn } from '@vueuse/core'
 
+const venues = ref<any[]>([])
 const showFilters = ref(false)
 const showLocationFilter = ref(false)
 const search = ref('')
 const selectedLocation = ref<string | null>(null)
 const selectedStyles = ref<string[]>([])
-const selectedFeatures = ref<string[]>([])
-const selectedPriceRange = ref<[number, number]>([0, 200])
-const selectedCapacityRange = ref<[number, number]>([0, 200])
+const styleSearch = ref('')
+const showStyleDropdown = ref(false)
 
-const allStyles = computed(() =>
-  Array.from(new Set(venues.flatMap((venue) => venue.styles)))
-)
+const page = ref(1)
+const limit = ref(9)
+const totalCount = ref(0)
+const hasMore = ref(false)
+const isLoading = ref(false)
+const loadingMore = ref(false)
+const loadingTimes = ref<number[]>([])
 
-const allFeatures = computed(() =>
-  Array.from(new Set(venues.flatMap((venue) => venue.features)))
-)
+const allDanceStyles = ref<string[]>([])
+const loadingStyles = ref(false)
+
+// For SearchableDropdown component
+const selectedDanceStyle = ref<string | null>(null)
+const danceStyleSearchQuery = ref('')
+
+// Format dance styles for SearchableDropdown
+const formattedDropdownStyles = computed(() => {
+  if (!allDanceStyles.value.length) return []
+
+  return allDanceStyles.value.map((style) => ({
+    id: style,
+    name: style,
+  }))
+})
 
 const updateLocation = (location: string | null) => {
   selectedLocation.value = location
   showLocationFilter.value = false
+  resetSearch()
 }
 
-const filteredVenues = computed(() => {
-  return venues.filter((venue) => {
-    const matchesSearch =
-      search.value === '' ||
-      venue.name.toLowerCase().includes(search.value.toLowerCase()) ||
-      venue.description.toLowerCase().includes(search.value.toLowerCase())
-
-    const matchesLocation =
-      !selectedLocation.value ||
-      venue.address.toLowerCase().includes(selectedLocation.value.toLowerCase())
-
-    const matchesStyles =
-      selectedStyles.value.length === 0 ||
-      venue.styles.some((style) => selectedStyles.value.includes(style))
-
-    const matchesFeatures =
-      selectedFeatures.value.length === 0 ||
-      venue.features.some((feature) => selectedFeatures.value.includes(feature))
-
-    const minPrice = Math.min(...venue.areas.map((area) => area.pricePerHour))
-    const maxPrice = Math.max(...venue.areas.map((area) => area.pricePerHour))
-    const totalCapacity = venue.areas.reduce(
-      (sum, area) => sum + area.capacity,
-      0
-    )
+const clearLocationFilter = () => {
+  selectedLocation.value = null
+  showLocationFilter.value = false
+  resetSearch()
+}
 
-    const matchesPrice =
-      maxPrice >= selectedPriceRange.value[0] &&
-      minPrice <= selectedPriceRange.value[1]
-
-    const matchesCapacity =
-      totalCapacity >= selectedCapacityRange.value[0] &&
-      totalCapacity <= selectedCapacityRange.value[1]
-
-    return (
-      matchesSearch &&
-      matchesLocation &&
-      matchesStyles &&
-      matchesFeatures &&
-      matchesPrice &&
-      matchesCapacity
-    )
+const resetSearch = () => {
+  page.value = 1
+  venues.value = []
+  fetchVenues()
+}
+
+async function fetchVenues() {
+  isLoading.value = true
+  try {
+    const result = await trpc.profiles.venues.query({
+      limit: limit.value,
+      page: page.value,
+      search: search.value,
+      location: selectedLocation.value || undefined,
+      styles:
+        selectedStyles.value.length > 0 ? selectedStyles.value : undefined,
+    })
+
+    if (page.value === 1) {
+      venues.value = result.venues
+    } else {
+      venues.value = [...venues.value, ...result.venues]
+    }
+
+    totalCount.value = result.totalCount
+    hasMore.value = result.hasMore
+  } catch (error) {
+    console.error('Error fetching venues:', error)
+  } finally {
+    isLoading.value = false
+  }
+}
+
+async function loadMore() {
+  if (!hasMore.value || isLoading.value) return
+  loadingMore.value = true
+  page.value++
+  try {
+    await fetchVenues()
+  } finally {
+    loadingMore.value = false
+  }
+}
+
+async function fetchStyles() {
+  if (allDanceStyles.value.length > 0) return
+
+  loadingStyles.value = true
+  try {
+    const styles = await trpc.profiles.allVenuesStyles.query()
+    allDanceStyles.value = styles
+  } catch (error) {
+    console.error('Error fetching dance styles:', error)
+  } finally {
+    loadingStyles.value = false
+  }
+}
+
+const toggleFilters = async () => {
+  showFilters.value = !showFilters.value
+
+  if (showFilters.value) {
+    await fetchStyles()
+  }
+}
+
+const debouncedReset = useDebounceFn(() => {
+  resetSearch()
+}, 300)
+
+watch([search], () => {
+  debouncedReset()
+})
+
+const toggleStyle = (style: string) => {
+  if (selectedStyles.value.includes(style)) {
+    selectedStyles.value = selectedStyles.value.filter((s) => s !== style)
+  } else {
+    selectedStyles.value.push(style)
+  }
+  resetSearch()
+}
+
+const onSelectStyle = (style: any) => {
+  // Add the style to multi-select
+  if (!selectedStyles.value.includes(style.id)) {
+    selectedStyles.value.push(style.id)
+  } else {
+    // Remove it if already selected (toggle behavior)
+    selectedStyles.value = selectedStyles.value.filter((s) => s !== style.id)
+  }
+  resetSearch()
+  // Keep dropdown open for more selections
+  showStyleDropdown.value = true
+}
+
+const clearAllStyles = () => {
+  selectedStyles.value = []
+  resetSearch()
+}
+
+onMounted(async () => {
+  await fetchVenues()
+})
+
+const getDescription = (venue: any) => {
+  return venue.bio || venue.description || ''
+}
+
+const getAddress = (venue: any) => {
+  return venue.formattedAddress || (venue.city ? venue.city.name : '') || ''
+}
+
+const currentStylesWithCount = computed(() => {
+  const styleCount = new Map<string, number>()
+
+  venues.value.forEach((venue) => {
+    if (venue.danceStyles && venue.danceStyles.length) {
+      venue.danceStyles.forEach((style: string) => {
+        styleCount.set(style, (styleCount.get(style) || 0) + 1)
+      })
+    }
   })
+
+  return Array.from(styleCount.entries())
+    .sort((a, b) => b[1] - a[1])
+    .map(([style, count]) => ({ style, count }))
 })
 
-const clearLocationFilter = () => {
-  selectedLocation.value = null
-  showLocationFilter.value = false
+const formattedDanceStyles = computed(() => {
+  if (allDanceStyles.value.length > 0) {
+    const countMap = new Map(
+      currentStylesWithCount.value.map((item) => [item.style, item.count])
+    )
+
+    return allDanceStyles.value.map((style) => ({
+      style,
+      count: countMap.get(style) || 0,
+    }))
+  }
+
+  return currentStylesWithCount.value
+})
+
+const filteredDanceStyles = computed(() => {
+  if (!styleSearch.value) {
+    return formattedDanceStyles.value
+  }
+
+  const query = styleSearch.value.toLowerCase()
+  return formattedDanceStyles.value.filter((item) =>
+    item.style.toLowerCase().includes(query)
+  )
+})
+
+const removeStyle = (style: string) => {
+  selectedStyles.value = selectedStyles.value.filter((s) => s !== style)
+  resetSearch()
 }
 </script>
 
@@ -110,130 +243,40 @@ const clearLocationFilter = () => {
           <Icon name="ph:x" class="w-4 h-4" />
         </Button>
       </div>
-      <Button
-        variant="outline"
-        class="gap-2"
-        @click="showFilters = !showFilters"
-      >
-        <Icon name="ph:funnel" class="w-4 h-4" />
-        Filters
-      </Button>
     </div>
   </div>
 
-  <!-- Filters Section -->
+  <!-- Loading State -->
   <div
-    v-if="showFilters"
-    class="bg-card border border-border rounded-lg p-6 space-y-6 mb-6"
+    v-if="isLoading && venues.length === 0"
+    class="py-10 flex justify-center"
   >
-    <!-- Dance Styles -->
-    <div>
-      <h3 class="font-medium mb-3">Dance Styles</h3>
-      <div class="flex flex-wrap gap-2">
-        <Button
-          v-for="style in allStyles"
-          :key="style"
-          variant="outline"
-          size="sm"
-          :class="[
-            selectedStyles.includes(style)
-              ? 'bg-primary text-primary-foreground'
-              : '',
-          ]"
-          @click="
-            selectedStyles.includes(style)
-              ? (selectedStyles = selectedStyles.filter((s) => s !== style))
-              : selectedStyles.push(style)
-          "
-        >
-          {{ style }}
-        </Button>
-      </div>
-    </div>
-
-    <!-- Features -->
-    <div>
-      <h3 class="font-medium mb-3">Features</h3>
-      <div class="flex flex-wrap gap-2">
-        <Button
-          v-for="feature in allFeatures"
-          :key="feature"
-          variant="outline"
-          size="sm"
-          :class="[
-            selectedFeatures.includes(feature)
-              ? 'bg-primary text-primary-foreground'
-              : '',
-          ]"
-          @click="
-            selectedFeatures.includes(feature)
-              ? (selectedFeatures = selectedFeatures.filter(
-                  (f) => f !== feature
-                ))
-              : selectedFeatures.push(feature)
-          "
-        >
-          {{ feature }}
-        </Button>
-      </div>
-    </div>
-
-    <!-- Price Range -->
-    <div>
-      <h3 class="font-medium mb-3">Price per Hour</h3>
-      <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedPriceRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
-        <span>to</span>
-        <Input
-          v-model="selectedPriceRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
-        <span>€</span>
-      </div>
-    </div>
-
-    <!-- Capacity -->
-    <div>
-      <h3 class="font-medium mb-3">Capacity</h3>
-      <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedCapacityRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
-        <span>to</span>
-        <Input
-          v-model="selectedCapacityRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
-        <span>people</span>
-      </div>
-    </div>
+    <div
+      class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
+    ></div>
   </div>
 
   <!-- Venues Grid -->
-  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
+  <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
     <NuxtLink
-      v-for="venue in filteredVenues"
+      v-for="venue in venues"
       :key="venue.id"
-      :to="`/venues/${venue.id}`"
+      :to="`/@${venue.username}`"
       class="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-lg transition-all duration-300"
     >
       <div class="aspect-video relative overflow-hidden">
-        <img
-          :src="venue.image"
+        <NuxtImg
+          :src="
+            venue.photo ||
+            'https://placehold.co/400x225/e2e8f0/334155?text=Venue'
+          "
           :alt="venue.name"
           class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
+          width="400"
+          height="225"
+          loading="lazy"
+          format="webp"
+          quality="80"
         />
       </div>
       <div class="p-6">
@@ -241,63 +284,74 @@ const clearLocationFilter = () => {
           <h3 class="font-bold text-lg text-foreground">
             {{ venue.name }}
           </h3>
-          <div class="flex items-center gap-1 text-amber-500">
+          <div
+            v-if="venue.rating"
+            class="flex items-center gap-1 text-amber-500"
+          >
             <Icon name="ph:star-fill" class="w-4 h-4" />
             <span class="text-sm">{{ venue.rating }}</span>
           </div>
         </div>
         <p class="text-muted-foreground text-sm mb-2">
-          {{ venue.address }}
+          {{ getAddress(venue) }}
+        </p>
+        <!-- TODO: Add price per hour and capacity -->
+        <p v-if="venue.bio" class="text-foreground mb-4 line-clamp-2">
+          {{ venue.bio }}
         </p>
-        <div class="flex items-center gap-4 text-sm text-muted-foreground mb-3">
-          <div class="flex items-center gap-1">
-            <Icon name="ph:currency-eur" class="w-4 h-4" />
-            {{ Math.min(...venue.areas.map((a) => a.pricePerHour)) }}-{{
-              Math.max(...venue.areas.map((a) => a.pricePerHour))
-            }}/hour
-          </div>
-          <div class="flex items-center gap-1">
-            <Icon name="ph:users" class="w-4 h-4" />
-            {{ venue.areas.reduce((sum, area) => sum + area.capacity, 0) }}
-            people
-          </div>
-        </div>
-        <p class="text-foreground mb-4 line-clamp-2">{{ venue.description }}</p>
-        <div class="flex flex-wrap gap-2">
-          <Badge
-            v-for="feature in venue.features.slice(0, 3)"
-            :key="feature"
-            variant="secondary"
-          >
-            {{ feature }}
-          </Badge>
-        </div>
       </div>
     </NuxtLink>
   </div>
 
+  <!-- Load More Button -->
+  <div v-if="hasMore && !isLoading" class="text-center py-8">
+    <Button @click="loadMore" :disabled="loadingMore">
+      <Icon
+        v-if="loadingMore"
+        name="ph:spinner-gap"
+        class="h-4 w-4 animate-spin mr-2"
+      />
+      <span v-if="loadingMore">Loading more...</span>
+      <span v-else>Load More Venues</span>
+    </Button>
+  </div>
+
+  <!-- Loading More Indicator -->
+  <div
+    v-if="isLoading && venues.length > 0 && !loadingMore"
+    class="mt-8 flex justify-center"
+  >
+    <div
+      class="animate-spin h-6 w-6 border-4 border-primary border-t-transparent rounded-full"
+    ></div>
+  </div>
+
   <!-- No Results -->
-  <div v-if="filteredVenues.length === 0" class="text-center py-12">
+  <div v-if="venues.length === 0 && !isLoading" class="text-center py-12">
     <Icon
       name="ph:magnifying-glass"
       class="w-12 h-12 mx-auto text-muted-foreground mb-4"
     />
     <h3 class="text-lg font-medium text-foreground mb-2">No venues found</h3>
     <p class="text-muted-foreground">
-      Try adjusting your filters or search terms
+      <span v-if="selectedStyles.length > 0">
+        No venues found with events offering
+        <strong>{{ selectedStyles.join(', ') }}</strong> dance styles.
+      </span>
+      <span v-else> Try adjusting your filters or search terms </span>
     </p>
   </div>
 
   <!-- Location Sheet -->
   <Sheet :open="showLocationFilter" @update:open="showLocationFilter = false">
-    <SheetContent side="bottom" class="h-[80vh]">
+    <SheetContent side="bottom" class="h-[80vh] flex flex-col">
       <SheetHeader>
         <SheetTitle>Filter by Location</SheetTitle>
       </SheetHeader>
-      <div class="mt-4">
+      <div class="mt-4 overflow-y-auto flex-1 pb-6">
         <LocationPanel
           :location="selectedLocation"
-          @update:location="updateLocation(selectedLocation)"
+          @update:location="updateLocation($event)"
         />
       </div>
     </SheetContent>
diff --git a/server/trpc/routers/profiles.ts b/server/trpc/routers/profiles.ts
index 614de4552..b75e396d3 100644
--- a/server/trpc/routers/profiles.ts
+++ b/server/trpc/routers/profiles.ts
@@ -310,6 +310,55 @@ export const profilesRouter = router({
 
     return Array.from(languagesSet).sort()
   }),
+  venueLocations: publicProcedure.query(async () => {
+    const citiesWithCount = await prisma.city.findMany({
+      where: {
+        profiles: {
+          some: {
+            type: 'Venue',
+          },
+        },
+      },
+      select: {
+        id: true,
+        name: true,
+        country: {
+          select: {
+            name: true,
+            code: true,
+          },
+        },
+        _count: {
+          select: {
+            profiles: {
+              where: {
+                type: 'Venue',
+              },
+            },
+          },
+        },
+      },
+      orderBy: {
+        profiles: {
+          _count: 'desc',
+        },
+      },
+    })
+
+    const cities = citiesWithCount.map((city) => ({
+      name: city.name,
+      displayName: `${city.name}${city.country ? ` - ${city.country.name}` : ''}`,
+      country: city.country ? city.country.name : null,
+      countryCode: city.country ? city.country.code : null,
+      count: city._count.profiles,
+      type: 'city',
+    }))
+
+    return {
+      cities: cities.sort((a, b) => b.count - a.count),
+      regions: [],
+    }
+  }),
   artists: publicProcedure
     .input(
       z
@@ -398,4 +447,186 @@ export const profilesRouter = router({
         hasMore: skip + artists.length < totalCount,
       }
     }),
+  allVenuesStyles: publicProcedure.query(async () => {
+    const styles = await prisma.danceStyle.findMany({
+      select: {
+        name: true,
+      },
+      orderBy: {
+        name: 'asc',
+      },
+    })
+
+    return styles.map((style) => style.name)
+  }),
+  venues: publicProcedure
+    .input(
+      z
+        .object({
+          limit: z.number().default(12),
+          page: z.number().default(1),
+          search: z.string().optional(),
+          location: z.string().optional(),
+          styles: z.array(z.string()).optional(),
+        })
+        .optional()
+    )
+    .query(async ({ input }) => {
+      const limit = input?.limit || 12
+      const page = input?.page || 1
+      const skip = (page - 1) * limit
+      const search = input?.search || ''
+      const location = input?.location || ''
+      const styles = input?.styles || []
+
+      const whereCondition: any = {
+        type: 'Venue',
+      }
+
+      if (search) {
+        whereCondition.AND = whereCondition.AND || []
+        whereCondition.AND.push({
+          OR: [
+            {
+              name: {
+                contains: search,
+                mode: 'insensitive',
+              },
+            },
+          ],
+        })
+      }
+
+      if (location) {
+        const cityName = location.split(' - ')[0].trim()
+
+        whereCondition.AND = whereCondition.AND || []
+        whereCondition.AND.push({
+          city: {
+            name: {
+              equals: cityName,
+              mode: 'insensitive',
+            },
+          },
+        })
+      }
+
+      let venueIdsWithStyles: string[] = []
+
+      if (styles.length > 0) {
+        const eventsWithStyles = await prisma.event.findMany({
+          where: {
+            styles: {
+              some: {
+                name: {
+                  in: styles,
+                },
+              },
+            },
+          },
+          select: {
+            venueId: true,
+            organizerId: true,
+          },
+        })
+
+        venueIdsWithStyles = Array.from(
+          new Set([
+            ...eventsWithStyles.map((e) => e.venueId).filter(Boolean),
+            ...eventsWithStyles.map((e) => e.organizerId).filter(Boolean),
+          ])
+        ) as string[]
+
+        if (venueIdsWithStyles.length > 0) {
+          whereCondition.id = {
+            in: venueIdsWithStyles,
+          }
+        } else if (styles.length > 0) {
+          return {
+            venues: [],
+            totalCount: 0,
+            hasMore: false,
+          }
+        }
+      }
+
+      const totalCount = await prisma.profile.count({
+        where: whereCondition,
+      })
+
+      const venues = await prisma.profile.findMany({
+        where: whereCondition,
+        select: {
+          id: true,
+          name: true,
+          username: true,
+          bio: true,
+          photo: true,
+          formattedAddress: true,
+          city: {
+            select: {
+              id: true,
+              name: true,
+            },
+          },
+          type: true,
+          createdAt: true,
+          updatedAt: true,
+        },
+        skip,
+        take: limit,
+        orderBy: {
+          name: 'asc',
+        },
+      })
+
+      const venueIds = venues.map((venue) => venue.id)
+
+      const eventsForVenues = await prisma.event.findMany({
+        where: {
+          OR: [
+            { venueId: { in: venueIds } },
+            { organizerId: { in: venueIds } },
+          ],
+        },
+        select: {
+          venueId: true,
+          organizerId: true,
+          styles: {
+            select: {
+              name: true,
+            },
+          },
+        },
+      })
+
+      const stylesByVenue = new Map<string, Set<string>>()
+
+      eventsForVenues.forEach((event) => {
+        const venueId = event.venueId || event.organizerId
+        if (!venueId) return
+
+        if (!stylesByVenue.has(venueId)) {
+          stylesByVenue.set(venueId, new Set<string>())
+        }
+
+        const venueStyles = stylesByVenue.get(venueId)!
+        event.styles.forEach((style) => {
+          venueStyles.add(style.name)
+        })
+      })
+
+      const venuesWithStyles = venues.map((venue) => {
+        return {
+          ...venue,
+          danceStyles: Array.from(stylesByVenue.get(venue.id) || []),
+        }
+      })
+
+      return {
+        venues: venuesWithStyles,
+        totalCount,
+        hasMore: skip + venues.length < totalCount,
+      }
+    }),
 })


Reviews:
user: login: razbakov
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T16:13:06Z
user: login: KindImagination
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T17:26:31Z
user: login: KindImagination
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T20:52:12Z
user: login: Nancheung23
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T08:33:41Z
user: login: razbakov
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T10:41:39Z
user: login: razbakov
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T19:08:17Z
user: login: KindImagination
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T11:48:44Z

Review comments:
diff_hunk: @@ -1,78 +1,213 @@
 <script setup lang="ts">
-import { mockVenues } from '~/data/mockVenues'
-const venues = mockVenues
+import { useDebounceFn } from '@vueuse/core'
 
+const venues = ref<any[]>([])
 const showFilters = ref(false)
 const showLocationFilter = ref(false)
 const search = ref('')
 const selectedLocation = ref<string | null>(null)
 const selectedStyles = ref<string[]>([])
-const selectedFeatures = ref<string[]>([])
-const selectedPriceRange = ref<[number, number]>([0, 200])
-const selectedCapacityRange = ref<[number, number]>([0, 200])
 
-const allStyles = computed(() =>
-  Array.from(new Set(venues.flatMap((venue) => venue.styles)))
-)
+const page = ref(1)
+const limit = ref(9)
+const totalCount = ref(0)
+const hasMore = ref(false)
+const isLoading = ref(false)
+const loadingMore = ref(false)
+const loadingTimes = ref<number[]>([])
+
+const allDanceStyles = ref<string[]>([])
+const loadingStyles = ref(false)
+
+const CACHE_KEY = 'wedance_venue_dance_styles'
+const CACHE_EXPIRY = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
+
+const loadStylesFromCache = () => {
+  if (process.client) {
+    try {
+      const cached = localStorage.getItem(CACHE_KEY)
path: components/VenuesView.vue
user: login: razbakov
body: this is bad. better just use vue-query
created_at: 2025-04-07T16:07:12Z
author_association: COLLABORATOR
diff_hunk: @@ -128,156 +266,230 @@ const clearLocationFilter = () => {
   >
     <!-- Dance Styles -->
     <div>
-      <h3 class="font-medium mb-3">Dance Styles</h3>
-      <div class="flex flex-wrap gap-2">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Dance Styles</h3>
         <Button
-          v-for="style in allStyles"
-          :key="style"
-          variant="outline"
+          v-if="selectedStyles.length > 0"
+          variant="ghost"
           size="sm"
-          :class="[
-            selectedStyles.includes(style)
-              ? 'bg-primary text-primary-foreground'
-              : '',
-          ]"
-          @click="
-            selectedStyles.includes(style)
-              ? (selectedStyles = selectedStyles.filter((s) => s !== style))
-              : selectedStyles.push(style)
-          "
+          @click="clearAllStyles"
         >
-          {{ style }}
+          Clear all
         </Button>
       </div>
-    </div>
-
-    <!-- Features -->
-    <div>
-      <h3 class="font-medium mb-3">Features</h3>
-      <div class="flex flex-wrap gap-2">
+      <p class="text-muted-foreground text-sm mb-4">
+        Filter venues that host events with these dance styles. Each number
+        shows how many venues offer that dance style.
+      </p>
+      <div v-if="loadingStyles" class="flex justify-center py-4">
+        <div
+          class="animate-spin h-6 w-6 border-4 border-primary border-t-transparent rounded-full"
+        ></div>
+      </div>
+      <div
+        v-else
+        class="flex flex-wrap gap-2 max-h-[300px] overflow-y-auto p-1"
+      >
         <Button
-          v-for="feature in allFeatures"
-          :key="feature"
+          v-for="styleObj in formattedDanceStyles"
+          :key="styleObj.style"
           variant="outline"
           size="sm"
           :class="[
-            selectedFeatures.includes(feature)
+            selectedStyles.includes(styleObj.style)
               ? 'bg-primary text-primary-foreground'
               : '',
           ]"
-          @click="
-            selectedFeatures.includes(feature)
-              ? (selectedFeatures = selectedFeatures.filter(
-                  (f) => f !== feature
-                ))
-              : selectedFeatures.push(feature)
-          "
+          @click="toggleStyle(styleObj.style)"
         >
-          {{ feature }}
+          {{ styleObj.style }}
         </Button>
       </div>
     </div>
 
+    <!-- Features -->
+    <div class="opacity-60 pointer-events-none">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Features</h3>
+        <Badge variant="outline" class="text-xs">Coming soon</Badge>
+      </div>
+      <div class="flex flex-wrap gap-2">
+        <Button variant="outline" size="sm"> Parking </Button>
+        <Button variant="outline" size="sm"> Air Conditioning </Button>
+        <Button variant="outline" size="sm"> Sound System </Button>
+        <Button variant="outline" size="sm"> Stage </Button>
+      </div>
+    </div>
+
     <!-- Price Range -->
-    <div>
-      <h3 class="font-medium mb-3">Price per Hour</h3>
+    <div class="opacity-60 pointer-events-none">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Price per Hour</h3>
+        <Badge variant="outline" class="text-xs">Coming soon</Badge>
+      </div>
       <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedPriceRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
+        <Input type="number" placeholder="Min" class="w-24" disabled />
         <span>to</span>
-        <Input
-          v-model="selectedPriceRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
+        <Input type="number" placeholder="Max" class="w-24" disabled />
         <span>€</span>
       </div>
     </div>
 
     <!-- Capacity -->
-    <div>
-      <h3 class="font-medium mb-3">Capacity</h3>
+    <div class="opacity-60 pointer-events-none">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Capacity</h3>
+        <Badge variant="outline" class="text-xs">Coming soon</Badge>
+      </div>
       <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedCapacityRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
+        <Input type="number" placeholder="Min" class="w-24" disabled />
         <span>to</span>
-        <Input
-          v-model="selectedCapacityRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
+        <Input type="number" placeholder="Max" class="w-24" disabled />
         <span>people</span>
       </div>
     </div>
   </div>
 
+  <!-- Loading State -->
+  <div
+    v-if="isLoading && venues.length === 0"
+    class="py-10 flex justify-center"
+  >
+    <div
+      class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
+    ></div>
+  </div>
+
   <!-- Venues Grid -->
-  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
+  <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
     <NuxtLink
-      v-for="venue in filteredVenues"
+      v-for="venue in venues"
       :key="venue.id"
       :to="`/venues/${venue.id}`"
       class="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-lg transition-all duration-300"
     >
       <div class="aspect-video relative overflow-hidden">
-        <img
-          :src="venue.image"
+        <NuxtImg
+          :src="
+            venue.photo ||
+            'https://placehold.co/400x225/e2e8f0/334155?text=Venue'
+          "
           :alt="venue.name"
           class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
+          width="400"
+          height="225"
+          loading="lazy"
+          format="webp"
+          quality="80"
         />
       </div>
       <div class="p-6">
         <div class="flex justify-between items-start mb-2">
           <h3 class="font-bold text-lg text-foreground">
             {{ venue.name }}
           </h3>
-          <div class="flex items-center gap-1 text-amber-500">
+          <div
+            v-if="venue.rating"
+            class="flex items-center gap-1 text-amber-500"
+          >
             <Icon name="ph:star-fill" class="w-4 h-4" />
             <span class="text-sm">{{ venue.rating }}</span>
           </div>
         </div>
         <p class="text-muted-foreground text-sm mb-2">
-          {{ venue.address }}
+          {{ getAddress(venue) }}
         </p>
-        <div class="flex items-center gap-4 text-sm text-muted-foreground mb-3">
-          <div class="flex items-center gap-1">
+        <div
+          v-if="venue.areas && venue.areas.length > 0"
+          class="flex items-center gap-4 text-sm text-muted-foreground mb-3"
+        >
+          <div
+            v-if="venue.areas.some((a: any) => a.pricePerHour)"
+            class="flex items-center gap-1"
+          >
             <Icon name="ph:currency-eur" class="w-4 h-4" />
-            {{ Math.min(...venue.areas.map((a) => a.pricePerHour)) }}-{{
-              Math.max(...venue.areas.map((a) => a.pricePerHour))
+            {{
+              Math.min(
path: components/VenuesView.vue
user: login: razbakov
body: so that would be the minimum of what? of shown venues? this have to be done across all venues and you don't have that information on client, so you need to do it in backend
created_at: 2025-04-07T16:12:44Z
author_association: COLLABORATOR
diff_hunk: @@ -1,78 +1,213 @@
 <script setup lang="ts">
-import { mockVenues } from '~/data/mockVenues'
-const venues = mockVenues
+import { useDebounceFn } from '@vueuse/core'
 
+const venues = ref<any[]>([])
 const showFilters = ref(false)
 const showLocationFilter = ref(false)
 const search = ref('')
 const selectedLocation = ref<string | null>(null)
 const selectedStyles = ref<string[]>([])
-const selectedFeatures = ref<string[]>([])
-const selectedPriceRange = ref<[number, number]>([0, 200])
-const selectedCapacityRange = ref<[number, number]>([0, 200])
 
-const allStyles = computed(() =>
-  Array.from(new Set(venues.flatMap((venue) => venue.styles)))
-)
+const page = ref(1)
+const limit = ref(9)
+const totalCount = ref(0)
+const hasMore = ref(false)
+const isLoading = ref(false)
+const loadingMore = ref(false)
+const loadingTimes = ref<number[]>([])
+
+const allDanceStyles = ref<string[]>([])
+const loadingStyles = ref(false)
+
+const CACHE_KEY = 'wedance_venue_dance_styles'
+const CACHE_EXPIRY = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
+
+const loadStylesFromCache = () => {
+  if (process.client) {
+    try {
+      const cached = localStorage.getItem(CACHE_KEY)
path: components/VenuesView.vue
user: login: KindImagination
body: solved
created_at: 2025-04-07T17:26:31Z
author_association: COLLABORATOR
diff_hunk: @@ -128,156 +266,230 @@ const clearLocationFilter = () => {
   >
     <!-- Dance Styles -->
     <div>
-      <h3 class="font-medium mb-3">Dance Styles</h3>
-      <div class="flex flex-wrap gap-2">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Dance Styles</h3>
         <Button
-          v-for="style in allStyles"
-          :key="style"
-          variant="outline"
+          v-if="selectedStyles.length > 0"
+          variant="ghost"
           size="sm"
-          :class="[
-            selectedStyles.includes(style)
-              ? 'bg-primary text-primary-foreground'
-              : '',
-          ]"
-          @click="
-            selectedStyles.includes(style)
-              ? (selectedStyles = selectedStyles.filter((s) => s !== style))
-              : selectedStyles.push(style)
-          "
+          @click="clearAllStyles"
         >
-          {{ style }}
+          Clear all
         </Button>
       </div>
-    </div>
-
-    <!-- Features -->
-    <div>
-      <h3 class="font-medium mb-3">Features</h3>
-      <div class="flex flex-wrap gap-2">
+      <p class="text-muted-foreground text-sm mb-4">
+        Filter venues that host events with these dance styles. Each number
+        shows how many venues offer that dance style.
+      </p>
+      <div v-if="loadingStyles" class="flex justify-center py-4">
+        <div
+          class="animate-spin h-6 w-6 border-4 border-primary border-t-transparent rounded-full"
+        ></div>
+      </div>
+      <div
+        v-else
+        class="flex flex-wrap gap-2 max-h-[300px] overflow-y-auto p-1"
+      >
         <Button
-          v-for="feature in allFeatures"
-          :key="feature"
+          v-for="styleObj in formattedDanceStyles"
+          :key="styleObj.style"
           variant="outline"
           size="sm"
           :class="[
-            selectedFeatures.includes(feature)
+            selectedStyles.includes(styleObj.style)
               ? 'bg-primary text-primary-foreground'
               : '',
           ]"
-          @click="
-            selectedFeatures.includes(feature)
-              ? (selectedFeatures = selectedFeatures.filter(
-                  (f) => f !== feature
-                ))
-              : selectedFeatures.push(feature)
-          "
+          @click="toggleStyle(styleObj.style)"
         >
-          {{ feature }}
+          {{ styleObj.style }}
         </Button>
       </div>
     </div>
 
+    <!-- Features -->
+    <div class="opacity-60 pointer-events-none">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Features</h3>
+        <Badge variant="outline" class="text-xs">Coming soon</Badge>
+      </div>
+      <div class="flex flex-wrap gap-2">
+        <Button variant="outline" size="sm"> Parking </Button>
+        <Button variant="outline" size="sm"> Air Conditioning </Button>
+        <Button variant="outline" size="sm"> Sound System </Button>
+        <Button variant="outline" size="sm"> Stage </Button>
+      </div>
+    </div>
+
     <!-- Price Range -->
-    <div>
-      <h3 class="font-medium mb-3">Price per Hour</h3>
+    <div class="opacity-60 pointer-events-none">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Price per Hour</h3>
+        <Badge variant="outline" class="text-xs">Coming soon</Badge>
+      </div>
       <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedPriceRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
+        <Input type="number" placeholder="Min" class="w-24" disabled />
         <span>to</span>
-        <Input
-          v-model="selectedPriceRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
+        <Input type="number" placeholder="Max" class="w-24" disabled />
         <span>€</span>
       </div>
     </div>
 
     <!-- Capacity -->
-    <div>
-      <h3 class="font-medium mb-3">Capacity</h3>
+    <div class="opacity-60 pointer-events-none">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Capacity</h3>
+        <Badge variant="outline" class="text-xs">Coming soon</Badge>
+      </div>
       <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedCapacityRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
+        <Input type="number" placeholder="Min" class="w-24" disabled />
         <span>to</span>
-        <Input
-          v-model="selectedCapacityRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
+        <Input type="number" placeholder="Max" class="w-24" disabled />
         <span>people</span>
       </div>
     </div>
   </div>
 
+  <!-- Loading State -->
+  <div
+    v-if="isLoading && venues.length === 0"
+    class="py-10 flex justify-center"
+  >
+    <div
+      class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
+    ></div>
+  </div>
+
   <!-- Venues Grid -->
-  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
+  <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
     <NuxtLink
-      v-for="venue in filteredVenues"
+      v-for="venue in venues"
       :key="venue.id"
       :to="`/venues/${venue.id}`"
       class="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-lg transition-all duration-300"
     >
       <div class="aspect-video relative overflow-hidden">
-        <img
-          :src="venue.image"
+        <NuxtImg
+          :src="
+            venue.photo ||
+            'https://placehold.co/400x225/e2e8f0/334155?text=Venue'
+          "
           :alt="venue.name"
           class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
+          width="400"
+          height="225"
+          loading="lazy"
+          format="webp"
+          quality="80"
         />
       </div>
       <div class="p-6">
         <div class="flex justify-between items-start mb-2">
           <h3 class="font-bold text-lg text-foreground">
             {{ venue.name }}
           </h3>
-          <div class="flex items-center gap-1 text-amber-500">
+          <div
+            v-if="venue.rating"
+            class="flex items-center gap-1 text-amber-500"
+          >
             <Icon name="ph:star-fill" class="w-4 h-4" />
             <span class="text-sm">{{ venue.rating }}</span>
           </div>
         </div>
         <p class="text-muted-foreground text-sm mb-2">
-          {{ venue.address }}
+          {{ getAddress(venue) }}
         </p>
-        <div class="flex items-center gap-4 text-sm text-muted-foreground mb-3">
-          <div class="flex items-center gap-1">
+        <div
+          v-if="venue.areas && venue.areas.length > 0"
+          class="flex items-center gap-4 text-sm text-muted-foreground mb-3"
+        >
+          <div
+            v-if="venue.areas.some((a: any) => a.pricePerHour)"
+            class="flex items-center gap-1"
+          >
             <Icon name="ph:currency-eur" class="w-4 h-4" />
-            {{ Math.min(...venue.areas.map((a) => a.pricePerHour)) }}-{{
-              Math.max(...venue.areas.map((a) => a.pricePerHour))
+            {{
+              Math.min(
path: components/VenuesView.vue
user: login: KindImagination
body: This part is not in the database
created_at: 2025-04-07T20:52:12Z
author_association: COLLABORATOR
diff_hunk: @@ -11,29 +11,62 @@ const props = defineProps({
 })
 
 const locationSearch = ref('')
+const loadingCities = ref(false)
+const loadingRegions = ref(false)
 
-const regions = [
-  { name: 'Europe', count: '15k+' },
-  { name: 'North America', count: '12k+' },
-  { name: 'Latin America', count: '8k+' },
-  { name: 'Asia', count: '5k+' },
-]
+const cities = ref<Array<{ name: string; count: string }>>([])
+const regions = ref<Array<{ name: string; count: string }>>([])
 
-const cities = [
-  { name: 'New York', count: '2.5k+' },
-  { name: 'London', count: '2k+' },
-  { name: 'Madrid', count: '1.8k+' },
-  { name: 'Berlin', count: '1.5k+' },
-]
+const fetchLocations = async () => {
+  loadingCities.value = true
+  loadingRegions.value = true
+
+  try {
+    const venueCities = await trpc.profiles.venueLocations.query()
+    cities.value = venueCities.cities.map((city) => ({
+      name: city.name,
+      count: `${city.count}+`,
+    }))
+
+    regions.value = venueCities.regions.map((region) => ({
+      name: region.name,
+      count: `${region.count}+`,
+    }))
+  } catch (error) {
+    console.error('Error fetching venue locations:', error)
+
+    cities.value = [
+      { name: 'New York', count: '10+' },
+      { name: 'London', count: '8+' },
+      { name: 'Berlin', count: '5+' },
+      { name: 'Madrid', count: '3+' },
+    ]
+
+    regions.value = [
+      { name: 'Europe', count: '20+' },
+      { name: 'North America', count: '15+' },
+      { name: 'Asia', count: '5+' },
+    ]
+  } finally {
+    loadingCities.value = false
+    loadingRegions.value = false
+  }
+}
 
 const filteredRegions = computed(() => {
   const search = locationSearch.value.toLowerCase()
-  return regions.filter((region) => region.name.toLowerCase().includes(search))
+  return regions.value.filter((region) =>
+    region.name.toLowerCase().includes(search)
+  )
 })
 
 const filteredCities = computed(() => {
   const search = locationSearch.value.toLowerCase()
-  return cities.filter((city) => city.name.toLowerCase().includes(search))
+  return cities.value.filter((city) => city.name.toLowerCase().includes(search))
+})
+
+onMounted(() => {
path: components/LocationPanel.vue
user: login: razbakov
body: that should be async
created_at: 2025-04-08T10:38:10Z
author_association: COLLABORATOR
diff_hunk: @@ -66,22 +112,45 @@ const filteredCities = computed(() => {
         }}</span>
       </Button>
 
-      <div class="font-medium text-sm text-muted-foreground mt-2">
-        Popular Cities
+      <div
+        class="font-medium text-sm text-muted-foreground mt-2 flex items-center justify-between"
+      >
+        Cities with Venues
+        <span v-if="loadingCities" class="text-xs text-muted-foreground">
+          <Icon name="ph:spinner-gap" class="w-3 h-3 animate-spin" />
+        </span>
       </div>
-      <Button
-        v-for="city in filteredCities"
-        :key="city.name"
-        variant="ghost"
-        class="justify-start"
-        :class="{ 'bg-accent': props.location === city.name }"
-        @click="emit('update:location', city.name)"
+
+      <div
+        v-if="!loadingCities && filteredCities.length === 0"
+        class="text-sm text-muted-foreground py-2 px-3"
       >
-        {{ city.name }}
-        <span class="ml-auto text-sm text-muted-foreground">{{
-          city.count
-        }}</span>
-      </Button>
+        No cities found
+      </div>
+
+      <div class="city-list">
+        <Button
+          v-for="city in filteredCities"
+          :key="city.name"
+          variant="ghost"
+          class="justify-start w-full"
+          :class="{ 'bg-accent': props.location === city.name }"
+          @click="emit('update:location', city.name)"
+        >
+          {{ city.name }}
+          <span class="ml-auto text-sm text-muted-foreground">{{
+            city.count
+          }}</span>
+        </Button>
+      </div>
     </div>
   </div>
 </template>
+
+<style scoped>
+.city-list {
path: components/LocationPanel.vue
user: login: razbakov
body: use tailwind
created_at: 2025-04-08T10:38:25Z
author_association: COLLABORATOR
diff_hunk: @@ -1,78 +1,170 @@
 <script setup lang="ts">
-import { mockVenues } from '~/data/mockVenues'
-const venues = mockVenues
+import { useDebounceFn } from '@vueuse/core'
 
+const venues = ref<any[]>([])
 const showFilters = ref(false)
 const showLocationFilter = ref(false)
 const search = ref('')
 const selectedLocation = ref<string | null>(null)
 const selectedStyles = ref<string[]>([])
-const selectedFeatures = ref<string[]>([])
-const selectedPriceRange = ref<[number, number]>([0, 200])
-const selectedCapacityRange = ref<[number, number]>([0, 200])
 
-const allStyles = computed(() =>
-  Array.from(new Set(venues.flatMap((venue) => venue.styles)))
-)
+const page = ref(1)
+const limit = ref(9)
+const totalCount = ref(0)
+const hasMore = ref(false)
+const isLoading = ref(false)
+const loadingMore = ref(false)
+const loadingTimes = ref<number[]>([])
+
+const allDanceStyles = ref<string[]>([])
+const loadingStyles = ref(false)
 
-const allFeatures = computed(() =>
-  Array.from(new Set(venues.flatMap((venue) => venue.features)))
+const allStyles = computed(() =>
path: components/VenuesView.vue
user: login: razbakov
body: list should come from backend
created_at: 2025-04-08T10:39:01Z
author_association: COLLABORATOR
diff_hunk: @@ -11,29 +11,85 @@ const props = defineProps({
 })
 
 const locationSearch = ref('')
+const loadingCities = ref(false)
 
-const regions = [
-  { name: 'Europe', count: '15k+' },
-  { name: 'North America', count: '12k+' },
-  { name: 'Latin America', count: '8k+' },
-  { name: 'Asia', count: '5k+' },
-]
+const cities = ref<
+  Array<{
+    name: string
+    displayName: string
+    country: string | null
+    countryCode: string | null
+    count: string
+    type: string
+  }>
+>([])
 
-const cities = [
-  { name: 'New York', count: '2.5k+' },
-  { name: 'London', count: '2k+' },
-  { name: 'Madrid', count: '1.8k+' },
-  { name: 'Berlin', count: '1.5k+' },
-]
+const fetchLocations = async () => {
+  loadingCities.value = true
 
-const filteredRegions = computed(() => {
-  const search = locationSearch.value.toLowerCase()
-  return regions.filter((region) => region.name.toLowerCase().includes(search))
-})
+  try {
+    const venueLocations = await trpc.profiles.venueLocations.query()
+    cities.value = venueLocations.cities.map((city) => ({
+      name: city.name,
+      displayName: city.displayName || city.name,
+      country: city.country || null,
+      countryCode: city.countryCode || null,
+      count: `${city.count}`,
+      type: city.type || 'address',
+    }))
+  } catch (error) {
+    console.error('Error fetching venue locations:', error)
+
+    cities.value = [
+      {
+        name: 'New York',
path: components/LocationPanel.vue
user: login: razbakov
body: what's this for?
created_at: 2025-04-08T19:05:14Z
author_association: COLLABORATOR
diff_hunk: @@ -128,176 +231,199 @@ const clearLocationFilter = () => {
   >
     <!-- Dance Styles -->
     <div>
-      <h3 class="font-medium mb-3">Dance Styles</h3>
-      <div class="flex flex-wrap gap-2">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Dance Styles</h3>
         <Button
-          v-for="style in allStyles"
-          :key="style"
-          variant="outline"
+          v-if="selectedStyles.length > 0"
+          variant="ghost"
           size="sm"
-          :class="[
-            selectedStyles.includes(style)
-              ? 'bg-primary text-primary-foreground'
-              : '',
-          ]"
-          @click="
-            selectedStyles.includes(style)
-              ? (selectedStyles = selectedStyles.filter((s) => s !== style))
-              : selectedStyles.push(style)
-          "
+          @click="clearAllStyles"
         >
-          {{ style }}
+          Clear all
         </Button>
       </div>
-    </div>
-
-    <!-- Features -->
-    <div>
-      <h3 class="font-medium mb-3">Features</h3>
-      <div class="flex flex-wrap gap-2">
-        <Button
-          v-for="feature in allFeatures"
-          :key="feature"
-          variant="outline"
-          size="sm"
-          :class="[
-            selectedFeatures.includes(feature)
-              ? 'bg-primary text-primary-foreground'
-              : '',
-          ]"
-          @click="
-            selectedFeatures.includes(feature)
-              ? (selectedFeatures = selectedFeatures.filter(
-                  (f) => f !== feature
-                ))
-              : selectedFeatures.push(feature)
-          "
-        >
-          {{ feature }}
-        </Button>
+      <p class="text-muted-foreground text-sm mb-4">
+        Filter venues that host events with these dance styles.
+      </p>
+      <div v-if="loadingStyles" class="flex justify-center py-4">
+        <div
+          class="animate-spin h-6 w-6 border-4 border-primary border-t-transparent rounded-full"
+        ></div>
       </div>
-    </div>
-
-    <!-- Price Range -->
-    <div>
-      <h3 class="font-medium mb-3">Price per Hour</h3>
-      <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedPriceRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
-        <span>to</span>
-        <Input
-          v-model="selectedPriceRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
-        <span>€</span>
+      <div v-else>
+        <div class="relative">
+          <Input
+            v-model="styleSearch"
+            placeholder="Search dance styles..."
+            class="mb-2 w-full"
+          />
+          <div class="max-h-[300px] overflow-y-auto border rounded-md">
+            <div
+              v-for="styleObj in filteredDanceStyles"
+              :key="styleObj.style"
+              class="px-3 py-2 hover:bg-muted border-b last:border-b-0 cursor-pointer transition-colors"
+              :class="{
+                'bg-muted font-medium': selectedStyles.includes(styleObj.style),
+              }"
+              @click="toggleStyle(styleObj.style)"
+            >
+              {{ styleObj.style }}
+            </div>
+            <div
+              v-if="filteredDanceStyles.length === 0"
+              class="p-4 text-center text-muted-foreground"
+            >
+              No dance styles found
+            </div>
+          </div>
+        </div>
       </div>
     </div>
 
-    <!-- Capacity -->
-    <div>
-      <h3 class="font-medium mb-3">Capacity</h3>
-      <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedCapacityRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
-        <span>to</span>
-        <Input
-          v-model="selectedCapacityRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
-        <span>people</span>
+    <!-- Amenities -->
+    <div class="opacity-60 pointer-events-none">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Amenities</h3>
+        <Badge variant="outline" class="text-xs">Coming soon</Badge>
+      </div>
+      <div class="flex flex-wrap gap-2">
+        <Button variant="outline" size="sm"> Parking </Button>
+        <Button variant="outline" size="sm"> Air Conditioning </Button>
+        <Button variant="outline" size="sm"> Sound System </Button>
+        <Button variant="outline" size="sm"> Stage </Button>
       </div>
     </div>
   </div>
 
+  <!-- Loading State -->
+  <div
+    v-if="isLoading && venues.length === 0"
+    class="py-10 flex justify-center"
+  >
+    <div
+      class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
+    ></div>
+  </div>
+
   <!-- Venues Grid -->
-  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
+  <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
     <NuxtLink
-      v-for="venue in filteredVenues"
+      v-for="venue in venues"
       :key="venue.id"
-      :to="`/venues/${venue.id}`"
+      :to="`/@${venue.username}`"
       class="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-lg transition-all duration-300"
     >
       <div class="aspect-video relative overflow-hidden">
-        <img
-          :src="venue.image"
+        <NuxtImg
+          :src="
+            venue.photo ||
+            'https://placehold.co/400x225/e2e8f0/334155?text=Venue'
+          "
           :alt="venue.name"
           class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
+          width="400"
+          height="225"
+          loading="lazy"
+          format="webp"
+          quality="80"
         />
       </div>
       <div class="p-6">
         <div class="flex justify-between items-start mb-2">
           <h3 class="font-bold text-lg text-foreground">
             {{ venue.name }}
           </h3>
-          <div class="flex items-center gap-1 text-amber-500">
+          <div
+            v-if="venue.rating"
+            class="flex items-center gap-1 text-amber-500"
+          >
             <Icon name="ph:star-fill" class="w-4 h-4" />
             <span class="text-sm">{{ venue.rating }}</span>
           </div>
         </div>
         <p class="text-muted-foreground text-sm mb-2">
-          {{ venue.address }}
+          {{ getAddress(venue) }}
         </p>
-        <div class="flex items-center gap-4 text-sm text-muted-foreground mb-3">
-          <div class="flex items-center gap-1">
-            <Icon name="ph:currency-eur" class="w-4 h-4" />
-            {{ Math.min(...venue.areas.map((a) => a.pricePerHour)) }}-{{
-              Math.max(...venue.areas.map((a) => a.pricePerHour))
-            }}/hour
+        <!-- TODO: Add price per hour and capacity -->
+        <p v-if="venue.bio" class="text-foreground mb-4 line-clamp-2">
+          {{ venue.bio }}
+        </p>
+        <div v-if="venue.danceStyles?.length" class="space-y-1">
+          <div class="flex flex-wrap gap-2 mb-1">
+            <Badge
+              v-for="style in (venue.danceStyles || []).slice(0, 3)"
+              :key="style"
+              variant="secondary"
+              class="flex items-center"
+            >
+              <span>{{ style }}</span>
+            </Badge>
+            <Badge
+              v-if="(venue.danceStyles || []).length > 3"
+              variant="outline"
+              class="text-xs"
+            >
+              +{{ venue.danceStyles.length - 3 }} more
+            </Badge>
           </div>
-          <div class="flex items-center gap-1">
-            <Icon name="ph:users" class="w-4 h-4" />
-            {{ venue.areas.reduce((sum, area) => sum + area.capacity, 0) }}
-            people
+          <div class="flex items-center gap-1 text-xs text-muted-foreground">
+            <Icon name="ph:calendar-check" class="w-3 h-3" />
+            <span>Dance styles from events</span>
path: components/VenuesView.vue
user: login: razbakov
body: what's this for?
created_at: 2025-04-08T19:07:46Z
author_association: COLLABORATOR
diff_hunk: @@ -128,176 +231,199 @@ const clearLocationFilter = () => {
   >
     <!-- Dance Styles -->
     <div>
-      <h3 class="font-medium mb-3">Dance Styles</h3>
-      <div class="flex flex-wrap gap-2">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Dance Styles</h3>
         <Button
-          v-for="style in allStyles"
-          :key="style"
-          variant="outline"
+          v-if="selectedStyles.length > 0"
+          variant="ghost"
           size="sm"
-          :class="[
-            selectedStyles.includes(style)
-              ? 'bg-primary text-primary-foreground'
-              : '',
-          ]"
-          @click="
-            selectedStyles.includes(style)
-              ? (selectedStyles = selectedStyles.filter((s) => s !== style))
-              : selectedStyles.push(style)
-          "
+          @click="clearAllStyles"
         >
-          {{ style }}
+          Clear all
         </Button>
       </div>
-    </div>
-
-    <!-- Features -->
-    <div>
-      <h3 class="font-medium mb-3">Features</h3>
-      <div class="flex flex-wrap gap-2">
-        <Button
-          v-for="feature in allFeatures"
-          :key="feature"
-          variant="outline"
-          size="sm"
-          :class="[
-            selectedFeatures.includes(feature)
-              ? 'bg-primary text-primary-foreground'
-              : '',
-          ]"
-          @click="
-            selectedFeatures.includes(feature)
-              ? (selectedFeatures = selectedFeatures.filter(
-                  (f) => f !== feature
-                ))
-              : selectedFeatures.push(feature)
-          "
-        >
-          {{ feature }}
-        </Button>
+      <p class="text-muted-foreground text-sm mb-4">
+        Filter venues that host events with these dance styles.
+      </p>
+      <div v-if="loadingStyles" class="flex justify-center py-4">
+        <div
+          class="animate-spin h-6 w-6 border-4 border-primary border-t-transparent rounded-full"
+        ></div>
       </div>
-    </div>
-
-    <!-- Price Range -->
-    <div>
-      <h3 class="font-medium mb-3">Price per Hour</h3>
-      <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedPriceRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
-        <span>to</span>
-        <Input
-          v-model="selectedPriceRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
-        <span>€</span>
+      <div v-else>
+        <div class="relative">
+          <Input
+            v-model="styleSearch"
+            placeholder="Search dance styles..."
+            class="mb-2 w-full"
+          />
+          <div class="max-h-[300px] overflow-y-auto border rounded-md">
+            <div
+              v-for="styleObj in filteredDanceStyles"
+              :key="styleObj.style"
+              class="px-3 py-2 hover:bg-muted border-b last:border-b-0 cursor-pointer transition-colors"
+              :class="{
+                'bg-muted font-medium': selectedStyles.includes(styleObj.style),
+              }"
+              @click="toggleStyle(styleObj.style)"
+            >
+              {{ styleObj.style }}
+            </div>
+            <div
+              v-if="filteredDanceStyles.length === 0"
+              class="p-4 text-center text-muted-foreground"
+            >
+              No dance styles found
+            </div>
+          </div>
+        </div>
       </div>
     </div>
 
-    <!-- Capacity -->
-    <div>
-      <h3 class="font-medium mb-3">Capacity</h3>
-      <div class="flex items-center gap-4">
-        <Input
-          v-model="selectedCapacityRange[0]"
-          type="number"
-          placeholder="Min"
-          class="w-24"
-        />
-        <span>to</span>
-        <Input
-          v-model="selectedCapacityRange[1]"
-          type="number"
-          placeholder="Max"
-          class="w-24"
-        />
-        <span>people</span>
+    <!-- Amenities -->
+    <div class="opacity-60 pointer-events-none">
+      <div class="flex justify-between items-center mb-3">
+        <h3 class="font-medium">Amenities</h3>
+        <Badge variant="outline" class="text-xs">Coming soon</Badge>
+      </div>
+      <div class="flex flex-wrap gap-2">
+        <Button variant="outline" size="sm"> Parking </Button>
+        <Button variant="outline" size="sm"> Air Conditioning </Button>
+        <Button variant="outline" size="sm"> Sound System </Button>
+        <Button variant="outline" size="sm"> Stage </Button>
       </div>
     </div>
   </div>
 
+  <!-- Loading State -->
+  <div
+    v-if="isLoading && venues.length === 0"
+    class="py-10 flex justify-center"
+  >
+    <div
+      class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
+    ></div>
+  </div>
+
   <!-- Venues Grid -->
-  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
+  <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
     <NuxtLink
-      v-for="venue in filteredVenues"
+      v-for="venue in venues"
       :key="venue.id"
-      :to="`/venues/${venue.id}`"
+      :to="`/@${venue.username}`"
       class="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-lg transition-all duration-300"
     >
       <div class="aspect-video relative overflow-hidden">
-        <img
-          :src="venue.image"
+        <NuxtImg
+          :src="
+            venue.photo ||
+            'https://placehold.co/400x225/e2e8f0/334155?text=Venue'
+          "
           :alt="venue.name"
           class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
+          width="400"
+          height="225"
+          loading="lazy"
+          format="webp"
+          quality="80"
         />
       </div>
       <div class="p-6">
         <div class="flex justify-between items-start mb-2">
           <h3 class="font-bold text-lg text-foreground">
             {{ venue.name }}
           </h3>
-          <div class="flex items-center gap-1 text-amber-500">
+          <div
+            v-if="venue.rating"
+            class="flex items-center gap-1 text-amber-500"
+          >
             <Icon name="ph:star-fill" class="w-4 h-4" />
             <span class="text-sm">{{ venue.rating }}</span>
           </div>
         </div>
         <p class="text-muted-foreground text-sm mb-2">
-          {{ venue.address }}
+          {{ getAddress(venue) }}
         </p>
-        <div class="flex items-center gap-4 text-sm text-muted-foreground mb-3">
-          <div class="flex items-center gap-1">
-            <Icon name="ph:currency-eur" class="w-4 h-4" />
-            {{ Math.min(...venue.areas.map((a) => a.pricePerHour)) }}-{{
-              Math.max(...venue.areas.map((a) => a.pricePerHour))
-            }}/hour
+        <!-- TODO: Add price per hour and capacity -->
+        <p v-if="venue.bio" class="text-foreground mb-4 line-clamp-2">
+          {{ venue.bio }}
+        </p>
+        <div v-if="venue.danceStyles?.length" class="space-y-1">
+          <div class="flex flex-wrap gap-2 mb-1">
+            <Badge
+              v-for="style in (venue.danceStyles || []).slice(0, 3)"
+              :key="style"
+              variant="secondary"
+              class="flex items-center"
+            >
+              <span>{{ style }}</span>
+            </Badge>
+            <Badge
+              v-if="(venue.danceStyles || []).length > 3"
+              variant="outline"
+              class="text-xs"
+            >
+              +{{ venue.danceStyles.length - 3 }} more
+            </Badge>
           </div>
-          <div class="flex items-center gap-1">
-            <Icon name="ph:users" class="w-4 h-4" />
-            {{ venue.areas.reduce((sum, area) => sum + area.capacity, 0) }}
-            people
+          <div class="flex items-center gap-1 text-xs text-muted-foreground">
+            <Icon name="ph:calendar-check" class="w-3 h-3" />
+            <span>Dance styles from events</span>
path: components/VenuesView.vue
user: login: KindImagination
body: removed
created_at: 2025-04-09T11:48:44Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-07T14:56:03Z
body: [vc]: #QPqjHei+c+fnYJuUpeeX2n2j//xsER6UvJNoKpUvTJk=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ2NCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJ2NC1naXQtZmVhdHVyZS1saXN0LW9mLXZlbnVlcy13ZWRhbmNlLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3dlZGFuY2UvdjQvR3pxRGF3QVVNY3g4Ym5yNGpocWpZY01UcEFIcSIsInByZXZpZXdVcmwiOiJ2NC1naXQtZmVhdHVyZS1saXN0LW9mLXZlbnVlcy13ZWRhbmNlLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJyb290RGlyZWN0b3J5IjpudWxsfV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **v4** | ✅ Ready ([Inspect](https://vercel.com/wedance/v4/GzqDawAUMcx8bnr4jhqjYcMTpAHq)) | [Visit Preview](https://v4-git-feature-list-of-venues-wedance.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/v4-git-feature-list-of-venues-wedance.vercel.app?via=pr-comment-feedback-link) | Apr 9, 2025 11:36am |


author_association: NONE
user: login: Nancheung23
created_at: 2025-04-08T08:33:21Z
body: All filter methods work
Recording of test:
https://github.com/user-attachments/assets/10465c92-50d4-4d71-8075-37f51eeefd5d

author_association: CONTRIBUTOR