Menu

Change language

GitHub repo

Store Locator Map

blockdashboard

Store locator. Map pins plus a compact list with open/closed status and fly-to on select.

Installation

$npx uipkge-ng@latest add store-locator-map
Manifest:https://uipkge.dev/r/angular/store-locator-map.jsonInstalls to:src/app/components/blocks/store-locator-map/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

RetailStore
interface RetailStore {
  id: string
  name: string
  city: string
  country: string
  lat: number
  lng: number
  isOpen: boolean
}

npm dependencies

Includes

Files installed (2)

  • src/app/components/blocks/store-locator-map/store-locator-map.component.ts6.9 kB
    // Store locator. Map pins plus a compact list with open/closed status; selecting a store
    // (pin or row) flies the camera there.
    import {
      type AfterViewInit,
      Component,
      type ElementRef,
      Input,
      type OnDestroy,
      type OnInit,
      type QueryList,
      ViewChildren,
      signal,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import type * as mapboxgl from 'mapbox-gl'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiMapComponent } from '@/ui/map/map.component'
    
    export interface RetailStore {
      id: string
      name: string
      city: string
      country: string
      lat: number
      lng: number
      isOpen: boolean
    }
    
    export const DEFAULT_STORES: RetailStore[] = [
      {
        id: 'nyc-soho',
        name: 'SoHo Flagship',
        city: 'New York',
        country: 'United States',
        lat: 40.7248,
        lng: -73.9984,
        isOpen: true,
      },
      {
        id: 'lon-regent',
        name: 'Regent Street',
        city: 'London',
        country: 'United Kingdom',
        lat: 51.5142,
        lng: -0.1418,
        isOpen: true,
      },
      { id: 'tyo-ginza', name: 'Ginza Studio', city: 'Tokyo', country: 'Japan', lat: 35.6719, lng: 139.7658, isOpen: true },
      {
        id: 'par-champs',
        name: 'Champs-Élysées',
        city: 'Paris',
        country: 'France',
        lat: 48.8718,
        lng: 2.3013,
        isOpen: true,
      },
      {
        id: 'syd-george',
        name: 'George Street',
        city: 'Sydney',
        country: 'Australia',
        lat: -33.8688,
        lng: 151.2093,
        isOpen: false,
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-store-locator-map, [ui-store-locator-map]',
      standalone: true,
      imports: [
        UiBadgeComponent,
        UiCardComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiCardContentComponent,
        UiMapComponent,
      ],
      host: {
        'data-slot': 'store-locator-map',
        '[class]': 'hostClass',
      },
      template: `
        <div class="flex flex-wrap items-center justify-between gap-3">
          <h2 class="text-foreground text-lg font-semibold tracking-tight">Stores</h2>
          <p class="text-muted-foreground text-xs">{{ openCount }} open · {{ stores.length }} locations</p>
        </div>
    
        <div class="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-12">
          <div ui-card class="border-border flex min-h-0 flex-col overflow-hidden shadow-xs lg:col-span-8">
            <div class="relative min-h-[360px] flex-1 overflow-hidden">
              <div
                ui-map
                [accessToken]="accessToken ?? ''"
                variant="dark"
                [center]="[10, 20]"
                [zoom]="1.5"
                class="absolute inset-0 size-full"
                (created)="onMapCreated($event)"
              >
                <!-- Marker elements: handed to mapbox-gl Markers once the map exists, which moves the
                     inner #marker node onto the map; the outer node stays put for Angular's @for. -->
                <div class="hidden">
                  @for (store of stores; track store.id) {
                    <div>
                      <div #marker>
                        <button type="button" [class]="markerClass(store.id)" (click)="selectStore(store)">
                          {{ store.city }}
                        </button>
                      </div>
                    </div>
                  }
                </div>
              </div>
            </div>
          </div>
    
          <div ui-card class="border-border flex min-h-0 flex-col overflow-hidden shadow-xs lg:col-span-4">
            <div ui-card-header class="border-border border-b p-4">
              <h3 ui-card-title class="text-sm font-semibold">Locations</h3>
            </div>
            <div ui-card-content class="divide-border min-h-0 flex-1 divide-y overflow-auto p-0">
              @for (store of stores; track store.id) {
                <button type="button" [class]="rowClass(store.id)" (click)="selectStore(store)">
                  <div class="min-w-0">
                    <p class="text-foreground truncate text-sm font-medium">{{ store.name }}</p>
                    <p class="text-muted-foreground truncate text-xs">{{ store.city }}, {{ store.country }}</p>
                  </div>
                  <span ui-badge [variant]="store.isOpen ? 'info' : 'secondary'" class="shrink-0 text-xs">
                    {{ store.isOpen ? 'Open' : 'Closed' }}
                  </span>
                </button>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiStoreLocatorMapComponent implements OnInit, AfterViewInit, OnDestroy {
      @Input() stores: RetailStore[] = DEFAULT_STORES
      @Input() accessToken?: string
      @Input('class') className?: string
    
      @ViewChildren('marker') private markerEls?: QueryList<ElementRef<HTMLDivElement>>
    
      readonly selectedId = signal('')
    
      private map: mapboxgl.Map | null = null
      private markers: mapboxgl.Marker[] = []
      private destroyed = false
    
      get hostClass(): string {
        return cn('flex h-full min-h-0 flex-col gap-4', this.className)
      }
    
      get openCount(): number {
        return this.stores.filter((s) => s.isOpen).length
      }
    
      ngOnInit(): void {
        this.selectedId.set(this.stores[0]?.id ?? '')
      }
    
      ngAfterViewInit(): void {
        // A new stores list re-renders the marker elements; put the new set on the map.
        this.markerEls?.changes.subscribe(() => void this.syncMarkers())
      }
    
      ngOnDestroy(): void {
        this.destroyed = true
        this.clearMarkers()
        this.map = null
      }
    
      markerClass(id: string): string {
        return this.selectedId() === id
          ? 'border-primary bg-primary text-primary-foreground rounded-full border px-2 py-0.5 text-xs font-semibold shadow-xs'
          : 'border-border bg-card text-foreground rounded-full border px-2 py-0.5 text-xs font-semibold shadow-xs'
      }
    
      rowClass(id: string): string {
        return cn(
          'hover:bg-accent/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors',
          this.selectedId() === id && 'bg-accent/60',
        )
      }
    
      onMapCreated(map: mapboxgl.Map): void {
        this.map = map
        void this.syncMarkers()
      }
    
      selectStore(store: RetailStore): void {
        this.selectedId.set(store.id)
        this.map?.flyTo?.({
          center: [store.lng, store.lat],
          zoom: 12.5,
          essential: true,
          duration: 900,
        })
      }
    
      /** One mapbox-gl Marker per store, anchored at its centre (the React MapMarker, anchor="center"). */
      private async syncMarkers(): Promise<void> {
        const map = this.map
        const els = this.markerEls?.toArray() ?? []
        if (!map) return
        const mod = await import('mapbox-gl')
        const mapbox = mod.default ?? mod
        if (this.destroyed || map !== this.map) return
        this.clearMarkers()
        this.markers = els.flatMap((el, i) => {
          const store = this.stores[i]
          if (!store) return []
          return [
            new mapbox.Marker({ element: el.nativeElement, anchor: 'center' }).setLngLat([store.lng, store.lat]).addTo(map),
          ]
        })
      }
    
      private clearMarkers(): void {
        for (const m of this.markers) m.remove()
        this.markers = []
      }
    }
    
  • src/app/components/blocks/store-locator-map/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/store-locator-map.json