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>
This commit is contained in:
Kevin Sivic 2025-12-01 00:11:36 -05:00
parent e8fd635ddb
commit 9ed897a60e
9 changed files with 1307 additions and 1 deletions

View file

@ -25,11 +25,110 @@ import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/localspot" import {hooks as colocatedHooks} from "phoenix-colocated/localspot"
import topbar from "../vendor/topbar" import topbar from "../vendor/topbar"
// Custom hooks
const Geolocation = {
mounted() {
this.el.addEventListener("click", () => {
if ("geolocation" in navigator) {
this.el.disabled = true
this.el.textContent = "Getting location..."
navigator.geolocation.getCurrentPosition(
(position) => {
this.pushEvent("set_location", {
latitude: position.coords.latitude.toString(),
longitude: position.coords.longitude.toString()
})
},
(error) => {
console.error("Geolocation error:", error)
this.el.disabled = false
this.el.textContent = "Use My Location"
alert("Could not get your location. Please check your browser permissions.")
}
)
} else {
alert("Geolocation is not supported by your browser.")
}
})
}
}
const LeafletMap = {
mounted() {
// Load Leaflet CSS dynamically
if (!document.querySelector('link[href*="leaflet"]')) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
document.head.appendChild(link)
}
// Load Leaflet JS dynamically
if (!window.L) {
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
script.onload = () => this.initMap()
document.head.appendChild(script)
} else {
this.initMap()
}
},
initMap() {
const businesses = JSON.parse(this.el.dataset.businesses)
const center = JSON.parse(this.el.dataset.center)
const zoom = parseInt(this.el.dataset.zoom)
// Clear the loading indicator
this.el.innerHTML = ''
// Initialize map
this.map = L.map(this.el).setView([center.lat, center.lng], zoom)
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.map)
// Add markers for each business
this.markers = businesses.map(business => {
const marker = L.marker([business.lat, business.lng])
.addTo(this.map)
.bindPopup(`
<div class="text-sm">
<strong>${business.name}</strong>
${business.locally_owned ? '<span style="color: green; margin-left: 4px;">&#10003; Local</span>' : ''}
<br>
<span style="color: #666;">${business.category}</span>
</div>
`)
marker.on('click', () => {
this.pushEvent('select_business', { slug: business.slug })
})
return marker
})
// Fit bounds to show all markers if there are any
if (this.markers.length > 0) {
const group = L.featureGroup(this.markers)
this.map.fitBounds(group.getBounds().pad(0.1))
}
},
destroyed() {
if (this.map) {
this.map.remove()
}
}
}
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}, params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks}, hooks: {...colocatedHooks, Geolocation, LeafletMap},
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View file

@ -0,0 +1,242 @@
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

View file

