From 70bca86c234e779f44dd4d4a9c8fca3f92d7478c Mon Sep 17 00:00:00 2001 From: Kevin Sivic Date: Mon, 1 Dec 2025 00:57:46 -0500 Subject: [PATCH] Add business import feature with Mix task and admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Import module for parsing JSON and creating businesses with associations - Add `mix localspot.import` task for CLI-based imports - Add admin import page with drag-and-drop file upload at /admin/import - Include sample import JSON and Buffalo NY business data (40 businesses) - Support for importing hours and photos with each business 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 3 +- lib/localspot/businesses.ex | 27 + lib/localspot/businesses/import.ex | 194 ++++ lib/localspot/businesses/queries.ex | 47 + lib/localspot_web/live/admin_live/import.ex | 202 +++++ lib/localspot_web/router.ex | 4 + lib/mix/tasks/localspot.import.ex | 67 ++ priv/data/buffalo_ny_businesses.json | 930 ++++++++++++++++++++ priv/data/sample_import.json | 57 ++ test/localspot/businesses/import_test.exs | 192 ++++ 10 files changed, 1722 insertions(+), 1 deletion(-) create mode 100644 lib/localspot/businesses/import.ex create mode 100644 lib/localspot_web/live/admin_live/import.ex create mode 100644 lib/mix/tasks/localspot.import.ex create mode 100644 priv/data/buffalo_ny_businesses.json create mode 100644 priv/data/sample_import.json create mode 100644 test/localspot/businesses/import_test.exs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 99b7f6b..3b1233a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(mix compile:*)", "Bash(mix test:*)", - "Bash(mix format:*)" + "Bash(mix format:*)", + "WebSearch" ], "deny": [], "ask": [] diff --git a/lib/localspot/businesses.ex b/lib/localspot/businesses.ex index 36bd079..ba94e7f 100644 --- a/lib/localspot/businesses.ex +++ b/lib/localspot/businesses.ex @@ -61,6 +61,33 @@ defmodule Localspot.Businesses do """ defdelegate count_businesses_by_category, to: Queries + # --- Admin functions --- + + @doc """ + List all businesses for admin, including inactive ones. + """ + defdelegate list_all_businesses, to: Queries + + @doc """ + Get any business by ID, including inactive ones. + """ + defdelegate get_any_business(id), to: Queries + + @doc """ + Soft delete a business by setting active to false. + """ + defdelegate deactivate_business(business), to: Queries + + @doc """ + Reactivate a previously deactivated business. + """ + defdelegate activate_business(business), to: Queries + + @doc """ + Permanently delete a business and all associated records. + """ + defdelegate delete_business(business), to: Queries + # --- Business with associations --- @doc """ diff --git a/lib/localspot/businesses/import.ex b/lib/localspot/businesses/import.ex new file mode 100644 index 0000000..6415dd7 --- /dev/null +++ b/lib/localspot/businesses/import.ex @@ -0,0 +1,194 @@ +defmodule Localspot.Businesses.Import do + @moduledoc """ + Handles importing businesses from JSON files. + + ## JSON Format + + The import expects a JSON file with the following structure: + + { + "businesses": [ + { + "name": "Business Name", + "description": "Optional description", + "category": "restaurants", + "street_address": "123 Main St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215", + "latitude": 39.9612, + "longitude": -82.9988, + "phone": "6145551234", + "email": "contact@example.com", + "website": "https://example.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "09:00", "closes_at": "17:00"}, + ... + ], + "photos": [ + {"url": "https://example.com/photo.jpg", "alt_text": "Front of store", "primary": true} + ] + } + ] + } + + Days are: 0=Sunday, 1=Monday, ..., 6=Saturday + """ + + alias Localspot.Businesses + + require Logger + + @type import_result :: {:ok, %{imported: non_neg_integer(), errors: list()}} | {:error, term()} + + @doc """ + Imports businesses from a JSON file path. + """ + @spec from_file(String.t()) :: import_result() + def from_file(path) do + case File.read(path) do + {:ok, content} -> from_json(content) + {:error, reason} -> {:error, {:file_error, reason}} + end + end + + @doc """ + Imports businesses from a JSON string. + """ + @spec from_json(String.t()) :: import_result() + def from_json(json_string) do + case Jason.decode(json_string) do + {:ok, %{"businesses" => businesses}} when is_list(businesses) -> + import_businesses(businesses) + + {:ok, _} -> + {:error, {:invalid_format, "JSON must contain a 'businesses' array"}} + + {:error, reason} -> + {:error, {:json_error, reason}} + end + end + + defp import_businesses(businesses) do + categories = load_categories() + + results = + businesses + |> Enum.with_index(1) + |> Enum.map(fn {business_data, index} -> + import_single_business(business_data, categories, index) + end) + + imported = Enum.count(results, &match?({:ok, _}, &1)) + errors = Enum.filter(results, &match?({:error, _, _}, &1)) + + {:ok, %{imported: imported, errors: errors}} + end + + defp load_categories do + Businesses.list_categories() + |> Enum.reduce(%{}, fn cat, acc -> + acc + |> Map.put(cat.slug, cat.id) + |> Map.put(String.downcase(cat.name), cat.id) + end) + end + + defp import_single_business(data, categories, index) do + with {:ok, business_attrs} <- build_business_attrs(data, categories), + {:ok, hours_attrs} <- build_hours_attrs(data), + {:ok, photos_attrs} <- build_photos_attrs(data), + {:ok, business} <- + Businesses.create_business_with_associations(business_attrs, hours_attrs, photos_attrs) do + Logger.info("Imported business: #{business.name}") + {:ok, business} + else + {:error, changeset} when is_struct(changeset, Ecto.Changeset) -> + errors = format_changeset_errors(changeset) + Logger.warning("Failed to import business at index #{index}: #{inspect(errors)}") + {:error, index, errors} + + {:error, reason} -> + Logger.warning("Failed to import business at index #{index}: #{inspect(reason)}") + {:error, index, reason} + end + end + + defp build_business_attrs(data, categories) do + category_key = data["category"] || "" + category_id = categories[String.downcase(category_key)] + + if is_nil(category_id) and category_key != "" do + {:error, {:unknown_category, category_key}} + else + attrs = %{ + "name" => data["name"], + "slug" => Businesses.generate_slug(data["name"] || ""), + "description" => data["description"], + "category_id" => category_id, + "street_address" => data["street_address"], + "city" => data["city"], + "state" => data["state"], + "zip_code" => data["zip_code"], + "latitude" => data["latitude"], + "longitude" => data["longitude"], + "phone" => data["phone"], + "email" => data["email"], + "website" => data["website"], + "locally_owned" => data["locally_owned"] || true + } + + {:ok, attrs} + end + end + + defp build_hours_attrs(data) do + hours = data["hours"] || [] + + attrs = + Enum.map(hours, fn hour -> + %{ + day_of_week: hour["day"], + closed: hour["closed"] || false, + opens_at: parse_time(hour["opens_at"]), + closes_at: parse_time(hour["closes_at"]) + } + end) + + {:ok, attrs} + end + + defp parse_time(nil), do: nil + + defp parse_time(time_string) when is_binary(time_string) do + case Time.from_iso8601(time_string <> ":00") do + {:ok, time} -> time + _ -> nil + end + end + + defp build_photos_attrs(data) do + photos = data["photos"] || [] + + attrs = + Enum.map(photos, fn photo -> + %{ + url: photo["url"], + alt_text: photo["alt_text"], + primary: photo["primary"] || false + } + end) + + {:ok, attrs} + end + + defp format_changeset_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/lib/localspot/businesses/queries.ex b/lib/localspot/businesses/queries.ex index fa9414b..8e0412a 100644 --- a/lib/localspot/businesses/queries.ex +++ b/lib/localspot/businesses/queries.ex @@ -68,6 +68,53 @@ defmodule Localspot.Businesses.Queries do |> Map.new() end + # --- Admin functions --- + + @doc """ + List all businesses for admin, including inactive ones. + """ + def list_all_businesses do + Business + |> preload([:category, :photos]) + |> order_by(:name) + |> Repo.all() + end + + @doc """ + Get any business by ID, including inactive ones. + """ + def get_any_business(id) do + Business + |> where([b], b.id == ^id) + |> preload([:category, :hours, :photos]) + |> Repo.one() + end + + @doc """ + Soft delete a business by setting active to false. + """ + def deactivate_business(%Business{} = business) do + business + |> Business.changeset(%{active: false}) + |> Repo.update() + end + + @doc """ + Reactivate a previously deactivated business. + """ + def activate_business(%Business{} = business) do + business + |> Business.changeset(%{active: true}) + |> Repo.update() + end + + @doc """ + Permanently delete a business and all associated records. + """ + def delete_business(%Business{} = business) do + Repo.delete(business) + end + # --- Private helpers --- defp apply_filters(query, filters) do diff --git a/lib/localspot_web/live/admin_live/import.ex b/lib/localspot_web/live/admin_live/import.ex new file mode 100644 index 0000000..8a03e73 --- /dev/null +++ b/lib/localspot_web/live/admin_live/import.ex @@ -0,0 +1,202 @@ +defmodule LocalspotWeb.AdminLive.Import do + use LocalspotWeb, :live_view + + alias Localspot.Businesses.Import + + @json_example """ + { + "businesses": [ + { + "name": "Business Name", + "category": "category-slug", + "street_address": "123 Main St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215", + "hours": [{"day": 1, "opens_at": "09:00", "closes_at": "17:00"}], + "photos": [{"url": "https://...", "primary": true}] + } + ] + } + """ + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Import Businesses") + |> assign(:uploaded_files, []) + |> assign(:import_result, nil) + |> assign(:importing, false) + |> assign(:json_example, @json_example) + |> allow_upload(:import_file, + accept: ~w(.json), + max_entries: 1, + max_file_size: 10_000_000 + )} + end + + @impl true + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("import", _params, socket) do + socket = assign(socket, :importing, true) + + result = + consume_uploaded_entries(socket, :import_file, fn %{path: path}, _entry -> + {:ok, Import.from_file(path)} + end) + + import_result = + case result do + [{:ok, result}] -> result + [{:error, reason}] -> {:error, reason} + [] -> {:error, :no_file} + end + + {:noreply, + socket + |> assign(:import_result, import_result) + |> assign(:importing, false)} + end + + @impl true + def handle_event("clear_result", _params, socket) do + {:noreply, assign(socket, :import_result, nil)} + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + Import Businesses + <:subtitle>Upload a JSON file to bulk import businesses + + +
+
+ <.icon name="hero-information-circle" class="w-5 h-5" /> +
+

