prospector_engine 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +333 -0
  4. data/Rakefile +9 -0
  5. data/app/CLAUDE.md +43 -0
  6. data/app/assets/stylesheets/prospector/application.css +476 -0
  7. data/app/controllers/prospector/application_controller.rb +16 -0
  8. data/app/controllers/prospector/candidates_controller.rb +31 -0
  9. data/app/controllers/prospector/keyword_generations_controller.rb +10 -0
  10. data/app/controllers/prospector/keywords_controller.rb +38 -0
  11. data/app/controllers/prospector/run_bulk_approvals_controller.rb +13 -0
  12. data/app/controllers/prospector/run_cancellations_controller.rb +9 -0
  13. data/app/controllers/prospector/run_reclassifications_controller.rb +21 -0
  14. data/app/controllers/prospector/run_restarts_controller.rb +14 -0
  15. data/app/controllers/prospector/run_retries_controller.rb +14 -0
  16. data/app/controllers/prospector/runs_controller.rb +47 -0
  17. data/app/jobs/prospector/application_job.rb +5 -0
  18. data/app/jobs/prospector/bulk_approve_job.rb +14 -0
  19. data/app/jobs/prospector/classify_job.rb +17 -0
  20. data/app/jobs/prospector/fetch_job.rb +8 -0
  21. data/app/models/prospector/application_record.rb +6 -0
  22. data/app/models/prospector/candidate.rb +93 -0
  23. data/app/models/prospector/classification_run.rb +15 -0
  24. data/app/models/prospector/keyword.rb +16 -0
  25. data/app/models/prospector/run.rb +94 -0
  26. data/app/views/prospector/candidates/show.html.erb +63 -0
  27. data/app/views/prospector/keywords/index.html.erb +72 -0
  28. data/app/views/prospector/layouts/prospector.html.erb +38 -0
  29. data/app/views/prospector/runs/index.html.erb +33 -0
  30. data/app/views/prospector/runs/new.html.erb +109 -0
  31. data/app/views/prospector/runs/show.html.erb +111 -0
  32. data/config/routes.rb +15 -0
  33. data/db/prospector_schema.rb +81 -0
  34. data/lib/generators/prospector/install/install_generator.rb +31 -0
  35. data/lib/generators/prospector/install/templates/create_prospector_tables.rb +83 -0
  36. data/lib/generators/prospector/install/templates/prospector.rb +37 -0
  37. data/lib/prospector/CLAUDE.md +52 -0
  38. data/lib/prospector/classification/runner.rb +105 -0
  39. data/lib/prospector/configuration.rb +56 -0
  40. data/lib/prospector/engine.rb +18 -0
  41. data/lib/prospector/enrichment/contact_scraper.rb +188 -0
  42. data/lib/prospector/error.rb +8 -0
  43. data/lib/prospector/geography/base.rb +40 -0
  44. data/lib/prospector/geography/bounding_box.rb +58 -0
  45. data/lib/prospector/geography/city.rb +29 -0
  46. data/lib/prospector/geography/coordinates.rb +43 -0
  47. data/lib/prospector/geography/metro_area.rb +74 -0
  48. data/lib/prospector/geography/zip_code.rb +25 -0
  49. data/lib/prospector/keywords/generator.rb +74 -0
  50. data/lib/prospector/pipeline/normalizer.rb +57 -0
  51. data/lib/prospector/pipeline/orchestrator.rb +151 -0
  52. data/lib/prospector/sources/base.rb +13 -0
  53. data/lib/prospector/sources/google_places/adapter.rb +92 -0
  54. data/lib/prospector/sources/google_places/client.rb +58 -0
  55. data/lib/prospector/sources/google_places/us_address_validator.rb +24 -0
  56. data/lib/prospector/sources/result.rb +21 -0
  57. data/lib/prospector/version.rb +3 -0
  58. data/lib/prospector.rb +20 -0
  59. metadata +185 -0