@ -0,0 +1,118 @@
defmodule LocalspotWeb.BusinessLive.Map do
use LocalspotWeb, :live_view
alias Localspot.Businesses
@impl true
def mount(_params, _session, socket) do
businesses = Businesses.list_businesses()
{:ok,
socket
|> assign(:page_title, "Business Map")
|> assign(:businesses, businesses)
|> assign(:selected_business, nil)}
end
@impl true
def handle_event("select_business", %{"slug" => slug}, socket) do
business = Enum.find(socket.assigns.businesses, &(&1.slug == slug))
{:noreply, assign(socket, :selected_business, business)}
end
@impl true
def handle_event("close_popup", _params, socket) do
{:noreply, assign(socket, :selected_business, nil)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="flex items-center justify-between mb-4">
<.header>
Business Map
<:subtitle>View all businesses on an interactive map</:subtitle>
</.header>
<.link navigate={~p"/businesses"} class="btn btn-ghost btn-sm">
<.icon name="hero-list-bullet" class="w-4 h-4" /> List View
</.link>
</div>
<div
id="map-container"
class="w-full h-[600px] rounded-lg overflow-hidden relative"
phx-hook="LeafletMap"
data-businesses={Jason.encode!(map_businesses(@businesses))}
data-center={Jason.encode!(%{lat: 39.9612, lng: -82.9988})}
data-zoom="12"
>
<div class="absolute inset-0 bg-base-300 flex items-center justify-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
<div :if={@selected_business} class="mt-4">
<div class="card bg-base-200">
<div class="card-body">
<div class="flex items-start justify-between">
<div>
<h2 class="card-title">
{@selected_business.name}
<span :if={@selected_business.locally_owned} class="badge badge-success badge-sm">
Local
</span>
</h2>
<p class="text-base-content/60">{@selected_business.category.name}</p>
</div>
<button class="btn btn-ghost btn-sm" phx-click="close_popup">
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</div>
<p :if={@selected_business.description} class="mt-2">
{@selected_business.description}
</p>
<div class="flex items-center gap-2 text-sm text-base-content/60 mt-2">
<.icon name="hero-map-pin" class="w-4 h-4" />
<span>
{@selected_business.street_address}, {@selected_business.city}, {@selected_business.state}
</span>
</div>
<div class="card-actions justify-end mt-4">
<.link
navigate={~p"/businesses/#{@selected_business.slug}"}
class="btn btn-primary btn-sm"
>
View Details
</.link>
</div>
</div>
</div>
</div>
<div class="mt-4 text-sm text-base-content/60">
<p>Showing {length(@businesses)} businesses on the map.</p>
</div>
</Layouts.app>
"""
end
defp map_businesses(businesses) do
businesses
|> Enum.filter(&(&1.latitude && &1.longitude))
|> Enum.map(fn b ->
%{
slug: b.slug,
name: b.name,
lat: Decimal.to_float(b.latitude),
lng: Decimal.to_float(b.longitude),
category: b.category.name,
locally_owned: b.locally_owned
}
end)
end
end

View file

@ -0,0 +1,306 @@
defmodule LocalspotWeb.BusinessLive.New do
use LocalspotWeb, :live_view
alias Localspot.Businesses
alias Localspot.Businesses.Business
@impl true
def mount(_params, _session, socket) do
categories = Businesses.list_categories()
changeset = Businesses.change_business(%Business{})
{:ok,
socket
|> assign(:page_title, "Add Your Business")
|> assign(:categories, categories)
|> assign(:form, to_form(changeset))
|> assign(:hours, default_hours())
|> assign(:photo_url, "")}
end
@impl true
def handle_event("validate", %{"business" => business_params}, socket) do
changeset =
%Business{}
|> Businesses.change_business(business_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
@impl true
def handle_event("update_hours", %{"day" => day, "field" => field, "value" => value}, socket) do
day = String.to_integer(day)
hours = update_hours(socket.assigns.hours, day, field, value)
{:noreply, assign(socket, :hours, hours)}
end
@impl true
def handle_event("update_photo_url", %{"value" => value}, socket) do
{:noreply, assign(socket, :photo_url, value)}
end
@impl true
def handle_event("save", %{"business" => business_params}, socket) do
business_params =
business_params
|> Map.put("slug", Businesses.generate_slug(business_params["name"] || ""))
|> Map.put("locally_owned", true)
hours_attrs = build_hours_attrs(socket.assigns.hours)
photo_attrs = build_photo_attrs(socket.assigns.photo_url)
case Businesses.create_business_with_associations(business_params, hours_attrs, photo_attrs) do
{:ok, business} ->
{:noreply,
socket
|> put_flash(:info, "Business submitted successfully!")
|> push_navigate(to: ~p"/businesses/#{business.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp default_hours do
for day <- 0..6, into: %{} do
{day, %{closed: day == 0, opens_at: "09:00", closes_at: "17:00"}}
end
end
defp update_hours(hours, day, "closed", value) do
put_in(hours, [day, :closed], value == "true")
end
defp update_hours(hours, day, field, value) do
put_in(hours, [day, String.to_existing_atom(field)], value)
end
defp build_hours_attrs(hours) do
hours
|> Enum.map(fn {day, data} ->
if data.closed do
%{day_of_week: day, closed: true}
else
%{
day_of_week: day,
closed: false,
opens_at: parse_time(data.opens_at),
closes_at: parse_time(data.closes_at)
}
end
end)
end
defp parse_time(time_string) do
case Time.from_iso8601(time_string <> ":00") do
{:ok, time} -> time
_ -> nil
end
end
defp build_photo_attrs(""), do: []
defp build_photo_attrs(url), do: [%{url: url, primary: true}]
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.link navigate={~p"/businesses"} class="btn btn-ghost btn-sm mb-4">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Directory
</.link>
<.header>
Add Your Business
<:subtitle>List your locally owned business in our directory</:subtitle>
</.header>
<div class="mt-8 max-w-2xl">
<div class="alert alert-info mb-6">
<.icon name="hero-information-circle" class="w-5 h-5" />
<span>
This directory is exclusively for locally owned businesses - no chains or franchises.
</span>
</div>
<.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6">
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Basic Information</h2>
<.input field={@form[:name]} label="Business Name" required />
<.input field={@form[:description]} type="textarea" label="Description" rows="3" />
<div class="form-control">
<label class="label">
<span class="label-text">Category</span>
</label>
<select name="business[category_id]" class="select select-bordered w-full" required>
<option value="">Select a category</option>
<option
:for={cat <- @categories}
value={cat.id}
selected={to_string(cat.id) == to_string(@form[:category_id].value)}
>
{cat.name}
</option>
</select>
</div>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Location</h2>
<.input field={@form[:street_address]} label="Street Address" required />
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="col-span-2">
<.input field={@form[:city]} label="City" required />
</div>
<.input field={@form[:state]} label="State" maxlength="2" placeholder="OH" required />
<.input field={@form[:zip_code]} label="ZIP Code" maxlength="10" required />
</div>
<div class="grid grid-cols-2 gap-4">
<.input
field={@form[:latitude]}
type="number"
step="any"
label="Latitude"
placeholder="39.9612"
/>
<.input
field={@form[:longitude]}
type="number"
step="any"
label="Longitude"
placeholder="-82.9988"
/>
</div>
<p class="text-sm text-base-content/60">
Coordinates help customers find you on the map. You can find yours on Google Maps.
</p>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Contact Information</h2>
<.input field={@form[:phone]} label="Phone Number" placeholder="6145551234" />
<.input field={@form[:email]} type="email" label="Email Address" />
<.input
field={@form[:website]}
type="url"
label="Website"
placeholder="https://example.com"
/>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Business Hours</h2>
<div class="space-y-3">
<.hours_row :for={day <- 0..6} day={day} hours={@hours[day]} />
</div>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Photo</h2>
<div class="form-control">
<label class="label">
<span class="label-text">Photo URL</span>
</label>
<input
type="url"
name="photo_url"
value={@photo_url}
placeholder="https://example.com/photo.jpg"
class="input input-bordered w-full"
phx-blur="update_photo_url"
phx-value-value={@photo_url}
/>
</div>
<p class="text-sm text-base-content/60">
Enter a URL to an image of your business. Direct image links work best.
</p>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Local Ownership Attestation</h2>
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
name="attest_local"
class="checkbox checkbox-primary mt-1"
required
/>
<span class="text-sm">
I confirm that this business is locally owned and operated, not a chain, franchise, or corporate-owned location.
I understand that providing false information may result in removal from the directory.
</span>
</label>
</div>
</div>
<div class="flex gap-4 justify-end">
<.link navigate={~p"/businesses"} class="btn btn-ghost">Cancel</.link>
<button type="submit" class="btn btn-primary">Submit Business</button>
</div>
</.form>
</div>
</Layouts.app>
"""
end
defp hours_row(assigns) do
~H"""
<div class="flex items-center gap-4 flex-wrap">
<span class="w-24 font-medium">{Businesses.day_name(@day)}</span>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={@hours.closed}
phx-click="update_hours"
phx-value-day={@day}
phx-value-field="closed"
phx-value-value={!@hours.closed}
/>
<span class="text-sm">Closed</span>
</label>
<div :if={!@hours.closed} class="flex items-center gap-2">
<input
type="time"
value={@hours.opens_at}
class="input input-bordered input-sm w-32"
phx-blur="update_hours"
phx-value-day={@day}
phx-value-field="opens_at"
/>
<span>to</span>
<input
type="time"
value={@hours.closes_at}
class="input input-bordered input-sm w-32"
phx-blur="update_hours"
phx-value-day={@day}
phx-value-field="closes_at"
/>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,167 @@
defmodule LocalspotWeb.BusinessLive.Show do
use LocalspotWeb, :live_view
alias Localspot.Businesses
@impl true
def mount(%{"slug" => slug}, _session, socket) do
case Businesses.get_business_by_slug(slug) do
nil ->
{:ok,
socket
|> put_flash(:error, "Business not found")
|> push_navigate(to: ~p"/businesses")}
business ->
{:ok,
socket
|> assign(:page_title, business.name)
|> assign(:business, business)
|> assign(:is_open, Businesses.currently_open?(business.hours))}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="max-w-4xl mx-auto">
<%!-- Back link --%>
<.link navigate={~p"/businesses"} class="btn btn-ghost btn-sm mb-4">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Directory
</.link>
<%!-- Photo gallery --%>
<div
:if={@business.photos != []}
class="carousel w-full rounded-lg mb-6 h-64 md:h-96 bg-base-300"
>
<div
:for={{photo, idx} <- Enum.with_index(@business.photos)}
id={"photo-#{idx}"}
class="carousel-item w-full"
>
<img
src={photo.url}
alt={photo.alt_text || "#{@business.name} photo #{idx + 1}"}
class="w-full object-cover"
/>
</div>
</div>
<%!-- Business header --%>
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3 flex-wrap">
{@business.name}
<span
:if={@business.locally_owned}
class="badge badge-success"
title="Locally Owned"
>
Locally Owned
</span>
</h1>
<.link
navigate={~p"/categories/#{@business.category.slug}"}
class="text-base-content/60 hover:underline"
>
{@business.category.name}
</.link>
</div>
<div class={[
"badge badge-lg",
if(@is_open, do: "badge-success", else: "badge-error")
]}>
{if @is_open, do: "Open Now", else: "Closed"}
</div>
</div>
<%!-- Description --%>
<p :if={@business.description} class="text-lg mb-6">{@business.description}</p>
<%!-- Local ownership info --%>
<div :if={@business.locally_owned} class="alert alert-success mb-6">
<.icon name="hero-check-badge" class="w-6 h-6" />
<div>
<h3 class="font-bold">Locally Owned Business</h3>
<p class="text-sm">
This business has confirmed they are locally owned and operated, not a chain or franchise.
</p>
</div>
</div>
<div class="grid md:grid-cols-2 gap-6">
<%!-- Contact info --%>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Contact Information</h2>
<div class="space-y-3">
<div class="flex items-start gap-3">
<.icon name="hero-map-pin" class="w-5 h-5 mt-0.5 text-primary" />
<div>
<p>{@business.street_address}</p>
<p>{@business.city}, {@business.state} {@business.zip_code}</p>
</div>
</div>
<div :if={@business.phone} class="flex items-center gap-3">
<.icon name="hero-phone" class="w-5 h-5 text-primary" />
<a href={"tel:#{@business.phone}"} class="link link-hover">
{Businesses.format_phone(@business.phone)}
</a>
</div>
<div :if={@business.email} class="flex items-center gap-3">
<.icon name="hero-envelope" class="w-5 h-5 text-primary" />
<a href={"mailto:#{@business.email}"} class="link link-hover">
{@business.email}
</a>
</div>
<div :if={@business.website} class="flex items-center gap-3">
<.icon name="hero-globe-alt" class="w-5 h-5 text-primary" />
<a href={@business.website} target="_blank" rel="noopener" class="link link-hover">
Visit Website
<.icon name="hero-arrow-top-right-on-square" class="w-4 h-4 inline" />
</a>
</div>
</div>
</div>
</div>
<%!-- Hours --%>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Business Hours</h2>
<%= if @business.hours == [] do %>
<p class="text-base-content/60">Hours not available</p>
<% else %>
<div class="space-y-2">
<div
:for={hour <- Enum.sort_by(@business.hours, & &1.day_of_week)}
class="flex justify-between"
>
<span class="font-medium">{Businesses.day_name(hour.day_of_week)}</span>
<span :if={hour.closed} class="text-base-content/60">Closed</span>
<span :if={!hour.closed && hour.opens_at && hour.closes_at}>
{format_time(hour.opens_at)} - {format_time(hour.closes_at)}
</span>
</div>
</div>
<% end %>
</div>
</div>
</div>
</div>
</Layouts.app>
"""
end
defp format_time(time) do
Calendar.strftime(time, "%I:%M %p")
end
end

View file

@ -0,0 +1,71 @@
defmodule LocalspotWeb.CategoryLive.Index do
use LocalspotWeb, :live_view
alias Localspot.Businesses
@impl true
def mount(_params, _session, socket) do
categories = Businesses.list_categories()
category_counts = Businesses.count_businesses_by_category()
{:ok,
socket
|> assign(:page_title, "Browse Categories")
|> assign(:categories, categories)
|> assign(:category_counts, category_counts)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Browse by Category
<:subtitle>Find local businesses by category</:subtitle>
</.header>
<div class="mt-8">
<%= if @categories == [] do %>
<p class="text-center text-base-content/60 py-12">
No categories yet. Check back soon!
</p>
<% else %>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<.link
:for={category <- @categories}
navigate={~p"/categories/#{category.slug}"}
class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer"
>
<div class="card-body">
<div class="flex items-center gap-3">
<div
:if={category.icon}
class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center"
>
<.icon name={category.icon} class="w-6 h-6 text-primary" />
</div>
<div>
<h2 class="card-title text-lg">{category.name}</h2>
<p class="text-sm text-base-content/60">
{Map.get(@category_counts, category.id, 0)} businesses
</p>
</div>
</div>
<p :if={category.description} class="text-sm mt-2 text-base-content/70">
{category.description}
</p>
</div>
</.link>
</div>
<% end %>
</div>
<div class="mt-8 text-center">
<.link navigate={~p"/businesses"} class="btn btn-primary">
Browse All Businesses
</.link>
</div>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,102 @@
defmodule LocalspotWeb.CategoryLive.Show do
use LocalspotWeb, :live_view
alias Localspot.Businesses
@impl true
def mount(%{"slug" => slug}, _session, socket) do
case Businesses.get_category_by_slug(slug) do
nil ->
{:ok,
socket
|> put_flash(:error, "Category not found")
|> push_navigate(to: ~p"/categories")}
category ->
businesses = Businesses.list_businesses(%{"category_id" => to_string(category.id)})
{:ok,
socket
|> assign(:page_title, category.name)
|> assign(:category, category)
|> stream(:businesses, businesses)}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.link navigate={~p"/categories"} class="btn btn-ghost btn-sm mb-4">
<.icon name="hero-arrow-left" class="w-4 h-4" /> All Categories
</.link>
<.header>
{@category.name}
<:subtitle :if={@category.description}>{@category.description}</:subtitle>
</.header>
<div id="businesses" phx-update="stream" class="mt-8 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 in this category yet.
</div>
<.business_card
:for={{dom_id, business} <- @streams.businesses}
id={dom_id}
business={business}
/>
</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 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

View file

@ -18,6 +18,16 @@ defmodule LocalspotWeb.Router do
pipe_through :browser pipe_through :browser
get "/", PageController, :home get "/", PageController, :home
# Business directory
live "/businesses", BusinessLive.Index, :index
live "/businesses/new", BusinessLive.New, :new
live "/businesses/map", BusinessLive.Map, :map
live "/businesses/:slug", BusinessLive.Show, :show
# Categories
live "/categories", CategoryLive.Index, :index
live "/categories/:slug", CategoryLive.Show, :show
end end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.

View file

@ -9,3 +9,194 @@
# #
# We recommend using the bang functions (`insert!`, `update!` # We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong. # and so on) as they will fail if something goes wrong.
alias Localspot.Repo
alias Localspot.Businesses.{Category, Business, BusinessHour, BusinessPhoto}
# Clear existing data
Repo.delete_all(BusinessPhoto)
Repo.delete_all(BusinessHour)
Repo.delete_all(Business)
Repo.delete_all(Category)
# Create categories
categories =
[
%{
name: "Restaurants",
slug: "restaurants",
description: "Local dining establishments",
icon: "hero-cake"
},
%{
name: "Coffee Shops",
slug: "coffee-shops",
description: "Cafes and coffee houses",
icon: "hero-cup-soda"
},
%{name: "Retail", slug: "retail", description: "Shops and stores", icon: "hero-shopping-bag"},
%{
name: "Services",
slug: "services",
description: "Professional services",
icon: "hero-wrench-screwdriver"
},
%{
name: "Arts & Entertainment",
slug: "arts-entertainment",
description: "Galleries, theaters, and venues",
icon: "hero-paint-brush"
}
]
|> Enum.map(fn attrs ->
%Category{}
|> Category.changeset(attrs)
|> Repo.insert!()
end)
[restaurants, coffee, retail, services, arts] = categories
# Sample businesses - using Columbus, OH area coordinates
businesses_data = [
%{
name: "The Cozy Bean",
slug: "the-cozy-bean",
description:
"A family-owned coffee shop serving locally roasted beans and homemade pastries since 1998.",
street_address: "123 High Street",
city: "Columbus",
state: "OH",
zip_code: "43215",
latitude: Decimal.new("39.9612"),
longitude: Decimal.new("-82.9988"),
phone: "6145551234",
email: "hello@cozybean.example",
website: "https://cozybean.example",
locally_owned: true,
category_id: coffee.id
},
%{
name: "Mama Rosa's Kitchen",
slug: "mama-rosas-kitchen",
description:
"Authentic Italian cuisine made with recipes passed down through four generations.",
street_address: "456 Main Street",
city: "Columbus",
state: "OH",
zip_code: "43215",
latitude: Decimal.new("39.9650"),
longitude: Decimal.new("-83.0020"),
phone: "6145555678",
email: "reservations@mamarosas.example",
website: "https://mamarosas.example",
locally_owned: true,
category_id: restaurants.id
},
%{
name: "Buckeye Books",
slug: "buckeye-books",
description: "Independent bookstore specializing in local authors and rare finds.",
street_address: "789 Oak Avenue",
city: "Columbus",
state: "OH",
zip_code: "43215",
latitude: Decimal.new("39.9580"),
longitude: Decimal.new("-82.9950"),
phone: "6145559012",
locally_owned: true,
category_id: retail.id
},
%{
name: "Short North Gallery",
slug: "short-north-gallery",
description: "Contemporary art gallery featuring works by Ohio artists.",
street_address: "321 Short North Ave",
city: "Columbus",
state: "OH",
zip_code: "43201",
latitude: Decimal.new("39.9750"),
longitude: Decimal.new("-83.0030"),
locally_owned: true,
category_id: arts.id
},
%{
name: "Fix-It Fred's",
slug: "fix-it-freds",
description:
"Family-owned repair shop for electronics, appliances, and more. If it's broken, Fred can fix it!",
street_address: "555 Repair Lane",
city: "Columbus",
state: "OH",
zip_code: "43215",
latitude: Decimal.new("39.9520"),
longitude: Decimal.new("-83.0100"),
phone: "6145553456",
email: "fred@fixitfreds.example",
locally_owned: true,
category_id: services.id
},
%{
name: "German Village Bakery",
slug: "german-village-bakery",
description: "Traditional German pastries and breads baked fresh daily.",
street_address: "888 Schiller Park",
city: "Columbus",
state: "OH",
zip_code: "43206",
latitude: Decimal.new("39.9430"),
longitude: Decimal.new("-82.9920"),
phone: "6145557890",
locally_owned: true,
category_id: restaurants.id
}
]
businesses =
businesses_data
|> Enum.map(fn attrs ->
%Business{}
|> Business.changeset(attrs)
|> Repo.insert!()
end)
# Add hours for each business (most open 9-5 or similar)
for business <- businesses do
# Monday through Friday: 9 AM - 5 PM (or restaurant hours)
is_restaurant = business.category_id == restaurants.id
for day <- 1..5 do
%BusinessHour{}
|> BusinessHour.changeset(%{
business_id: business.id,
day_of_week: day,
opens_at: if(is_restaurant, do: ~T[11:00:00], else: ~T[09:00:00]),
closes_at: if(is_restaurant, do: ~T[21:00:00], else: ~T[17:00:00]),
closed: false
})
|> Repo.insert!()
end
# Saturday: shorter hours
%BusinessHour{}
|> BusinessHour.changeset(%{
business_id: business.id,
day_of_week: 6,
opens_at: ~T[10:00:00],
closes_at: ~T[15:00:00],
closed: false
})
|> Repo.insert!()
# Sunday: closed (except restaurants)
%BusinessHour{}
|> BusinessHour.changeset(%{
business_id: business.id,
day_of_week: 0,
opens_at: if(is_restaurant, do: ~T[12:00:00], else: nil),
closes_at: if(is_restaurant, do: ~T[20:00:00], else: nil),
closed: !is_restaurant
})
|> Repo.insert!()
end
IO.puts("Seeded #{length(categories)} categories and #{length(businesses)} businesses")