Menu

Change language

GitHub repo

Leaflet Store Locator Map

blockdashboard

Store locator on free OpenStreetMap tiles — no API key. Pins with bound popups, distance/hours/amenities list, fly-to on select.

Installation

$npx uipkge-ng@latest add leaflet-store-locator-map
Manifest:https://uipkge.dev/r/angular/leaflet-store-locator-map.jsonInstalls to:src/app/components/blocks/leaflet-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
  address: string
  neighborhood: string
  lat: number
  lng: number
  distanceMiles: number
  hours: string
  isOpen: boolean
  amenities: string[]
}

Files installed (2)

  • src/app/components/blocks/leaflet-store-locator-map/leaflet-store-locator-map.component.ts7.1 kB
    import { Component, Input, OnInit, ViewChild, signal, ChangeDetectionStrategy } from '@angular/core'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import {
      UiLeafletMapComponent,
      UiLeafletMarkerComponent,
      UiLeafletPopupComponent,
    } from '@/ui/leaflet-map/leaflet-map.component'
    
    export interface RetailStore {
      id: string
      name: string
      address: string
      neighborhood: string
      lat: number
      lng: number
      distanceMiles: number
      hours: string
      isOpen: boolean
      amenities: string[]
    }
    
    export const DEFAULT_STORES: RetailStore[] = [
      {
        id: 'nyc-herald',
        name: 'Herald Square',
        address: '151 W 34th St',
        neighborhood: 'Midtown',
        lat: 40.7484,
        lng: -73.9879,
        distanceMiles: 0.3,
        hours: 'Open until 10 PM',
        isOpen: true,
        amenities: ['Pickup', 'Returns', 'Cafe'],
      },
      {
        id: 'nyc-chelsea',
        name: 'Chelsea Market',
        address: '75 9th Ave',
        neighborhood: 'Chelsea',
        lat: 40.7424,
        lng: -74.0061,
        distanceMiles: 0.9,
        hours: 'Open until 9 PM',
        isOpen: true,
        amenities: ['Parking', 'Cafe'],
      },
      {
        id: 'nyc-soho',
        name: 'SoHo Flagship',
        address: '112 Greene St',
        neighborhood: 'SoHo',
        lat: 40.7248,
        lng: -73.9984,
        distanceMiles: 1.6,
        hours: 'Open until 9 PM',
        isOpen: true,
        amenities: ['Parking', 'Pickup', 'Returns'],
      },
      {
        id: 'nyc-ues',
        name: 'Lexington & 78th',
        address: '1120 Lexington Ave',
        neighborhood: 'Upper East Side',
        lat: 40.7736,
        lng: -73.9614,
        distanceMiles: 1.8,
        hours: 'Open until 8 PM',
        isOpen: true,
        amenities: ['Pickup', 'Returns'],
      },
      {
        id: 'nyc-tribeca',
        name: 'Tribeca',
        address: '60 Hudson St',
        neighborhood: 'Tribeca',
        lat: 40.7175,
        lng: -74.0089,
        distanceMiles: 2.1,
        hours: 'Closed · Opens 10 AM',
        isOpen: false,
        amenities: ['Parking', 'Returns'],
      },
    ]
    
    // Midtown Manhattan as [lng, lat] — a stable reference so the map's center input never re-fires.
    const MIDTOWN_CENTER: [number, number] = [-73.985, 40.748]
    const POPUP_OFFSET: [number, number] = [0, -18]
    
    const PIN_SELECTED =
      'border-primary bg-primary text-primary-foreground rounded-full border px-2 py-0.5 text-xs font-semibold shadow-xs'
    const PIN_IDLE = 'border-border bg-card text-foreground rounded-full border px-2 py-0.5 text-xs font-semibold shadow-xs'
    
    /**
     * Store locator on free OpenStreetMap tiles — no API key. Pins with bound
     * popups, distance/hours/amenities list, fly-to on select (map pin or row).
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-leaflet-store-locator-map, [ui-leaflet-store-locator-map]',
      standalone: true,
      imports: [
        UiBadgeComponent,
        UiCardComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiCardContentComponent,
        UiLeafletMapComponent,
        UiLeafletMarkerComponent,
        UiLeafletPopupComponent,
      ],
      host: {
        'data-slot': 'leaflet-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">
          <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">
              <ui-leaflet-map variant="streets" [center]="midtownCenter" [zoom]="13" class="absolute inset-0 size-full">
                @for (store of stores; track store.id) {
                  <ui-leaflet-marker [lngLat]="[store.lng, store.lat]" anchor="center">
                    <button type="button" [class]="pinClass(store)" (click)="selectStore(store)">{{ store.name }}</button>
                    <ui-leaflet-popup [offset]="popupOffset" className="space-y-1 text-xs">
                      <p class="text-foreground font-semibold">{{ store.name }}</p>
                      <p class="text-muted-foreground">{{ store.address }}, {{ store.neighborhood }}</p>
                      <p class="text-muted-foreground">{{ store.hours }} · {{ store.distanceMiles }} mi away</p>
                    </ui-leaflet-popup>
                  </ui-leaflet-marker>
                }
              </ui-leaflet-map>
            </div>
          </ui-card>
    
          <ui-card class="border-border flex min-h-0 flex-col overflow-hidden shadow-xs lg:col-span-4">
            <ui-card-header class="border-border border-b p-4">
              <ui-card-title class="text-sm font-semibold">Locations</ui-card-title>
            </ui-card-header>
            <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)" (click)="selectStore(store)">
                  <div class="min-w-0 space-y-1">
                    <p class="text-foreground truncate text-sm font-medium">{{ store.name }}</p>
                    <p class="text-muted-foreground truncate text-xs">
                      {{ store.address }}, {{ store.neighborhood }} · {{ store.hours }}
                    </p>
                    <div class="flex flex-wrap gap-1">
                      @for (amenity of store.amenities; track amenity) {
                        <ui-badge variant="secondary" class="text-xs">{{ amenity }}</ui-badge>
                      }
                    </div>
                  </div>
                  <div class="flex shrink-0 flex-col items-end gap-1">
                    <span class="text-muted-foreground font-mono text-xs tabular-nums">{{ store.distanceMiles }} mi</span>
                    <ui-badge [variant]="store.isOpen ? 'info' : 'secondary'" class="text-xs">
                      {{ store.isOpen ? 'Open' : 'Closed' }}
                    </ui-badge>
                  </div>
                </button>
              }
            </ui-card-content>
          </ui-card>
        </div>
      `,
    })
    export class UiLeafletStoreLocatorMapComponent implements OnInit {
      @Input() stores: RetailStore[] = DEFAULT_STORES
      @Input('class') className?: string
    
      @ViewChild(UiLeafletMapComponent) map?: UiLeafletMapComponent
    
      protected readonly midtownCenter = MIDTOWN_CENTER
      protected readonly popupOffset = POPUP_OFFSET
      readonly selectedId = signal('')
    
      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 ?? '')
      }
    
      selectStore(store: RetailStore): void {
        this.selectedId.set(store.id)
        this.map?.flyTo({
          center: [store.lng, store.lat],
          zoom: 14,
          duration: 900,
        })
      }
    
      pinClass(store: RetailStore): string {
        return this.selectedId() === store.id ? PIN_SELECTED : PIN_IDLE
      }
    
      rowClass(store: RetailStore): 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() === store.id && 'bg-accent/60',
        )
      }
    }
    
  • src/app/components/blocks/leaflet-store-locator-map/index.ts0.1 kB

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