Upload a JSON file with the following structure:

+
{@json_example}
+
+
+ + <.form for={%{}} phx-change="validate" phx-submit="import" class="space-y-4"> +
+ <.live_file_input upload={@uploads.import_file} class="hidden" /> + +
+ <.icon name="hero-document-arrow-up" class="w-12 h-12 mx-auto text-base-content/40" /> +

+ Drag and drop a JSON file here, or + +

+

Max file size: 10MB

+
+ +
+
+ <.icon name="hero-document" class="w-6 h-6 text-primary" /> + {entry.client_name} + + ({format_bytes(entry.client_size)}) + +
+ +
+ +
+ {error_to_string(err)} +
+
+ + + + +
+
+
+ <.icon name="hero-check-circle" class="w-5 h-5" /> + Successfully imported {@import_result.imported} business(es) +
+ +
0} class="alert alert-warning"> + <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> +
+

{length(@import_result.errors)} error(s) occurred:

+
    +
  • + Row {index}: {inspect(reason)} +
  • +
+
+
+ +
+ <.link navigate={~p"/businesses"} class="btn btn-primary"> + View Businesses + + +
+
+ +
+ <.icon name="hero-x-circle" class="w-5 h-5" /> + Import failed: {format_error(@import_result)} +
+
+
+
+ """ + end + + defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B" + defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB" + defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB" + + defp error_to_string(:too_large), do: "File is too large (max 10MB)" + defp error_to_string(:not_accepted), do: "Only JSON files are accepted" + defp error_to_string(:too_many_files), do: "Only one file at a time" + defp error_to_string(err), do: "Error: #{inspect(err)}" + + defp format_error({:error, {:file_error, reason}}), + do: "Could not read file: #{inspect(reason)}" + + defp format_error({:error, {:json_error, _}}), do: "Invalid JSON format" + + defp format_error({:error, {:invalid_format, msg}}), do: msg + defp format_error({:error, :no_file}), do: "No file selected" + defp format_error({:error, reason}), do: inspect(reason) +end diff --git a/lib/localspot_web/router.ex b/lib/localspot_web/router.ex index 214bd87..f09eedb 100644 --- a/lib/localspot_web/router.ex +++ b/lib/localspot_web/router.ex @@ -28,6 +28,10 @@ defmodule LocalspotWeb.Router do # Categories live "/categories", CategoryLive.Index, :index live "/categories/:slug", CategoryLive.Show, :show + + # Admin + live "/admin/businesses", AdminLive.Businesses, :index + live "/admin/import", AdminLive.Import, :import end # Other scopes may use custom stacks. diff --git a/lib/mix/tasks/localspot.import.ex b/lib/mix/tasks/localspot.import.ex new file mode 100644 index 0000000..fb8cabf --- /dev/null +++ b/lib/mix/tasks/localspot.import.ex @@ -0,0 +1,67 @@ +defmodule Mix.Tasks.Localspot.Import do + @moduledoc """ + Imports businesses from a JSON file. + + ## Usage + + mix localspot.import path/to/businesses.json + + ## JSON Format + + See `Localspot.Businesses.Import` for the expected JSON format. + + ## Example + + mix localspot.import priv/data/businesses.json + + """ + use Mix.Task + + @shortdoc "Imports businesses from a JSON file" + + @impl Mix.Task + def run(args) do + case args do + [path] -> + Mix.Task.run("app.start") + import_file(path) + + _ -> + Mix.shell().error("Usage: mix localspot.import ") + exit({:shutdown, 1}) + end + end + + defp import_file(path) do + Mix.shell().info("Importing businesses from #{path}...") + + case Localspot.Businesses.Import.from_file(path) do + {:ok, %{imported: imported, errors: errors}} -> + Mix.shell().info("Successfully imported #{imported} business(es)") + + if length(errors) > 0 do + Mix.shell().info("#{length(errors)} error(s) occurred:") + + Enum.each(errors, fn {:error, index, reason} -> + Mix.shell().error(" - Row #{index}: #{inspect(reason)}") + end) + end + + {:error, {:file_error, reason}} -> + Mix.shell().error("Could not read file: #{inspect(reason)}") + exit({:shutdown, 1}) + + {:error, {:json_error, reason}} -> + Mix.shell().error("Invalid JSON: #{inspect(reason)}") + exit({:shutdown, 1}) + + {:error, {:invalid_format, message}} -> + Mix.shell().error("Invalid format: #{message}") + exit({:shutdown, 1}) + + {:error, reason} -> + Mix.shell().error("Import failed: #{inspect(reason)}") + exit({:shutdown, 1}) + end + end +end diff --git a/priv/data/buffalo_ny_businesses.json b/priv/data/buffalo_ny_businesses.json new file mode 100644 index 0000000..6c480b7 --- /dev/null +++ b/priv/data/buffalo_ny_businesses.json @@ -0,0 +1,930 @@ +{ + "businesses": [ + { + "name": "Gabriel's Gate", + "description": "Historic tavern in Buffalo's Allentown District serving exceptional Buffalo wings in an 1864 Tift Row Home. Known for superior cooking method that keeps wings crispy despite vinegary sauce. Celebrating 50 years of serving the community.", + "category": "restaurants", + "street_address": "145 Allen St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14201", + "latitude": 42.8996, + "longitude": -78.8754, + "phone": "7168860602", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "12:00", "closes_at": "22:00"}, + {"day": 1, "opens_at": "11:30", "closes_at": "22:00"}, + {"day": 2, "opens_at": "11:30", "closes_at": "22:00"}, + {"day": 3, "opens_at": "11:30", "closes_at": "22:00"}, + {"day": 4, "opens_at": "11:30", "closes_at": "23:00"}, + {"day": 5, "opens_at": "11:30", "closes_at": "23:00"}, + {"day": 6, "opens_at": "12:00", "closes_at": "23:00"} + ] + }, + { + "name": "Schwabl's Restaurant", + "description": "Home of Buffalo's Original Roast Beef on Kummelweck since 1837. Family-owned restaurant serving homemade German specialties including German Potato Salad, Hungarian Goulash, Liver Dumpling Soup, and locally made Birch Beer on tap.", + "category": "restaurants", + "street_address": "789 Center Rd", + "city": "West Seneca", + "state": "NY", + "zip_code": "14224", + "latitude": 42.8389, + "longitude": -78.7506, + "phone": "7166752333", + "website": "https://schwabls.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 3, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 4, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 5, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 6, "opens_at": "16:00", "closes_at": "21:00"} + ] + }, + { + "name": "Bacchus Wine Bar & Restaurant", + "description": "Wine Spectator award-winning wine bar in the historic Calumet building. Seasonal menu highlighting ingredients from local and regional farms. Ranked as one of Buffalo's best fine dining restaurants with complimentary valet service.", + "category": "restaurants", + "street_address": "56 W Chippewa St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14202", + "latitude": 42.8867, + "longitude": -78.8751, + "phone": "7168549463", + "email": "events@bacchuswine.bar", + "website": "https://bacchuswine.bar", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 3, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 4, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 5, "opens_at": "17:00", "closes_at": "23:00"}, + {"day": 6, "opens_at": "17:00", "closes_at": "23:00"} + ] + }, + { + "name": "Dina's Restaurant", + "description": "Culinary gem for 27 years in a restored 1840 building in the heart of Ellicottville. Innovative range of cuisines using best local and freshest ingredients. Contemporary western ambiance with 1970s après ski vibe. In-house pastry chef creates cakes, cookies, pies and desserts.", + "category": "restaurants", + "street_address": "15 Washington St", + "city": "Ellicottville", + "state": "NY", + "zip_code": "14731", + "latitude": 42.2753, + "longitude": -78.6718, + "phone": "7166995330", + "website": "https://dinas.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "closed": true}, + {"day": 3, "opens_at": "17:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "17:00", "closes_at": "21:00"}, + {"day": 5, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 6, "opens_at": "17:00", "closes_at": "22:00"} + ] + }, + { + "name": "Bar-Bill Tavern", + "description": "WNY institution since 1967 known for world's best beef on weck and chicken wings. Uses paintbrush to apply sauce for perfect coating. Features housemade sauces and bleu cheese, hand carved beef station, all-season patio. Cash only.", + "category": "restaurants", + "street_address": "185 Main St", + "city": "East Aurora", + "state": "NY", + "zip_code": "14052", + "latitude": 42.7676, + "longitude": -78.6134, + "phone": "7166527959", + "website": "https://barbill.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "12:00", "closes_at": "21:00"}, + {"day": 1, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 2, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 3, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "11:00", "closes_at": "22:00"}, + {"day": 5, "opens_at": "11:00", "closes_at": "22:00"}, + {"day": 6, "opens_at": "11:00", "closes_at": "22:00"} + ] + }, + { + "name": "Arriba Tortilla", + "description": "Fresh, made-from-scratch Tex-Mex restaurant in East Aurora serving authentic Mexican cuisine with locally sourced ingredients.", + "category": "restaurants", + "street_address": "40 Riley St", + "city": "East Aurora", + "state": "NY", + "zip_code": "14052", + "latitude": 42.7668, + "longitude": -78.6095, + "phone": "7167149176", + "website": "https://arribatortilla.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 3, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 4, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 5, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 6, "opens_at": "11:00", "closes_at": "21:00"} + ] + }, + { + "name": "Remedy House", + "description": "All-day coffee bar in Buffalo's Five Points neighborhood offering extensive menu of coffee, food, and beverages. Expertly crafted coffee with highly curated beer and wine list in cozy ambiance.", + "category": "restaurants", + "street_address": "429 Rhode Island St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14213", + "latitude": 42.9145, + "longitude": -78.8845, + "phone": "7162482155", + "website": "https://remedyhouse.co", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "21:00"}, + {"day": 1, "opens_at": "07:00", "closes_at": "21:00"}, + {"day": 2, "opens_at": "07:00", "closes_at": "21:00"}, + {"day": 3, "opens_at": "07:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "21:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "21:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "21:00"} + ] + }, + { + "name": "Tipico Coffee & Cafe", + "description": "Neighborhood cafe in former grocery store on Buffalo's West Side. Features North America's largest kachelofen (masonry heater). Specialty coffee roaster with vibrant cafe featuring delicious house-made food and premium relationship coffees roasted to perfection.", + "category": "coffee-shops", + "street_address": "1084 Elmwood Ave", + "city": "Buffalo", + "state": "NY", + "zip_code": "14222", + "latitude": 42.9295, + "longitude": -78.8762, + "phone": "7164643449", + "website": "https://tipicocoffee.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "16:00"}, + {"day": 1, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 2, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 3, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "17:00"} + ] + }, + { + "name": "Public Espresso + Coffee", + "description": "Roasting, brewing, and serving since 2013. Full-service cafe and bakery within historic Lafayette Hotel in downtown Buffalo. Fresh-roasted coffee, handmade donuts and bagels, made-to-order breakfast, lunch, and dinner.", + "category": "coffee-shops", + "street_address": "391 Washington St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14203", + "latitude": 42.8864, + "longitude": -78.8712, + "phone": "7163679971", + "website": "https://publicespresso.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "15:00"}, + {"day": 1, "opens_at": "07:00", "closes_at": "16:00"}, + {"day": 2, "opens_at": "07:00", "closes_at": "16:00"}, + {"day": 3, "opens_at": "07:00", "closes_at": "16:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "16:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "16:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "15:00"} + ] + }, + { + "name": "Dog Ears Bookstore & Cafe", + "description": "Not-for-profit neighborhood bookstore and cafe ranked #2 on list of best local spots in New York State. Welcoming hometown atmosphere in South Buffalo supporting creativity and literacy programs.", + "category": "coffee-shops", + "street_address": "688 Abbott Rd", + "city": "Buffalo", + "state": "NY", + "zip_code": "14220", + "latitude": 42.8398, + "longitude": -78.8234, + "phone": "7168232665", + "website": "https://dogearsbookstore.org", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "09:00", "closes_at": "15:00"}, + {"day": 1, "opens_at": "07:30", "closes_at": "18:00"}, + {"day": 2, "opens_at": "07:30", "closes_at": "18:00"}, + {"day": 3, "opens_at": "07:30", "closes_at": "18:00"}, + {"day": 4, "opens_at": "07:30", "closes_at": "18:00"}, + {"day": 5, "opens_at": "07:30", "closes_at": "18:00"}, + {"day": 6, "opens_at": "09:00", "closes_at": "15:00"} + ] + }, + { + "name": "Overwinter Coffee", + "description": "Leading provider of high quality, single origin coffee in Buffalo area. Mission to bring amazing coffee to everyone without it being a secret. Locally roasted specialty coffee.", + "category": "coffee-shops", + "street_address": "5526 Main St", + "city": "Williamsville", + "state": "NY", + "zip_code": "14221", + "latitude": 42.9650, + "longitude": -78.7350, + "website": "https://overwinter.coffee", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 1, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 2, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 3, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "14:00"} + ] + }, + { + "name": "BreadHive Bakery & Cafe", + "description": "Buffalo's only worker-owned bakery cooperative. Woman and worker-owned since 2014. Sourdough bread, bagels, pretzels, breakfast and lunch sandwiches named after notable women musicians. Vegan cookies and treats made by hand.", + "category": "coffee-shops", + "street_address": "402 Connecticut St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14213", + "latitude": 42.9137, + "longitude": -78.8867, + "phone": "7169805623", + "email": "info@breadhive.com", + "website": "https://breadhive.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 1, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 2, "closed": true}, + {"day": 3, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 4, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 5, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "14:00"} + ] + }, + { + "name": "Butter Block", + "description": "Woman-owned French-style patisserie serving Five Points neighborhood for over 10 years. Classic croissants and pain au chocolat using imported French butter and traditional techniques. Plastic-free packaging, dog-friendly.", + "category": "coffee-shops", + "street_address": "426 Rhode Island St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14213", + "latitude": 42.9143, + "longitude": -78.8847, + "phone": "7164240027", + "website": "https://butterblockshop.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 1, "closed": true}, + {"day": 2, "closed": true}, + {"day": 3, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 4, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 5, "opens_at": "08:00", "closes_at": "14:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "14:00"} + ] + }, + { + "name": "Thin Ice Gift Shop", + "description": "Features 230+ local artists plus regional talent. Locally made, one-of-a-kind gift items including jewelry, pottery, glass, metal, fiber and wood creations all handcrafted in America, predominantly in Western New York.", + "category": "retail", + "street_address": "719 Elmwood Ave", + "city": "Buffalo", + "state": "NY", + "zip_code": "14222", + "latitude": 42.9185, + "longitude": -78.8768, + "phone": "7168814321", + "website": "https://thiniceonline.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "11:00", "closes_at": "17:00"}, + {"day": 1, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 2, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 3, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 4, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 5, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 6, "opens_at": "11:00", "closes_at": "18:00"} + ] + }, + { + "name": "Paula's Donuts", + "description": "Family-owned since 1996. Voted #1 Donuts by Buffalo Spree. Over 30 varieties of donuts plus pastries, bagels, breakfast sandwiches, coffee, and beverages. Provides best quality donuts and pastries in greater Buffalo area.", + "category": "retail", + "street_address": "8560 Main St", + "city": "Williamsville", + "state": "NY", + "zip_code": "14221", + "latitude": 42.9712, + "longitude": -78.7423, + "phone": "7165803614", + "website": "https://paulasdonuts.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "06:00", "closes_at": "16:00"}, + {"day": 1, "opens_at": "05:00", "closes_at": "18:00"}, + {"day": 2, "opens_at": "05:00", "closes_at": "18:00"}, + {"day": 3, "opens_at": "05:00", "closes_at": "18:00"}, + {"day": 4, "opens_at": "05:00", "closes_at": "18:00"}, + {"day": 5, "opens_at": "05:00", "closes_at": "18:00"}, + {"day": 6, "opens_at": "06:00", "closes_at": "17:00"} + ] + }, + { + "name": "Elm Street Bakery", + "description": "Family-owned local bakery, cafe and market offering whole, seasonal and fresh food. Artisan breads, pastries and freshly-roasted coffee. Wood-fired pizzas served daily from 3pm. Warm and welcoming environment.", + "category": "retail", + "street_address": "72 Elm St", + "city": "East Aurora", + "state": "NY", + "zip_code": "14052", + "latitude": 42.7670, + "longitude": -78.6170, + "phone": "7166524720", + "website": "https://elmstreetbakery.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "15:00"}, + {"day": 1, "closed": true}, + {"day": 2, "closed": true}, + {"day": 3, "opens_at": "07:00", "closes_at": "20:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "20:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "21:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "21:00"} + ] + }, + { + "name": "42 North Brewing Company", + "description": "20 barrel production brewery and tasting room founded in 2015 in historic village of East Aurora. Pet-friendly beer garden, rustic tasting room with live music, bubble hockey and shuffleboard. Handcrafted beer brewed on-site.", + "category": "services", + "street_address": "25 Pine St", + "city": "East Aurora", + "state": "NY", + "zip_code": "14052", + "latitude": 42.7654, + "longitude": -78.6098, + "phone": "7168057500", + "website": "https://42northbrewing.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "12:00", "closes_at": "18:00"}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "15:00", "closes_at": "21:00"}, + {"day": 3, "opens_at": "15:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "15:00", "closes_at": "22:00"}, + {"day": 5, "opens_at": "12:00", "closes_at": "22:00"}, + {"day": 6, "opens_at": "12:00", "closes_at": "22:00"} + ] + }, + { + "name": "Pausa Art House", + "description": "Winner of JazzBuffalo Poll 2022 'Best Small Jazz Venue.' Live music venue, art space, wine and tapas bar in Historic Allentown District. Presents indigenous Buffalo artists and national/international touring acts.", + "category": "arts-entertainment", + "street_address": "19 Wadsworth St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14201", + "latitude": 42.8995, + "longitude": -78.8771, + "phone": "7166979075", + "website": "https://pausaarthouse.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "closed": true}, + {"day": 3, "closed": true}, + {"day": 4, "opens_at": "18:30", "closes_at": "23:00"}, + {"day": 5, "opens_at": "18:30", "closes_at": "23:00"}, + {"day": 6, "opens_at": "18:30", "closes_at": "23:00"} + ] + }, + { + "name": "Nietzsche's", + "description": "Buffalo's legendary live music club since 1982. Capacity 350. Eclectically decorated venue attracting local and national acts. Many popular bands played here early in careers including Phish and Ani DiFranco. Buffalo's Longest Running Celtic Session every Saturday.", + "category": "arts-entertainment", + "street_address": "248 Allen St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14201", + "latitude": 42.8998, + "longitude": -78.8762, + "phone": "7168868539", + "website": "https://nietzschesbuffalo.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "12:00", "closes_at": "02:00"}, + {"day": 1, "opens_at": "16:00", "closes_at": "02:00"}, + {"day": 2, "opens_at": "16:00", "closes_at": "02:00"}, + {"day": 3, "opens_at": "16:00", "closes_at": "02:00"}, + {"day": 4, "opens_at": "16:00", "closes_at": "02:00"}, + {"day": 5, "opens_at": "16:00", "closes_at": "02:00"}, + {"day": 6, "opens_at": "12:00", "closes_at": "02:00"} + ] + }, + { + "name": "North Park Theatre", + "description": "Historic single-screen theater opened November 21, 1920. Buffalo's only fully independent, non-profit cinema since extensive 2014 restoration. Mesmerizing architecture showcasing classic and independent films.", + "category": "arts-entertainment", + "street_address": "1428 Hertel Ave", + "city": "Buffalo", + "state": "NY", + "zip_code": "14216", + "latitude": 42.9450, + "longitude": -78.8580, + "phone": "7168367411", + "email": "Info@NorthParkTheatre.Org", + "website": "https://northparktheatre.org", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "12:00", "closes_at": "22:00"}, + {"day": 1, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 2, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 3, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 4, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 5, "opens_at": "17:00", "closes_at": "23:00"}, + {"day": 6, "opens_at": "12:00", "closes_at": "23:00"} + ] + }, + { + "name": "Hallwalls Contemporary Arts Center", + "description": "Founded 1974, supports creation and presentation of new work in visual, media, performing, and literary arts. Renowned for contemporary art exhibitions, film and video screenings, live jazz, new music, and performances.", + "category": "arts-entertainment", + "street_address": "341 Delaware Ave", + "city": "Buffalo", + "state": "NY", + "zip_code": "14202", + "latitude": 42.8934, + "longitude": -78.8716, + "phone": "7168541694", + "website": "https://hallwalls.org", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 3, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 4, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 5, "opens_at": "11:00", "closes_at": "18:00"}, + {"day": 6, "opens_at": "11:00", "closes_at": "18:00"} + ] + }, + { + "name": "Sportsmen's Tavern", + "description": "Since mid-1980s, Buffalo's home for Americana music in historic Black Rock section. Rotation of country, Americana and roots music, plus jazz, R&B, rock and blues. Monthly polka party. Intimate and authentic experience.", + "category": "arts-entertainment", + "street_address": "326 Amherst St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14207", + "latitude": 42.9345, + "longitude": -78.8923, + "phone": "7168747734", + "website": "https://blackrock.sportsmenstavern.net", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "14:00", "closes_at": "22:00"}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "16:00", "closes_at": "23:00"}, + {"day": 3, "opens_at": "16:00", "closes_at": "23:00"}, + {"day": 4, "opens_at": "16:00", "closes_at": "23:00"}, + {"day": 5, "opens_at": "16:00", "closes_at": "00:00"}, + {"day": 6, "opens_at": "14:00", "closes_at": "00:00"} + ] + }, + { + "name": "Buffalo Iron Works", + "description": "Historic live music venue in early 1900s factory space in Cobblestone District next to KeyBank Center. Capacity 500. Named 'Best Music Venue in Buffalo' (2017) and 'Best Place to Dance' (2019). Full bar, kitchen, and outdoor patio.", + "category": "arts-entertainment", + "street_address": "49 Illinois St", + "city": "Buffalo", + "state": "NY", + "zip_code": "14203", + "latitude": 42.8756, + "longitude": -78.8734, + "phone": "7162001893", + "email": "hello@buffaloironworks.com", + "website": "https://buffaloironworks.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "closed": true}, + {"day": 3, "opens_at": "17:00", "closes_at": "23:00"}, + {"day": 4, "opens_at": "17:00", "closes_at": "00:00"}, + {"day": 5, "opens_at": "17:00", "closes_at": "02:00"}, + {"day": 6, "opens_at": "17:00", "closes_at": "02:00"} + ] + }, + { + "name": "Gear for Adventure - Hamburg", + "description": "Locally owned outdoor retailer offering a wide range of outdoor gear including tents, backpacks, apparel, canoes, kayaks, camping equipment, and sporting goods. Known as an affordable and reliable source for camping, hiking, backpacking, and canoeing gear in the Buffalo area.", + "category": "retail", + "street_address": "305 Buffalo Street", + "city": "Hamburg", + "state": "NY", + "zip_code": "14075", + "latitude": 42.7159, + "longitude": -78.8295, + "phone": "7166464327", + "website": "https://gearforadventure.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "10:00", "closes_at": "16:00"}, + {"day": 1, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 2, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 3, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 4, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 5, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 6, "opens_at": "10:00", "closes_at": "17:00"} + ] + }, + { + "name": "Gear for Adventure - Amherst", + "description": "Locally owned outdoor retailer specializing in outdoor recreation equipment and apparel. Wide selection of tents, backpacks, canoes, kayaks, and camping gear for all your adventure needs.", + "category": "retail", + "street_address": "3906 Maple Road", + "city": "Amherst", + "state": "NY", + "zip_code": "14226", + "latitude": 42.9790, + "longitude": -78.7998, + "phone": "7168354327", + "website": "https://gearforadventure.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "10:00", "closes_at": "16:00"}, + {"day": 1, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 2, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 3, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 4, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 5, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 6, "opens_at": "10:00", "closes_at": "17:00"} + ] + }, + { + "name": "Jonny C's NY Deli and Caterers", + "description": "Self-service deli featuring an eat-in dining room decorated with New York City nostalgia. Offers extensive menu of overstuffed sandwiches, hot entrees, salads, sides, and soups. Specialty meats and cold cuts sold by the pound. Owned by NYC native Jon Cohen, opened November 2006.", + "category": "restaurants", + "street_address": "9350 Transit Road E", + "city": "East Amherst", + "state": "NY", + "zip_code": "14051", + "latitude": 43.0184, + "longitude": -78.6967, + "phone": "7166888400", + "website": "https://jonnycs.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "10:00", "closes_at": "20:00"}, + {"day": 2, "opens_at": "10:00", "closes_at": "20:00"}, + {"day": 3, "opens_at": "10:00", "closes_at": "20:00"}, + {"day": 4, "opens_at": "10:00", "closes_at": "20:00"}, + {"day": 5, "opens_at": "10:00", "closes_at": "20:00"}, + {"day": 6, "opens_at": "10:00", "closes_at": "20:00"} + ] + }, + { + "name": "Kennedy's Cove", + "description": "Known for hand-cut steaks and house-made blackened seasoning since 1981. Specializes in perfectly aged, hand-cut steaks. A Clarence institution for over 40 years.", + "category": "restaurants", + "street_address": "9800 Main Street", + "city": "Clarence", + "state": "NY", + "zip_code": "14031", + "latitude": 42.9800, + "longitude": -78.5900, + "phone": "7167598961", + "website": "https://kennedyscove.net", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "16:00", "closes_at": "22:00"}, + {"day": 2, "opens_at": "16:00", "closes_at": "22:00"}, + {"day": 3, "opens_at": "16:00", "closes_at": "22:00"}, + {"day": 4, "opens_at": "16:00", "closes_at": "22:00"}, + {"day": 5, "opens_at": "16:00", "closes_at": "22:00"}, + {"day": 6, "opens_at": "16:00", "closes_at": "22:00"} + ] + }, + { + "name": "The Hollow Bistro & Brew", + "description": "Established December 2010, owned and operated by the Yu Family (Clarence residents for 31 years). Offers new American fine dining with Asian flair in historic Clarence Hollow. Uses only the freshest ingredients for comfort food.", + "category": "restaurants", + "street_address": "10641 Main Street", + "city": "Clarence", + "state": "NY", + "zip_code": "14031", + "latitude": 42.9809, + "longitude": -78.5912, + "phone": "7167597351", + "email": "hollowbistro@gmail.com", + "website": "https://thehollowclarence.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "11:00", "closes_at": "14:00"}, + {"day": 1, "closed": true}, + {"day": 2, "closed": true}, + {"day": 3, "opens_at": "16:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "16:00", "closes_at": "21:00"}, + {"day": 5, "opens_at": "16:00", "closes_at": "21:00"}, + {"day": 6, "opens_at": "16:00", "closes_at": "21:00"} + ] + }, + { + "name": "This Little Pig", + "description": "Family-owned and operated by husband and wife Jeffrey and Mandy Cooke. Upscale casual restaurant featuring farm-to-table American cuisine with unique barbecue. Features full bar, wine list, and up to 20 local beers on tap. Seats about 150 with indoor/patio dining.", + "category": "restaurants", + "street_address": "10651 Main Street", + "city": "Clarence", + "state": "NY", + "zip_code": "14031", + "latitude": 42.9808, + "longitude": -78.5912, + "phone": "7165807872", + "website": "https://thislittlepigeats.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "12:00", "closes_at": "21:00"}, + {"day": 3, "opens_at": "12:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "12:00", "closes_at": "21:00"}, + {"day": 5, "opens_at": "12:00", "closes_at": "21:00"}, + {"day": 6, "opens_at": "12:00", "closes_at": "21:00"} + ] + }, + { + "name": "Morluski's Polish & Italian Cuisine", + "description": "Located in an old, renovated church. Features eclectic and award-winning homemade Polish and Italian food on a 50-50 menu. Fresh, authentic, hand-made dishes with international drinks and creative cocktails. Limited seating; reservations encouraged.", + "category": "restaurants", + "street_address": "10678 Main Street", + "city": "Clarence", + "state": "NY", + "zip_code": "14031", + "latitude": 42.9808, + "longitude": -78.5910, + "phone": "7164073238", + "website": "https://morluskis.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "15:00", "closes_at": "20:00"}, + {"day": 2, "opens_at": "15:00", "closes_at": "20:00"}, + {"day": 3, "opens_at": "15:00", "closes_at": "20:00"}, + {"day": 4, "opens_at": "15:00", "closes_at": "20:00"}, + {"day": 5, "opens_at": "15:00", "closes_at": "21:00"}, + {"day": 6, "opens_at": "15:00", "closes_at": "21:00"} + ] + }, + { + "name": "Unbridled Cafe", + "description": "Locally owned cafe specializing in fresh, homemade dishes including homemade jelly, real corned beef hash, coffee cake French toast, and rustic baked goods. Features attached Mercantile with locally produced items. Offers indoor/outdoor dining, catering, and takeout.", + "category": "restaurants", + "street_address": "9380 Transit Road", + "city": "East Amherst", + "state": "NY", + "zip_code": "14051", + "latitude": 43.0180, + "longitude": -78.6970, + "phone": "7165756067", + "email": "eat@unbridledcafe.com", + "website": "https://eatunbridledcafe.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 3, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "15:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "19:00"}, + {"day": 6, "opens_at": "07:00", "closes_at": "15:00"} + ] + }, + { + "name": "Feature Eatery", + "description": "REAL FOOD eatery - no gluten, no seed oils, no soy. All dishes prepared and cooked entirely in-house from dairy-free & sugar-free sauces to all-natural proteins. Choose-your-own bowls with base, protein, 2 sides, and sauce.", + "category": "restaurants", + "street_address": "9310 Transit Road", + "city": "East Amherst", + "state": "NY", + "zip_code": "14051", + "latitude": 43.0180, + "longitude": -78.6970, + "phone": "7165082830", + "website": "https://featureeatery.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 2, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 3, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 4, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 5, "opens_at": "11:00", "closes_at": "20:00"}, + {"day": 6, "opens_at": "11:00", "closes_at": "20:00"} + ] + }, + { + "name": "Jazzboline Restaurant & Bar", + "description": "Established 2019, located next to Reikart House on the Amherst Hospitality Campus. Embraces flair without formality, blending bold flavors with fun. Offers contemporary fare, well-curated cocktails, and local craft brews on tap.", + "category": "restaurants", + "street_address": "5010 Main Street", + "city": "Amherst", + "state": "NY", + "zip_code": "14226", + "latitude": 42.9784, + "longitude": -78.7998, + "phone": "7168392220", + "website": "https://jazzboline.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "10:00", "closes_at": "21:00"}, + {"day": 1, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 2, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 3, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "11:00", "closes_at": "22:00"}, + {"day": 5, "opens_at": "11:00", "closes_at": "22:00"}, + {"day": 6, "opens_at": "10:00", "closes_at": "22:00"} + ] + }, + { + "name": "Toasted by Buffalo Beauty Foodie", + "description": "Owned by Adria Campana (The Buffalo Beauty Foodie), offering fresh, locally produced food with a focus on healthy living. Completely vegetarian menu with gluten-free and nut-free options. Features superfoods and nutritious ingredients.", + "category": "coffee-shops", + "street_address": "6000 Goodrich Road", + "city": "Clarence Center", + "state": "NY", + "zip_code": "14032", + "latitude": 43.0000, + "longitude": -78.6200, + "phone": "7164062709", + "website": "https://toastedbflo.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "15:00"}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "08:00", "closes_at": "15:00"}, + {"day": 3, "opens_at": "08:00", "closes_at": "15:00"}, + {"day": 4, "opens_at": "08:00", "closes_at": "15:00"}, + {"day": 5, "opens_at": "08:00", "closes_at": "15:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "15:00"} + ] + }, + { + "name": "Prominent Coffee Co.", + "description": "Opened 2023. Offers specialty coffees, seasonal lattes, and bubble teas. Ethically-sourced beans with unique coffee blend exclusive to Prominent Coffee Co. Stands out by offering something distinctive and extraordinary in the coffee market.", + "category": "coffee-shops", + "street_address": "9135 Sheridan Drive, Suite 4", + "city": "Clarence", + "state": "NY", + "zip_code": "14031", + "latitude": 42.9708, + "longitude": -78.6509, + "phone": "7162609860", + "website": "https://prominentcoffeeco.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 2, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 3, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "17:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "17:00"} + ] + }, + { + "name": "Goodrich Coffee & Tea", + "description": "Local family-owned-and-operated cafe offering personal customer service and top-quality food and beverages. Locally owned, small batch roasted coffee. Features drive-thru service and lovely patio area. Offers breakfast, lunch items, and wide variety of beverages.", + "category": "coffee-shops", + "street_address": "9450 Main Street", + "city": "Clarence", + "state": "NY", + "zip_code": "14031", + "latitude": 42.9808, + "longitude": -78.5912, + "phone": "7167591791", + "website": "https://goodrichcoffee.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "06:30", "closes_at": "19:00"}, + {"day": 2, "opens_at": "06:30", "closes_at": "19:00"}, + {"day": 3, "opens_at": "06:30", "closes_at": "19:00"}, + {"day": 4, "opens_at": "06:30", "closes_at": "19:00"}, + {"day": 5, "opens_at": "06:30", "closes_at": "19:00"}, + {"day": 6, "opens_at": "07:00", "closes_at": "18:00"} + ] + }, + { + "name": "Clarence Center Coffee Co. & Cafe", + "description": "Established 1998. Unique restaurant offering high-quality food and drinks from top local vendors. Fresh baked goods, locally crafted beers, impressive wine selection, award-winning food (including vegan and gluten-free options), and live music.", + "category": "coffee-shops", + "street_address": "9475 Clarence Center Road", + "city": "Clarence Center", + "state": "NY", + "zip_code": "14032", + "latitude": 43.0000, + "longitude": -78.6200, + "phone": "7167418573", + "website": "https://clarencecentercoffee.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "08:00", "closes_at": "15:00"}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "07:00", "closes_at": "22:00"}, + {"day": 3, "opens_at": "07:00", "closes_at": "19:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "19:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "19:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "19:00"} + ] + }, + { + "name": "Bocce Club Pizza - Clarence Center", + "description": "Local tradition in WNY since 1946. Started by Dino Pacciotti upon returning from WWII. Currently owned by James D. Pacciotti. Family-owned for 79 years with 3 locations, Clarence being the newest.", + "category": "restaurants", + "street_address": "6235 Goodrich Road", + "city": "Clarence Center", + "state": "NY", + "zip_code": "14032", + "latitude": 43.0000, + "longitude": -78.6200, + "phone": "7167412888", + "website": "https://bocceclubpizza.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "12:00", "closes_at": "21:00"}, + {"day": 1, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 2, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 3, "opens_at": "11:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "11:00", "closes_at": "22:00"}, + {"day": 5, "opens_at": "11:00", "closes_at": "22:00"}, + {"day": 6, "opens_at": "12:00", "closes_at": "22:00"} + ] + }, + { + "name": "Shalooby Loofer Brewing", + "description": "Nano-brewery in converted 3-car garage with Ruby Pro 1 BBL Electric Brew House. Owned by Eon Verrall, named after his daughters' nicknames. Offers ales, sours, and pilsners with mission to 'drink it forward' - portion of pint sales go to charity.", + "category": "services", + "street_address": "10737 Main Street", + "city": "Clarence", + "state": "NY", + "zip_code": "14031", + "latitude": 42.9808, + "longitude": -78.5912, + "phone": "7163205021", + "email": "hopwizard@shaloobyloofer.com", + "website": "https://shaloobyloofer.com", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "closed": true}, + {"day": 2, "closed": true}, + {"day": 3, "opens_at": "16:00", "closes_at": "21:00"}, + {"day": 4, "opens_at": "16:00", "closes_at": "21:00"}, + {"day": 5, "opens_at": "14:00", "closes_at": "22:00"}, + {"day": 6, "opens_at": "12:00", "closes_at": "20:00"} + ] + }, + { + "name": "The Lion and Eagle Pub", + "description": "All-ages live music venue with 99-seat capacity. Huge selection of domestic and craft beers, excellent food, great vibes. Hosts open mic on Wednesday nights and shows Thursday-Saturday. One of Clarence's best neighborhood hangouts.", + "category": "arts-entertainment", + "street_address": "10255 Main Street", + "city": "Clarence", + "state": "NY", + "zip_code": "14031", + "latitude": 42.9808, + "longitude": -78.5912, + "phone": "7163205872", + "email": "lionandeaglebooking@gmail.com", + "website": "https://thelionandeaglepub.com", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "11:30", "closes_at": "19:00"}, + {"day": 1, "closed": true}, + {"day": 2, "opens_at": "17:00", "closes_at": "22:00"}, + {"day": 3, "opens_at": "16:00", "closes_at": "00:00"}, + {"day": 4, "opens_at": "16:00", "closes_at": "00:00"}, + {"day": 5, "opens_at": "16:00", "closes_at": "00:00"}, + {"day": 6, "opens_at": "11:30", "closes_at": "02:00"} + ] + } + ] +} diff --git a/priv/data/sample_import.json b/priv/data/sample_import.json new file mode 100644 index 0000000..109b376 --- /dev/null +++ b/priv/data/sample_import.json @@ -0,0 +1,57 @@ +{ + "businesses": [ + { + "name": "Sample Coffee Shop", + "description": "A cozy neighborhood coffee shop with locally roasted beans.", + "category": "coffee-shops", + "street_address": "100 Example Street", + "city": "Columbus", + "state": "OH", + "zip_code": "43215", + "latitude": 39.9700, + "longitude": -83.0000, + "phone": "6145550100", + "email": "hello@samplecoffee.example", + "website": "https://samplecoffee.example", + "locally_owned": true, + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "07:00", "closes_at": "18:00"}, + {"day": 2, "opens_at": "07:00", "closes_at": "18:00"}, + {"day": 3, "opens_at": "07:00", "closes_at": "18:00"}, + {"day": 4, "opens_at": "07:00", "closes_at": "18:00"}, + {"day": 5, "opens_at": "07:00", "closes_at": "18:00"}, + {"day": 6, "opens_at": "08:00", "closes_at": "16:00"} + ], + "photos": [ + { + "url": "https://example.com/photos/coffee-shop.jpg", + "alt_text": "Interior of Sample Coffee Shop", + "primary": true + } + ] + }, + { + "name": "Sample Bookstore", + "description": "Independent bookstore featuring local authors and rare finds.", + "category": "retail", + "street_address": "200 Book Lane", + "city": "Columbus", + "state": "OH", + "zip_code": "43215", + "latitude": 39.9650, + "longitude": -82.9950, + "phone": "6145550200", + "locally_owned": true, + "hours": [ + {"day": 0, "opens_at": "12:00", "closes_at": "17:00"}, + {"day": 1, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 2, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 3, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 4, "opens_at": "10:00", "closes_at": "19:00"}, + {"day": 5, "opens_at": "10:00", "closes_at": "21:00"}, + {"day": 6, "opens_at": "10:00", "closes_at": "21:00"} + ] + } + ] +} diff --git a/test/localspot/businesses/import_test.exs b/test/localspot/businesses/import_test.exs new file mode 100644 index 0000000..9ba49e4 --- /dev/null +++ b/test/localspot/businesses/import_test.exs @@ -0,0 +1,192 @@ +defmodule Localspot.Businesses.ImportTest do + use Localspot.DataCase + + alias Localspot.Businesses.Import + alias Localspot.Businesses + + describe "from_json/1" do + setup do + # Create a category for testing + {:ok, category} = + Businesses.create_category(%{ + name: "Test Category", + slug: "test-category", + description: "For testing" + }) + + %{category: category} + end + + test "imports a valid business", %{category: category} do + json = """ + { + "businesses": [ + { + "name": "Test Business", + "description": "A test business", + "category": "test-category", + "street_address": "123 Test St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215", + "latitude": 39.9612, + "longitude": -82.9988, + "locally_owned": true + } + ] + } + """ + + assert {:ok, %{imported: 1, errors: []}} = Import.from_json(json) + + business = Businesses.get_business_by_slug("test-business") + assert business.name == "Test Business" + assert business.category_id == category.id + end + + test "imports business with hours", %{category: _category} do + json = """ + { + "businesses": [ + { + "name": "Business With Hours", + "category": "test-category", + "street_address": "123 Test St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215", + "hours": [ + {"day": 0, "closed": true}, + {"day": 1, "opens_at": "09:00", "closes_at": "17:00"} + ] + } + ] + } + """ + + assert {:ok, %{imported: 1, errors: []}} = Import.from_json(json) + + business = Businesses.get_business_by_slug("business-with-hours") + assert length(business.hours) == 2 + + sunday = Enum.find(business.hours, &(&1.day_of_week == 0)) + assert sunday.closed == true + + monday = Enum.find(business.hours, &(&1.day_of_week == 1)) + assert monday.opens_at == ~T[09:00:00] + assert monday.closes_at == ~T[17:00:00] + end + + test "imports business with photos", %{category: _category} do + json = """ + { + "businesses": [ + { + "name": "Business With Photos", + "category": "test-category", + "street_address": "123 Test St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215", + "photos": [ + {"url": "https://example.com/photo.jpg", "alt_text": "Test photo", "primary": true} + ] + } + ] + } + """ + + assert {:ok, %{imported: 1, errors: []}} = Import.from_json(json) + + business = Businesses.get_business_by_slug("business-with-photos") + assert length(business.photos) == 1 + assert hd(business.photos).url == "https://example.com/photo.jpg" + assert hd(business.photos).primary == true + end + + test "imports multiple businesses", %{category: _category} do + json = """ + { + "businesses": [ + { + "name": "First Business", + "category": "test-category", + "street_address": "123 Test St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215" + }, + { + "name": "Second Business", + "category": "test-category", + "street_address": "456 Test St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215" + } + ] + } + """ + + assert {:ok, %{imported: 2, errors: []}} = Import.from_json(json) + end + + test "reports errors for invalid businesses", %{category: _category} do + json = """ + { + "businesses": [ + { + "name": "Valid Business", + "category": "test-category", + "street_address": "123 Test St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215" + }, + { + "description": "Missing required name field" + } + ] + } + """ + + assert {:ok, %{imported: 1, errors: errors}} = Import.from_json(json) + assert length(errors) == 1 + end + + test "returns error for invalid JSON" do + assert {:error, {:json_error, _}} = Import.from_json("not valid json") + end + + test "returns error for missing businesses key" do + assert {:error, {:invalid_format, _}} = Import.from_json("{\"data\": []}") + end + + test "handles unknown category gracefully" do + json = """ + { + "businesses": [ + { + "name": "Test Business", + "category": "nonexistent-category", + "street_address": "123 Test St", + "city": "Columbus", + "state": "OH", + "zip_code": "43215" + } + ] + } + """ + + assert {:ok, + %{imported: 0, errors: [{:error, 1, {:unknown_category, "nonexistent-category"}}]}} = + Import.from_json(json) + end + end + + describe "from_file/1" do + test "returns error for nonexistent file" do + assert {:error, {:file_error, :enoent}} = Import.from_file("nonexistent.json") + end + end +end