@@ -0,0 +1,14 @@
1
+ module Prospector
2
+ class BulkApproveJob < ApplicationJob
3
+ def perform(run_id)
4
+ run = Run.find(run_id)
5
+ run.candidates.where(status: "pending").find_each do |candidate|
6
+ next unless candidate.approvable?
7
+
8
+ candidate.approve!
9
+ rescue => e
10
+ Rails.logger.warn "Prospector: skip approve #{candidate.id}: #{e.message}"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module Prospector
2
+ class ClassifyJob < ApplicationJob
3
+ def perform(run_id, force: false, model: nil, classification_run_id: nil)
4
+ run = Run.find(run_id)
5
+ classification_run = ClassificationRun.find_by(id: classification_run_id)
6
+
7
+ classification_run&.update!(status: "running", started_at: Time.current)
8
+
9
+ Classification::Runner.new(
10
+ run,
11
+ model: model,
12
+ include_approved: force,
13
+ classification_run: classification_run
14
+ ).perform
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ module Prospector
2
+ class FetchJob < ApplicationJob
3
+ def perform(run_id)
4
+ run = Run.find(run_id)
5
+ Pipeline::Orchestrator.new(run).perform
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,6 @@
1
+ module Prospector
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ self.table_name_prefix = "prospector_"
5
+ end
6
+ end
@@ -0,0 +1,93 @@
1
+ module Prospector
2
+ class Candidate < ApplicationRecord
3
+ STATUSES = %w[pending approved rejected].freeze
4
+
5
+ belongs_to :run
6
+
7
+ validates :name, presence: true
8
+ validates :address, presence: true
9
+ validates :source_uid, presence: true, uniqueness: { scope: :run_id }
10
+ validates :status, presence: true, inclusion: { in: STATUSES }
11
+
12
+ if defined?(Turbo::Broadcastable)
13
+ broadcasts_refreshes_to :run
14
+ end
15
+
16
+ scope :by_status, ->(status) { where(status: status) }
17
+ scope :pending, -> { where(status: "pending") }
18
+ scope :approved, -> { where(status: "approved") }
19
+ scope :rejected, -> { where(status: "rejected") }
20
+ scope :ai_unprocessed, -> { where("NOT (metadata ? 'llm_categories')") }
21
+ scope :ai_processed, -> { where("metadata ? 'llm_categories'") }
22
+ scope :auto_rejected, -> { rejected.where("metadata->>'rejection_reason' = ?", "no_relevant_categories") }
23
+
24
+ def pending? = status == "pending"
25
+ def approved? = status == "approved"
26
+ def rejected? = status == "rejected"
27
+
28
+ def approve!
29
+ raise Error, "Candidate is not approvable" unless approvable?
30
+
31
+ update!(status: "approved")
32
+ complete_approval
33
+ rescue => e
34
+ update!(status: "pending") if approved?
35
+ raise Error, "Approval failed: #{e.message}"
36
+ end
37
+
38
+ def reject!(reason: nil)
39
+ attrs = { status: "rejected" }
40
+ attrs[:metadata] = metadata.merge("rejection_reason" => reason) if reason
41
+ update!(attrs)
42
+ instrument("prospector.candidate.rejected", candidate: self, reason: reason)
43
+ end
44
+
45
+ def restore_to_pending!
46
+ new_metadata = metadata.except("rejection_reason")
47
+ update!(status: "pending", metadata: new_metadata)
48
+ end
49
+
50
+ def normalized_data
51
+ metadata["normalized_data"] || {}
52
+ end
53
+
54
+ def llm_categories
55
+ metadata["llm_categories"] || []
56
+ end
57
+
58
+ def llm_confidence
59
+ metadata["llm_confidence"]
60
+ end
61
+
62
+ def llm_reasoning
63
+ metadata["llm_reasoning"]
64
+ end
65
+
66
+ SOCIAL_FIELDS = %i[facebook_url instagram_url linkedin_url tiktok_url youtube_url].freeze
67
+
68
+ def approvable?
69
+ pending? && normalized_data["state"].present?
70
+ end
71
+
72
+ def has_contact_info?
73
+ email.present? || SOCIAL_FIELDS.any? { |f| self[f].present? }
74
+ end
75
+
76
+ private
77
+
78
+ def complete_approval
79
+ record = invoke_on_approve
80
+ instrument("prospector.candidate.approved", candidate: self, record: record)
81
+ record
82
+ end
83
+
84
+ def invoke_on_approve
85
+ callback = Prospector.config.on_approve
86
+ callback ? callback.call(self) : nil
87
+ end
88
+
89
+ def instrument(event, payload = {})
90
+ ActiveSupport::Notifications.instrument(event, payload)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,15 @@
1
+ module Prospector
2
+ class ClassificationRun < ApplicationRecord
3
+ STATUSES = %w[pending running completed failed].freeze
4
+
5
+ belongs_to :run
6
+
7
+ validates :ai_model, presence: true
8
+ validates :status, presence: true, inclusion: { in: STATUSES }
9
+
10
+ def pending? = status == "pending"
11
+ def running? = status == "running"
12
+ def completed? = status == "completed"
13
+ def failed? = status == "failed"
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ module Prospector
2
+ class Keyword < ApplicationRecord
3
+ validates :domain, presence: true
4
+ validates :category, presence: true
5
+ validates :keyword, presence: true, uniqueness: { scope: %i[domain category] }
6
+ validates :source, presence: true
7
+
8
+ scope :active, -> { where(active: true) }
9
+ scope :for_domain, ->(domain) { where(domain: domain) }
10
+ scope :for_category, ->(category) { where(category: category) }
11
+
12
+ def self.keywords_for(domain:, category:)
13
+ active.for_domain(domain).for_category(category).pluck(:keyword)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,94 @@
1
+ module Prospector
2
+ class Run < ApplicationRecord
3
+ STATUSES = %w[pending running classifying completed failed cancelled].freeze
4
+
5
+ has_many :candidates, dependent: :destroy
6
+ has_many :classification_runs, dependent: :destroy
7
+
8
+ validates :status, presence: true, inclusion: { in: STATUSES }
9
+ validates :source_adapter, presence: true
10
+ validates :geography_type, presence: true
11
+
12
+ if defined?(Turbo::Broadcastable)
13
+ broadcasts_refreshes
14
+ end
15
+
16
+ after_update_commit :instrument_status_change, if: :saved_change_to_status?
17
+
18
+ scope :by_status, ->(status) { where(status: status) }
19
+ scope :recent, -> { order(created_at: :desc) }
20
+
21
+ def pending? = status == "pending"
22
+ def running? = status == "running"
23
+ def classifying? = status == "classifying"
24
+ def completed? = status == "completed"
25
+ def failed? = status == "failed"
26
+ def cancelled? = status == "cancelled"
27
+
28
+ def cancellable?
29
+ pending? || running? || classifying?
30
+ end
31
+
32
+ def restartable?
33
+ completed? || failed? || cancelled?
34
+ end
35
+
36
+ def retryable? = failed?
37
+
38
+ def cancel!
39
+ update!(status: "cancelled", completed_at: Time.current)
40
+ instrument("prospector.run.cancelled", run: self)
41
+ end
42
+
43
+ def reset_for_retry!
44
+ update!(
45
+ status: "pending",
46
+ error_messages: nil,
47
+ error_count: 0,
48
+ started_at: nil,
49
+ completed_at: nil
50
+ )
51
+ end
52
+
53
+ def restart!
54
+ transaction do
55
+ candidates.where(status: %w[pending rejected]).destroy_all
56
+ update!(
57
+ status: "pending",
58
+ total_found: 0,
59
+ fetched_count: 0,
60
+ skipped_count: 0,
61
+ error_count: 0,
62
+ error_messages: nil,
63
+ metadata: {},
64
+ started_at: nil,
65
+ completed_at: nil
66
+ )
67
+ end
68
+ end
69
+
70
+ def geography
71
+ Geography::Base.from_h(geography_data.merge("type" => geography_type))
72
+ end
73
+
74
+ def geography=(geo)
75
+ self.geography_type = geo.type
76
+ self.geography_data = geo.to_h
77
+ end
78
+
79
+ private
80
+
81
+ def instrument_status_change
82
+ ActiveSupport::Notifications.instrument(
83
+ "prospector.run.status_changed",
84
+ run: self,
85
+ status: status,
86
+ previous_status: status_before_last_save
87
+ )
88
+ end
89
+
90
+ def instrument(event, payload = {})
91
+ ActiveSupport::Notifications.instrument(event, payload)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,63 @@
1
+ <%= link_to "&larr; Back to run".html_safe, run_path(@run, filter: @candidate.status), class: "prospector-text-muted prospector-text-sm" %>
2
+
3
+ <h1 class="prospector-heading prospector-mt-4"><%= @candidate.name %></h1>
4
+
5
+ <div class="prospector-flex prospector-gap-2" style="margin-bottom: 1rem;">
6
+ <span class="prospector-badge prospector-badge-<%= @candidate.status %>"><%= @candidate.status.titleize %></span>
7
+ <% if @candidate.llm_confidence %>
8
+ <span class="prospector-text-muted prospector-text-sm">Confidence: <%= (@candidate.llm_confidence * 100).round %>%</span>
9
+ <% end %>
10
+ </div>
11
+
12
+ <div class="prospector-card">
13
+ <h2 class="prospector-subheading">Details</h2>
14
+ <table class="prospector-table">
15
+ <tbody>
16
+ <tr><td><strong>Address</strong></td><td><%= @candidate.address %></td></tr>
17
+ <tr><td><strong>Phone</strong></td><td><%= @candidate.phone_number || "-" %></td></tr>
18
+ <tr><td><strong>Website</strong></td><td><%= @candidate.website.present? ? link_to(@candidate.website, @candidate.website, target: "_blank") : "-" %></td></tr>
19
+ <tr><td><strong>Category</strong></td><td><%= @candidate.category || "-" %></td></tr>
20
+ <tr><td><strong>Source UID</strong></td><td class="prospector-text-muted"><%= @candidate.source_uid %></td></tr>
21
+ <tr><td><strong>Rating</strong></td><td><%= @candidate.metadata["rating"] || "-" %> (<%= @candidate.metadata["rating_count"] || 0 %> reviews)</td></tr>
22
+ </tbody>
23
+ </table>
24
+ </div>
25
+
26
+ <% if @candidate.has_contact_info? %>
27
+ <div class="prospector-card">
28
+ <h2 class="prospector-subheading">Contact & Social</h2>
29
+ <table class="prospector-table">
30
+ <tbody>
31
+ <% if @candidate.email.present? %>
32
+ <tr><td><strong>Email</strong></td><td><%= mail_to @candidate.email %></td></tr>
33
+ <% end %>
34
+ <% Prospector::Candidate::SOCIAL_FIELDS.each do |field| %>
35
+ <% url = @candidate[field] %>
36
+ <% if url.present? %>
37
+ <tr><td><strong><%= field.to_s.chomp("_url").titleize %></strong></td><td><%= link_to truncate(url, length: 50), url, target: "_blank" %></td></tr>
38
+ <% end %>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ <% end %>
44
+
45
+ <% if @candidate.llm_categories.any? %>
46
+ <div class="prospector-card">
47
+ <h2 class="prospector-subheading">AI Classification</h2>
48
+ <p><strong>Categories:</strong> <%= @candidate.llm_categories.join(", ") %></p>
49
+ <p class="prospector-text-muted prospector-text-sm"><%= @candidate.llm_reasoning %></p>
50
+ </div>
51
+ <% end %>
52
+
53
+ <div class="prospector-flex prospector-gap-2 prospector-mt-4">
54
+ <% if @candidate.pending? && @candidate.approvable? %>
55
+ <%= button_to "Approve", run_candidate_path(@run, @candidate, status: "approved"), method: :patch, class: "prospector-btn prospector-btn-primary" %>
56
+ <% end %>
57
+ <% if @candidate.pending? %>
58
+ <%= button_to "Reject", run_candidate_path(@run, @candidate, status: "rejected"), method: :patch, class: "prospector-btn prospector-btn-danger" %>
59
+ <% end %>
60
+ <% if @candidate.rejected? %>
61
+ <%= button_to "Restore to Pending", run_candidate_path(@run, @candidate, status: "pending"), method: :patch, class: "prospector-btn prospector-btn-outline" %>
62
+ <% end %>
63
+ </div>
@@ -0,0 +1,72 @@
1
+ <div class="prospector-flex prospector-flex-between">
2
+ <h1 class="prospector-heading">Keywords</h1>
3
+ <span class="prospector-text-muted prospector-text-sm">Domain: <strong><%= @domain %></strong></span>
4
+ </div>
5
+
6
+ <div class="prospector-card">
7
+ <h2 class="prospector-subheading">Add Keyword</h2>
8
+ <%= form_with model: Prospector::Keyword.new, url: keywords_path, method: :post do |f| %>
9
+ <%= f.hidden_field :domain, value: @domain %>
10
+ <div class="prospector-flex prospector-gap-4" style="align-items: flex-end;">
11
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
12
+ <%= f.label :category, class: "prospector-label" %>
13
+ <%= f.text_field :category, class: "prospector-input", placeholder: "e.g. mechanic" %>
14
+ </div>
15
+ <div class="prospector-form-group" style="flex: 2; margin-bottom: 0;">
16
+ <%= f.label :keyword, class: "prospector-label" %>
17
+ <%= f.text_field :keyword, class: "prospector-input", placeholder: "e.g. motorcycle repair shop" %>
18
+ </div>
19
+ <div>
20
+ <%= f.submit "Add", class: "prospector-btn prospector-btn-primary" %>
21
+ </div>
22
+ </div>
23
+ <% end %>
24
+ </div>
25
+
26
+ <% if @categories.any? %>
27
+ <% @categories.each do |category| %>
28
+ <div class="prospector-card">
29
+ <div class="prospector-flex prospector-flex-between" style="margin-bottom: 0.75rem;">
30
+ <h2 class="prospector-subheading" style="margin-bottom: 0;"><%= category %></h2>
31
+ <%= button_to "Generate via AI",
32
+ keyword_generations_path(domain: @domain, category: category),
33
+ method: :post,
34
+ class: "prospector-btn prospector-btn-outline prospector-btn-sm" %>
35
+ </div>
36
+ <table class="prospector-table">
37
+ <thead>
38
+ <tr>
39
+ <th>Keyword</th>
40
+ <th>Source</th>
41
+ <th>Active</th>
42
+ <th></th>
43
+ </tr>
44
+ </thead>
45
+ <tbody>
46
+ <% @keywords.where(category: category).each do |kw| %>
47
+ <tr>
48
+ <td><%= kw.keyword %></td>
49
+ <td>
50
+ <span class="prospector-badge <%= kw.source == 'llm' ? 'prospector-badge-classifying' : 'prospector-badge-completed' %>">
51
+ <%= kw.source %>
52
+ </span>
53
+ </td>
54
+ <td>
55
+ <%= button_to keyword_path(kw), method: :patch, class: "prospector-btn prospector-btn-outline prospector-btn-sm" do %>
56
+ <%= kw.active? ? "Active" : "Disabled" %>
57
+ <% end %>
58
+ </td>
59
+ <td>
60
+ <%= button_to "Remove", keyword_path(kw), method: :delete, class: "prospector-btn prospector-btn-danger prospector-btn-sm" %>
61
+ </td>
62
+ </tr>
63
+ <% end %>
64
+ </tbody>
65
+ </table>
66
+ </div>
67
+ <% end %>
68
+ <% else %>
69
+ <div class="prospector-card" style="text-align: center; padding: 3rem;">
70
+ <p class="prospector-text-muted">No keywords yet. Add one manually or generate via AI.</p>
71
+ </div>
72
+ <% end %>
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Prospector</title>
7
+ <%= stylesheet_link_tag "prospector/application", media: "all" %>
8
+ <%= csrf_meta_tags %>
9
+ </head>
10
+ <body style="margin: 0;">
11
+ <div class="prospector-ui">
12
+ <nav class="prospector-nav">
13
+ <div class="prospector-nav-brand">
14
+ <%= link_to "Prospector", prospector.root_path, class: "prospector-nav-title" %>
15
+ </div>
16
+ <div class="prospector-nav-links">
17
+ <%= link_to "Runs", prospector.runs_path, class: "prospector-nav-link" %>
18
+ <%= link_to "Keywords", prospector.keywords_path, class: "prospector-nav-link" %>
19
+ </div>
20
+ </nav>
21
+ <main class="prospector-main">
22
+ <% if notice.present? %>
23
+ <div class="prospector-flash prospector-flash-notice"><%= notice %></div>
24
+ <% end %>
25
+ <% if alert.present? %>
26
+ <div class="prospector-flash prospector-flash-alert"><%= alert %></div>
27
+ <% end %>
28
+ <%= yield %>
29
+ </main>
30
+ <footer class="prospector-footer">
31
+ <div class="prospector-footer-inner">
32
+ <span>&copy; <%= Time.current.year %> Prospector</span>
33
+ <span>An <a href="https://axiumfoundry.com" target="_blank" rel="noopener noreferrer">Axium Foundry</a> product</span>
34
+ </div>
35
+ </footer>
36
+ </div>
37
+ </body>
38
+ </html>
@@ -0,0 +1,33 @@
1
+ <div class="prospector-flex prospector-flex-between">
2
+ <h1 class="prospector-heading">Runs</h1>
3
+ <%= link_to "New Run", new_run_path, class: "prospector-btn prospector-btn-primary" %>
4
+ </div>
5
+
6
+ <div class="prospector-card">
7
+ <table class="prospector-table">
8
+ <thead>
9
+ <tr>
10
+ <th>Label</th>
11
+ <th>Source</th>
12
+ <th>Geography</th>
13
+ <th>Status</th>
14
+ <th>Found</th>
15
+ <th>Created</th>
16
+ <th></th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @runs.each do |run| %>
21
+ <tr>
22
+ <td><%= link_to run.label || "##{run.id}", run_path(run) %></td>
23
+ <td><%= run.source_adapter %></td>
24
+ <td><%= run.geography.label %></td>
25
+ <td><span class="prospector-badge prospector-badge-<%= run.status %>"><%= run.status.titleize %></span></td>
26
+ <td><%= run.total_found %></td>
27
+ <td class="prospector-text-muted prospector-text-sm"><%= run.created_at.strftime("%b %d, %Y %H:%M") %></td>
28
+ <td><%= link_to "View", run_path(run), class: "prospector-btn prospector-btn-outline prospector-btn-sm" %></td>
29
+ </tr>
30
+ <% end %>
31
+ </tbody>
32
+ </table>
33
+ </div>
@@ -0,0 +1,109 @@
1
+ <h1 class="prospector-heading">New Run</h1>
2
+
3
+ <div class="prospector-card">
4
+ <%= form_with model: @run, url: runs_path, method: :post do |f| %>
5
+ <div class="prospector-form-group">
6
+ <%= f.label :label, "Label (optional)", class: "prospector-label" %>
7
+ <%= f.text_field :label, class: "prospector-input", placeholder: "e.g. Dallas motorcycle shops" %>
8
+ </div>
9
+
10
+ <div class="prospector-form-group">
11
+ <%= f.label :source_adapter, "Source", class: "prospector-label" %>
12
+ <%= f.select :source_adapter, Prospector.config.sources.keys.map { |k| [k.to_s.titleize, k] }, {}, class: "prospector-select" %>
13
+ </div>
14
+
15
+ <div class="prospector-form-group">
16
+ <%= f.label :geography_type, "Geography Type", class: "prospector-label" %>
17
+ <%= f.select :geography_type,
18
+ Prospector::Geography::Base::TYPES.map { |t| [t.titleize, t] },
19
+ {},
20
+ class: "prospector-select",
21
+ onchange: "document.querySelectorAll('[data-geo-fields]').forEach(el => el.style.display = el.dataset.geoFields === this.value ? '' : 'none')" %>
22
+ </div>
23
+
24
+ <fieldset data-geo-fields="metro_area" class="prospector-form-group">
25
+ <legend class="prospector-label">Metro Area</legend>
26
+ <div class="prospector-form-group" style="margin-bottom: 8px;">
27
+ <select class="prospector-select" onchange="
28
+ var opt = this.options[this.selectedIndex];
29
+ var nameField = this.closest('fieldset').querySelector('[name*=name]');
30
+ var stateField = this.closest('fieldset').querySelector('[name*=primary_state]');
31
+ nameField.value = opt.dataset.name || '';
32
+ stateField.value = opt.dataset.state || '';
33
+ ">
34
+ <option value="">Select a metro area or enter custom below</option>
35
+ <% Prospector::Geography::MetroArea::PRELOADED.each do |metro| %>
36
+ <option data-name="<%= metro[:name] %>" data-state="<%= metro[:primary_state] %>">
37
+ <%= metro[:name] %>, <%= metro[:primary_state] %>
38
+ </option>
39
+ <% end %>
40
+ </select>
41
+ </div>
42
+ <div class="prospector-flex prospector-gap-4">
43
+ <div class="prospector-form-group" style="flex: 2; margin-bottom: 0;">
44
+ <%= text_field_tag "run[geography_data][name]", nil, class: "prospector-input", placeholder: "Metro area name, e.g. Dallas-Fort Worth" %>
45
+ </div>
46
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
47
+ <%= text_field_tag "run[geography_data][primary_state]", nil, class: "prospector-input", placeholder: "State, e.g. TX" %>
48
+ </div>
49
+ </div>
50
+ </fieldset>
51
+
52
+ <fieldset data-geo-fields="city" class="prospector-form-group" style="display: none;">
53
+ <legend class="prospector-label">City</legend>
54
+ <div class="prospector-flex prospector-gap-4">
55
+ <div class="prospector-form-group" style="flex: 2; margin-bottom: 0;">
56
+ <%= text_field_tag "run[geography_data][city]", nil, class: "prospector-input", placeholder: "City name, e.g. Austin" %>
57
+ </div>
58
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
59
+ <%= text_field_tag "run[geography_data][state]", nil, class: "prospector-input", placeholder: "State, e.g. TX" %>
60
+ </div>
61
+ </div>
62
+ </fieldset>
63
+
64
+ <fieldset data-geo-fields="coordinates" class="prospector-form-group" style="display: none;">
65
+ <legend class="prospector-label">Coordinates</legend>
66
+ <div class="prospector-flex prospector-gap-4">
67
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
68
+ <%= text_field_tag "run[geography_data][lat]", nil, class: "prospector-input", placeholder: "Latitude, e.g. 30.267" %>
69
+ </div>
70
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
71
+ <%= text_field_tag "run[geography_data][lng]", nil, class: "prospector-input", placeholder: "Longitude, e.g. -97.743" %>
72
+ </div>
73
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
74
+ <%= text_field_tag "run[geography_data][radius_meters]", "10000", class: "prospector-input", placeholder: "Radius (meters)" %>
75
+ </div>
76
+ </div>
77
+ </fieldset>
78
+
79
+ <fieldset data-geo-fields="zip_code" class="prospector-form-group" style="display: none;">
80
+ <legend class="prospector-label">ZIP Code</legend>
81
+ <div class="prospector-form-group" style="max-width: 200px; margin-bottom: 0;">
82
+ <%= text_field_tag "run[geography_data][zip]", nil, class: "prospector-input", placeholder: "e.g. 75201" %>
83
+ </div>
84
+ </fieldset>
85
+
86
+ <fieldset data-geo-fields="bounding_box" class="prospector-form-group" style="display: none;">
87
+ <legend class="prospector-label">Bounding Box</legend>
88
+ <div class="prospector-flex prospector-gap-4">
89
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
90
+ <%= text_field_tag "run[geography_data][sw_lat]", nil, class: "prospector-input", placeholder: "SW Latitude" %>
91
+ </div>
92
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
93
+ <%= text_field_tag "run[geography_data][sw_lng]", nil, class: "prospector-input", placeholder: "SW Longitude" %>
94
+ </div>
95
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
96
+ <%= text_field_tag "run[geography_data][ne_lat]", nil, class: "prospector-input", placeholder: "NE Latitude" %>
97
+ </div>
98
+ <div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
99
+ <%= text_field_tag "run[geography_data][ne_lng]", nil, class: "prospector-input", placeholder: "NE Longitude" %>
100
+ </div>
101
+ </div>
102
+ </fieldset>
103
+
104
+ <div class="prospector-form-group">
105
+ <%= f.submit "Start Run", class: "prospector-btn prospector-btn-primary" %>
106
+ <%= link_to "Cancel", runs_path, class: "prospector-btn prospector-btn-outline" %>
107
+ </div>
108
+ <% end %>
109
+ </div>