From 9ed897a60e5a45f3be3ba8cf07e862e0424bd447 Mon Sep 17 00:00:00 2001 From: Kevin Sivic Date: Mon, 1 Dec 2025 00:11:36 -0500 Subject: [PATCH] Add LiveView pages for business directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- assets/js/app.js | 101 +++++- lib/localspot_web/live/business_live/index.ex | 242 ++++++++++++++ lib/localspot_web/live/business_live/map.ex | 118 +++++++ lib/localspot_web/live/business_live/new.ex | 306 ++++++++++++++++++ lib/localspot_web/live/business_live/show.ex | 167 ++++++++++ lib/localspot_web/live/category_live/index.ex | 71 ++++ lib/localspot_web/live/category_live/show.ex | 102 ++++++ lib/localspot_web/router.ex | 10 + priv/repo/seeds.exs | 191 +++++++++++ 9 files changed, 1307 insertions(+), 1 deletion(-) create mode 100644 lib/localspot_web/live/business_live/index.ex create mode 100644 lib/localspot_web/live/business_live/map.ex create mode 100644 lib/localspot_web/live/business_live/new.ex create mode 100644 lib/localspot_web/live/business_live/show.ex create mode 100644 lib/localspot_web/live/category_live/index.ex create mode 100644 lib/localspot_web/live/category_live/show.ex diff --git a/assets/js/app.js b/assets/js/app.js index aa6f931..05f2812 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,11 +25,110 @@ import {LiveSocket} from "phoenix_live_view" import {hooks as colocatedHooks} from "phoenix-colocated/localspot" 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: '© OpenStreetMap 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(` +
+ ${business.name} + ${business.locally_owned ? '✓ Local' : ''} +
+ ${business.category} +
+ `) + + 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 liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, - hooks: {...colocatedHooks}, + hooks: {...colocatedHooks, Geolocation, LeafletMap}, }) // Show progress bar on live navigation and form submits diff --git a/lib/localspot_web/live/business_live/index.ex b/lib/localspot_web/live/business_live/index.ex new file mode 100644 index 0000000..9a29fd0 --- /dev/null +++ b/lib/localspot_web/live/business_live/index.ex @@ -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""" + +
+ <.header> + Local Business Directory + <:subtitle>Discover locally owned businesses in your area + + + <.link navigate={~p"/businesses/map"} class="btn btn-ghost btn-sm"> + <.icon name="hero-map" class="w-4 h-4" /> Map View + +
+ +
+ <%!-- Search and filters form --%> + <.form + for={%{}} + as={:search} + id="search-form" + phx-submit="search" + class="flex flex-col md:flex-row gap-4" + > +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + + <%!-- Location status --%> +
+ <.icon name="hero-map-pin" class="w-5 h-5" /> + Enable location to find businesses near you + +
+ +
+ <.icon name="hero-map-pin" class="w-5 h-5" /> + Showing businesses within {@filters["radius"] || "10"} miles of your location +
+ + <%!-- Locally owned filter --%> + + + <%!-- Business listing --%> +
+ + + <.business_card + :for={{dom_id, business} <- @streams.businesses} + id={dom_id} + business={business} + /> +
+
+ +
+ <.link navigate={~p"/businesses/new"} class="btn btn-secondary"> + <.icon name="hero-plus" class="w-5 h-5" /> Add Your Business + +
+
+ """ + end + + defp business_card(assigns) do + ~H""" +
+
+ {primary_photo(@business.photos).alt_text +
+ +
+
+

+ <.link navigate={~p"/businesses/#{@business.slug}"} class="hover:underline"> + {@business.name} + +

+ + Local + +
+ +

+ {@business.description} +

+ +
+ <.icon name="hero-map-pin" class="w-4 h-4" /> + {@business.city}, {@business.state} +
+ +
+ <.icon name="hero-arrow-right" class="w-4 h-4 inline" /> + {@business.distance_miles} miles away +
+ +
+ {if @business.category, do: @business.category.name, else: "Uncategorized"} +
+ +
+ <.link navigate={~p"/businesses/#{@business.slug}"} class="btn btn-sm btn-primary"> + View Details + +
+
+
+ """ + end + + defp primary_photo([]), do: nil + defp primary_photo(photos), do: Enum.find(photos, & &1.primary) || List.first(photos) +end diff --git a/lib/localspot_web/live/business_live/map.ex b/lib/localspot_web/live/business_live/map.ex new file mode 100644 index 0000000..c90285a --- /dev/null +++ b/lib/localspot_web/live/business_live/map.ex @@ -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""" + +
+ <.header> + Business Map + <:subtitle>View all businesses on an interactive map + + + <.link navigate={~p"/businesses"} class="btn btn-ghost btn-sm"> + <.icon name="hero-list-bullet" class="w-4 h-4" /> List View + +
+ +
+
+ +
+
+ +
+
+
+
+
+

+ {@selected_business.name} + + Local + +

+

{@selected_business.category.name}

+
+ +
+ +

+ {@selected_business.description} +

+ +
+ <.icon name="hero-map-pin" class="w-4 h-4" /> + + {@selected_business.street_address}, {@selected_business.city}, {@selected_business.state} + +
+ +
+ <.link + navigate={~p"/businesses/#{@selected_business.slug}"} + class="btn btn-primary btn-sm" + > + View Details + +
+
+
+
+ +
+

Showing {length(@businesses)} businesses on the map.

+
+
+ """ + 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 diff --git a/lib/localspot_web/live/business_live/new.ex b/lib/localspot_web/live/business_live/new.ex new file mode 100644 index 0000000..0a448e7 --- /dev/null +++ b/lib/localspot_web/live/business_live/new.ex @@ -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""" + + <.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 + + + <.header> + Add Your Business + <:subtitle>List your locally owned business in our directory + + +
+
+ <.icon name="hero-information-circle" class="w-5 h-5" /> + + This directory is exclusively for locally owned businesses - no chains or franchises. + +
+ + <.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6"> +
+
+

Basic Information

+ + <.input field={@form[:name]} label="Business Name" required /> + <.input field={@form[:description]} type="textarea" label="Description" rows="3" /> + +
+ + +
+
+
+ +
+
+

Location

+ + <.input field={@form[:street_address]} label="Street Address" required /> + +
+
+ <.input field={@form[:city]} label="City" required /> +
+ <.input field={@form[:state]} label="State" maxlength="2" placeholder="OH" required /> + <.input field={@form[:zip_code]} label="ZIP Code" maxlength="10" required /> +
+ +
+ <.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" + /> +
+

+ Coordinates help customers find you on the map. You can find yours on Google Maps. +

+
+
+ +
+
+

Contact Information

+ + <.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" + /> +
+
+ +
+
+

Business Hours

+ +
+ <.hours_row :for={day <- 0..6} day={day} hours={@hours[day]} /> +
+
+
+ +
+
+

Photo

+ +
+ + +
+

+ Enter a URL to an image of your business. Direct image links work best. +

+
+
+ +
+
+

Local Ownership Attestation

+ + +
+
+ +
+ <.link navigate={~p"/businesses"} class="btn btn-ghost">Cancel + +
+ +
+
+ """ + end + + defp hours_row(assigns) do + ~H""" +
+ {Businesses.day_name(@day)} + + + +
+ + to + +
+
+ """ + end +end diff --git a/lib/localspot_web/live/business_live/show.ex b/lib/localspot_web/live/business_live/show.ex new file mode 100644 index 0000000..5a42efd --- /dev/null +++ b/lib/localspot_web/live/business_live/show.ex @@ -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""" + +
+ <%!-- 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 + + + <%!-- Photo gallery --%> + + + <%!-- Business header --%> +
+
+

+ {@business.name} + + Locally Owned + +

+ <.link + navigate={~p"/categories/#{@business.category.slug}"} + class="text-base-content/60 hover:underline" + > + {@business.category.name} + +
+ +
+ {if @is_open, do: "Open Now", else: "Closed"} +
+
+ + <%!-- Description --%> +

{@business.description}

+ + <%!-- Local ownership info --%> +
+ <.icon name="hero-check-badge" class="w-6 h-6" /> +
+

Locally Owned Business

+

+ This business has confirmed they are locally owned and operated, not a chain or franchise. +

+
+
+ +
+ <%!-- Contact info --%> +
+
+

Contact Information

+ +
+
+ <.icon name="hero-map-pin" class="w-5 h-5 mt-0.5 text-primary" /> +
+

{@business.street_address}

+

{@business.city}, {@business.state} {@business.zip_code}

+
+
+ +
+ <.icon name="hero-phone" class="w-5 h-5 text-primary" /> + + {Businesses.format_phone(@business.phone)} + +
+ +
+ <.icon name="hero-envelope" class="w-5 h-5 text-primary" /> + + {@business.email} + +
+ +
+ <.icon name="hero-globe-alt" class="w-5 h-5 text-primary" /> + + Visit Website + <.icon name="hero-arrow-top-right-on-square" class="w-4 h-4 inline" /> + +
+
+
+
+ + <%!-- Hours --%> +
+
+

Business Hours

+ + <%= if @business.hours == [] do %> +

Hours not available

+ <% else %> +
+
+ {Businesses.day_name(hour.day_of_week)} + Closed + + {format_time(hour.opens_at)} - {format_time(hour.closes_at)} + +
+
+ <% end %> +
+
+
+
+
+ """ + end + + defp format_time(time) do + Calendar.strftime(time, "%I:%M %p") + end +end diff --git a/lib/localspot_web/live/category_live/index.ex b/lib/localspot_web/live/category_live/index.ex new file mode 100644 index 0000000..9945dc9 --- /dev/null +++ b/lib/localspot_web/live/category_live/index.ex @@ -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""" + + <.header> + Browse by Category + <:subtitle>Find local businesses by category + + +
+ <%= if @categories == [] do %> +

+ No categories yet. Check back soon! +

+ <% else %> +
+ <.link + :for={category <- @categories} + navigate={~p"/categories/#{category.slug}"} + class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer" + > +
+
+
+ <.icon name={category.icon} class="w-6 h-6 text-primary" /> +
+
+

{category.name}

+

+ {Map.get(@category_counts, category.id, 0)} businesses +

+
+
+

+ {category.description} +

+
+ +
+ <% end %> +
+ +
+ <.link navigate={~p"/businesses"} class="btn btn-primary"> + Browse All Businesses + +
+
+ """ + end +end diff --git a/lib/localspot_web/live/category_live/show.ex b/lib/localspot_web/live/category_live/show.ex new file mode 100644 index 0000000..14f596b --- /dev/null +++ b/lib/localspot_web/live/category_live/show.ex @@ -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""" + + <.link navigate={~p"/categories"} class="btn btn-ghost btn-sm mb-4"> + <.icon name="hero-arrow-left" class="w-4 h-4" /> All Categories + + + <.header> + {@category.name} + <:subtitle :if={@category.description}>{@category.description} + + +
+ + + <.business_card + :for={{dom_id, business} <- @streams.businesses} + id={dom_id} + business={business} + /> +
+
+ """ + end + + defp business_card(assigns) do + ~H""" +
+
+ {primary_photo(@business.photos).alt_text +
+ +
+
+

+ <.link navigate={~p"/businesses/#{@business.slug}"} class="hover:underline"> + {@business.name} + +

+ + Local + +
+ +

+ {@business.description} +

+ +
+ <.icon name="hero-map-pin" class="w-4 h-4" /> + {@business.city}, {@business.state} +
+ +
+ <.link navigate={~p"/businesses/#{@business.slug}"} class="btn btn-sm btn-primary"> + View Details + +
+
+
+ """ + end + + defp primary_photo([]), do: nil + defp primary_photo(photos), do: Enum.find(photos, & &1.primary) || List.first(photos) +end diff --git a/lib/localspot_web/router.ex b/lib/localspot_web/router.ex index ae1369a..214bd87 100644 --- a/lib/localspot_web/router.ex +++ b/lib/localspot_web/router.ex @@ -18,6 +18,16 @@ defmodule LocalspotWeb.Router do pipe_through :browser 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 # Other scopes may use custom stacks. diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 8fa1cf4..eca707a 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,3 +9,194 @@ # # We recommend using the bang functions (`insert!`, `update!` # 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")