localspot/lib/localspot_web/live/business_live/new.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

306 lines
9.8 KiB
Elixir

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