- 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>
306 lines
9.8 KiB
Elixir
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
|