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:
parent
e8fd635ddb
commit
9ed897a60e
9 changed files with 1307 additions and 1 deletions
101
assets/js/app.js
101
assets/js/app.js
|
|
@ -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: '© <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;">✓ 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
|
||||||
|
|
|
||||||
242
lib/localspot_web/live/business_live/index.ex
Normal file
242
lib/localspot_web/live/business_live/index.ex
Normal 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
|
||||||
118
lib/localspot_web/live/business_live/map.ex
Normal file
118
lib/localspot_web/live/business_live/map.ex
Normal 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
|
||||||
306
lib/localspot_web/live/business_live/new.ex
Normal file
306
lib/localspot_web/live/business_live/new.ex
Normal 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
|
||||||
167
lib/localspot_web/live/business_live/show.ex
Normal file
167
lib/localspot_web/live/business_live/show.ex
Normal 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
|
||||||
71
lib/localspot_web/live/category_live/index.ex
Normal file
71
lib/localspot_web/live/category_live/index.ex
Normal 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
|
||||||
102
lib/localspot_web/live/category_live/show.ex
Normal file
102
lib/localspot_web/live/category_live/show.ex
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue