localspot/lib/localspot_web/live/business_live/index.ex
Kevin Sivic 9ed897a60e Add LiveView pages for business directory
- 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>
2025-12-01 00:11:36 -05:00

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