- CategoryLive.Index: browse categories with business counts - CategoryLive.Show: view businesses filtered by category - BusinessLive.Index: search/filter businesses with geolocation - BusinessLive.Show: detailed business profile with hours - BusinessLive.New: submission form for new businesses - BusinessLive.Map: interactive Leaflet.js map view - Seed data with sample Columbus, OH businesses 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
242 lines
7.7 KiB
Elixir
242 lines
7.7 KiB
Elixir
defmodule LocalspotWeb.BusinessLive.Index do
|
|
use LocalspotWeb, :live_view
|
|
|
|
alias Localspot.Businesses
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
categories = Businesses.list_categories()
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Local Businesses")
|
|
|> assign(:categories, categories)
|
|
|> assign(:filters, %{})
|
|
|> stream(:businesses, [])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _uri, socket) do
|
|
businesses = Businesses.list_businesses(params)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:filters, params)
|
|
|> stream(:businesses, businesses, reset: true)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search", %{"search" => search_params}, socket) do
|
|
params = build_url_params(socket.assigns.filters, search_params)
|
|
{:noreply, push_patch(socket, to: ~p"/businesses?#{params}")}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_filters", _params, socket) do
|
|
{:noreply, push_patch(socket, to: ~p"/businesses")}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("set_location", %{"latitude" => lat, "longitude" => lng}, socket) do
|
|
params =
|
|
socket.assigns.filters
|
|
|> Map.put("latitude", lat)
|
|
|> Map.put("longitude", lng)
|
|
|
|
{:noreply, push_patch(socket, to: ~p"/businesses?#{params}")}
|
|
end
|
|
|
|
defp build_url_params(existing, new) do
|
|
Map.merge(existing, new)
|
|
|> Enum.reject(fn {_k, v} -> v == "" or is_nil(v) end)
|
|
|> Map.new()
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash}>
|
|
<div class="flex items-center justify-between">
|
|
<.header>
|
|
Local Business Directory
|
|
<:subtitle>Discover locally owned businesses in your area</:subtitle>
|
|
</.header>
|
|
|
|
<.link navigate={~p"/businesses/map"} class="btn btn-ghost btn-sm">
|
|
<.icon name="hero-map" class="w-4 h-4" /> Map View
|
|
</.link>
|
|
</div>
|
|
|
|
<div id="business-directory" class="mt-6 space-y-6">
|
|
<%!-- Search and filters form --%>
|
|
<.form
|
|
for={%{}}
|
|
as={:search}
|
|
id="search-form"
|
|
phx-submit="search"
|
|
class="flex flex-col md:flex-row gap-4"
|
|
>
|
|
<div class="flex-1">
|
|
<input
|
|
type="search"
|
|
name="search[query]"
|
|
placeholder="Search businesses..."
|
|
value={@filters["query"]}
|
|
class="input input-bordered w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="w-full md:w-48">
|
|
<select name="search[category_id]" class="select select-bordered w-full">
|
|
<option value="">All Categories</option>
|
|
<option
|
|
:for={cat <- @categories}
|
|
value={cat.id}
|
|
selected={@filters["category_id"] == to_string(cat.id)}
|
|
>
|
|
{cat.name}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="w-full md:w-32">
|
|
<select name="search[radius]" class="select select-bordered w-full">
|
|
<option value="5" selected={@filters["radius"] == "5"}>5 miles</option>
|
|
<option
|
|
value="10"
|
|
selected={
|
|
@filters["radius"] != "5" and @filters["radius"] != "25" and
|
|
@filters["radius"] != "50"
|
|
}
|
|
>
|
|
10 miles
|
|
</option>
|
|
<option value="25" selected={@filters["radius"] == "25"}>25 miles</option>
|
|
<option value="50" selected={@filters["radius"] == "50"}>50 miles</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<button type="submit" class="btn btn-primary">Search</button>
|
|
<button type="button" phx-click="clear_filters" class="btn btn-ghost">Clear</button>
|
|
</div>
|
|
</.form>
|
|
|
|
<%!-- Location status --%>
|
|
<div
|
|
:if={is_nil(@filters["latitude"])}
|
|
class="alert alert-info"
|
|
>
|
|
<.icon name="hero-map-pin" class="w-5 h-5" />
|
|
<span>Enable location to find businesses near you</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm btn-primary"
|
|
id="get-location-btn"
|
|
phx-hook="Geolocation"
|
|
>
|
|
Use My Location
|
|
</button>
|
|
</div>
|
|
|
|
<div :if={@filters["latitude"]} class="alert alert-success">
|
|
<.icon name="hero-map-pin" class="w-5 h-5" />
|
|
<span>Showing businesses within {@filters["radius"] || "10"} miles of your location</span>
|
|
</div>
|
|
|
|
<%!-- Locally owned filter --%>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
name="locally_owned_filter"
|
|
class="checkbox checkbox-sm checkbox-primary"
|
|
checked={@filters["locally_owned"] == "true"}
|
|
phx-click="search"
|
|
phx-value-search[locally_owned]={
|
|
if @filters["locally_owned"] == "true", do: "", else: "true"
|
|
}
|
|
/>
|
|
<span>Show only locally owned businesses</span>
|
|
</label>
|
|
|
|
<%!-- Business listing --%>
|
|
<div id="businesses" phx-update="stream" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
<div class="hidden only:block col-span-full text-center py-12 text-base-content/60">
|
|
No businesses found matching your criteria.
|
|
</div>
|
|
|
|
<.business_card
|
|
:for={{dom_id, business} <- @streams.businesses}
|
|
id={dom_id}
|
|
business={business}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-8 text-center">
|
|
<.link navigate={~p"/businesses/new"} class="btn btn-secondary">
|
|
<.icon name="hero-plus" class="w-5 h-5" /> Add Your Business
|
|
</.link>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
defp business_card(assigns) do
|
|
~H"""
|
|
<div id={@id} class="card bg-base-200 shadow-md hover:shadow-lg transition-shadow">
|
|
<figure :if={primary_photo(@business.photos)} class="h-48">
|
|
<img
|
|
src={primary_photo(@business.photos).url}
|
|
alt={primary_photo(@business.photos).alt_text || @business.name}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
</figure>
|
|
|
|
<div class="card-body">
|
|
<div class="flex items-start justify-between">
|
|
<h2 class="card-title text-lg">
|
|
<.link navigate={~p"/businesses/#{@business.slug}"} class="hover:underline">
|
|
{@business.name}
|
|
</.link>
|
|
</h2>
|
|
<span
|
|
:if={@business.locally_owned}
|
|
class="badge badge-success badge-sm"
|
|
title="Locally Owned"
|
|
>
|
|
Local
|
|
</span>
|
|
</div>
|
|
|
|
<p :if={@business.description} class="text-sm text-base-content/70 line-clamp-2">
|
|
{@business.description}
|
|
</p>
|
|
|
|
<div class="flex items-center gap-2 text-sm text-base-content/60">
|
|
<.icon name="hero-map-pin" class="w-4 h-4" />
|
|
<span>{@business.city}, {@business.state}</span>
|
|
</div>
|
|
|
|
<div :if={@business.distance_miles} class="text-sm text-base-content/60">
|
|
<.icon name="hero-arrow-right" class="w-4 h-4 inline" />
|
|
{@business.distance_miles} miles away
|
|
</div>
|
|
|
|
<div class="text-xs text-base-content/50 mt-1">
|
|
{if @business.category, do: @business.category.name, else: "Uncategorized"}
|
|
</div>
|
|
|
|
<div class="card-actions justify-end mt-2">
|
|
<.link navigate={~p"/businesses/#{@business.slug}"} class="btn btn-sm btn-primary">
|
|
View Details
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp primary_photo([]), do: nil
|
|
defp primary_photo(photos), do: Enum.find(photos, & &1.primary) || List.first(photos)
|
|
end
|