Skip to content

Commit 6b7f1df

Browse files
committed
feat(api): Add complete headless JS client API support
P0 - Critical: - Add Favorites API (CRUD + check) at /api_public/v1/favorites - Add Saved Searches API (CRUD + verify/unsubscribe) at /api_public/v1/saved_searches - Token-based authentication for anonymous access P1 - High Priority: - Add Locales endpoint for i18n and hreflang tags - Add Cacheable concern with ETag/Cache-Control helpers - Add page parts support via include_parts param on Pages API - Add caching to theme, site_details, translations, search_config P2 - Medium Priority: - Add Search Facets endpoint for lightweight filter counts - Add JSON-LD schema endpoint for SEO P3 - Nice to Have: - Add map_config to theme response - Add analytics config to site_details response New files: - app/controllers/api_public/v1/favorites_controller.rb - app/controllers/api_public/v1/saved_searches_controller.rb - app/controllers/api_public/v1/locales_controller.rb - app/controllers/api_public/v1/search_facets_controller.rb - app/controllers/concerns/api_public/cacheable.rb - docs/frontend/BACKEND_WORK_FOR_JS_CLIENTS.md - docs/frontend/public_frontend_functionality.md - spec/requests/api_public/v1/favorites_spec.rb - spec/requests/api_public/v1/saved_searches_spec.rb - spec/requests/api_public/v1/locales_spec.rb - spec/requests/api_public/v1/search_facets_spec.rb Modified files: - config/routes.rb (new API routes) - app/controllers/api_public/v1/properties_controller.rb (caching + schema) - app/controllers/api_public/v1/theme_controller.rb (caching + map_config) - app/controllers/api_public/v1/site_details_controller.rb (caching + analytics) - app/controllers/api_public/v1/translations_controller.rb (caching) - app/controllers/api_public/v1/search_config_controller.rb (caching) - app/controllers/api_public/v1/pages_controller.rb (caching + page_parts)
1 parent 5739852 commit 6b7f1df

18 files changed

+2538
-30
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
# Public API for managing saved/favorited properties
6+
# Users access this via token-based authentication (no login required)
7+
class FavoritesController < BaseController
8+
before_action :set_favorite_by_token, only: %i[show update destroy]
9+
before_action :set_favorites_by_manage_token, only: [:index]
10+
11+
# GET /api_public/v1/favorites?token=XXX
12+
# List all favorites for email associated with token
13+
def index
14+
render json: {
15+
email: @favorites.first&.email,
16+
favorites: @favorites.map { |f| favorite_json(f) }
17+
}
18+
end
19+
20+
# GET /api_public/v1/favorites/:id?token=XXX
21+
def show
22+
render json: favorite_json(@favorite)
23+
end
24+
25+
# POST /api_public/v1/favorites
26+
# Create a new favorite
27+
# Body: { favorite: { email, provider, external_reference, property_data?, notes? } }
28+
def create
29+
favorite = build_favorite
30+
31+
if favorite.save
32+
render json: {
33+
success: true,
34+
favorite: favorite_json(favorite),
35+
manage_token: favorite.manage_token,
36+
manage_url: favorites_manage_url(favorite.manage_token)
37+
}, status: :created
38+
else
39+
render json: { success: false, errors: favorite.errors.full_messages },
40+
status: :unprocessable_content
41+
end
42+
end
43+
44+
# PATCH /api_public/v1/favorites/:id?token=XXX
45+
# Update notes
46+
def update
47+
if @favorite.update(favorite_update_params)
48+
render json: { success: true, favorite: favorite_json(@favorite) }
49+
else
50+
render json: { success: false, errors: @favorite.errors.full_messages },
51+
status: :unprocessable_content
52+
end
53+
end
54+
55+
# DELETE /api_public/v1/favorites/:id?token=XXX
56+
def destroy
57+
@favorite.destroy
58+
render json: { success: true }
59+
end
60+
61+
# POST /api_public/v1/favorites/check
62+
# Check which references are already saved for an email
63+
# Body: { email, references: [] }
64+
def check
65+
email = params[:email].to_s.downcase.strip
66+
references = Array(params[:references])
67+
68+
saved = saved_property_class
69+
.for_email(email)
70+
.where(external_reference: references)
71+
.pluck(:external_reference)
72+
73+
render json: { saved: saved }
74+
end
75+
76+
private
77+
78+
def set_favorite_by_token
79+
@favorite = saved_property_class.find_by(manage_token: params[:token])
80+
@favorite ||= saved_property_class.find_by(id: params[:id], manage_token: params[:token])
81+
82+
return if @favorite
83+
84+
render json: { error: "Invalid token" }, status: :unauthorized
85+
end
86+
87+
def set_favorites_by_manage_token
88+
sample = saved_property_class.find_by(manage_token: params[:token])
89+
90+
unless sample
91+
render json: { error: "Invalid token" }, status: :unauthorized
92+
return
93+
end
94+
95+
@favorites = saved_property_class.for_email(sample.email).recent
96+
end
97+
98+
def build_favorite
99+
favorite = saved_property_class.new(favorite_params)
100+
favorite.website = Pwb::Current.website
101+
102+
# Fetch and cache property data if not provided
103+
fetch_and_cache_property_data(favorite) if favorite.property_data.blank? && favorite.external_reference.present?
104+
105+
favorite
106+
end
107+
108+
def fetch_and_cache_property_data(favorite)
109+
# For internal properties, fetch from database
110+
return unless favorite.provider == "internal"
111+
112+
property = Pwb::Current.website.listed_properties.find_by(
113+
id: favorite.external_reference
114+
) || Pwb::Current.website.listed_properties.find_by(
115+
slug: favorite.external_reference
116+
)
117+
118+
return unless property
119+
120+
favorite.property_data = {
121+
title: property.title,
122+
price: property.price_hash,
123+
image_url: property.primary_image_url,
124+
property_url: "/properties/#{property.slug}",
125+
bedrooms: property.count_bedrooms,
126+
bathrooms: property.count_bathrooms,
127+
area: property.plot_area
128+
}
129+
favorite.original_price_cents = property.price_cents
130+
favorite.current_price_cents = property.price_cents
131+
132+
# External properties would use the feed API - handled elsewhere
133+
end
134+
135+
def favorite_params
136+
params.require(:favorite).permit(
137+
:email, :provider, :external_reference, :notes,
138+
property_data: {}
139+
)
140+
end
141+
142+
def favorite_update_params
143+
params.require(:favorite).permit(:notes)
144+
end
145+
146+
def favorite_json(fav)
147+
{
148+
id: fav.id,
149+
email: fav.email,
150+
provider: fav.provider,
151+
external_reference: fav.external_reference,
152+
notes: fav.notes,
153+
title: fav.title,
154+
price: fav.price,
155+
price_formatted: fav.price_formatted,
156+
image_url: fav.image_url,
157+
property_url: fav.property_url,
158+
original_price_cents: fav.original_price_cents,
159+
current_price_cents: fav.current_price_cents,
160+
price_changed: fav.price_changed_at.present?,
161+
price_changed_at: fav.price_changed_at,
162+
created_at: fav.created_at,
163+
manage_token: fav.manage_token
164+
}
165+
end
166+
167+
def favorites_manage_url(token)
168+
"#{request.protocol}#{request.host_with_port}/my/favorites?token=#{token}"
169+
end
170+
171+
def saved_property_class
172+
# Use tenant-specific model if available, otherwise fall back to Pwb
173+
if defined?(PwbTenant::SavedProperty)
174+
PwbTenant::SavedProperty
175+
else
176+
Pwb::SavedProperty
177+
end
178+
end
179+
end
180+
end
181+
end
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
# Public API endpoint for available locales/languages
6+
# Returns enabled locales for the current website for language switchers and hreflang
7+
class LocalesController < BaseController
8+
# GET /api_public/v1/locales
9+
def index
10+
website = Pwb::Current.website
11+
12+
render json: {
13+
default_locale: default_locale(website),
14+
available_locales: available_locales(website),
15+
current_locale: I18n.locale.to_s
16+
}
17+
end
18+
19+
private
20+
21+
def default_locale(website)
22+
if website.respond_to?(:default_locale) && website.default_locale.present?
23+
website.default_locale
24+
else
25+
I18n.default_locale.to_s
26+
end
27+
end
28+
29+
def available_locales(website)
30+
# Website may have enabled_locales field, otherwise fall back to app defaults
31+
locales = if website.respond_to?(:enabled_locales) && website.enabled_locales.present?
32+
website.enabled_locales
33+
else
34+
%w[en es de fr nl pt it]
35+
end
36+
37+
locales.map do |code|
38+
{
39+
code: code,
40+
name: locale_name(code),
41+
native_name: locale_native_name(code),
42+
flag_emoji: locale_flag(code),
43+
url_prefix: code == default_locale(website) ? nil : "/#{code}"
44+
}
45+
end
46+
end
47+
48+
def locale_name(code)
49+
LOCALE_NAMES[code] || code.upcase
50+
end
51+
52+
def locale_native_name(code)
53+
LOCALE_NATIVE_NAMES[code] || code.upcase
54+
end
55+
56+
def locale_flag(code)
57+
LOCALE_FLAGS[code] || "🏳️"
58+
end
59+
60+
LOCALE_NAMES = {
61+
"en" => "English",
62+
"es" => "Spanish",
63+
"de" => "German",
64+
"fr" => "French",
65+
"nl" => "Dutch",
66+
"pt" => "Portuguese",
67+
"it" => "Italian",
68+
"ru" => "Russian",
69+
"zh" => "Chinese",
70+
"ja" => "Japanese",
71+
"ar" => "Arabic",
72+
"pl" => "Polish",
73+
"sv" => "Swedish",
74+
"no" => "Norwegian",
75+
"da" => "Danish",
76+
"fi" => "Finnish"
77+
}.freeze
78+
79+
LOCALE_NATIVE_NAMES = {
80+
"en" => "English",
81+
"es" => "Español",
82+
"de" => "Deutsch",
83+
"fr" => "Français",
84+
"nl" => "Nederlands",
85+
"pt" => "Português",
86+
"it" => "Italiano",
87+
"ru" => "Русский",
88+
"zh" => "中文",
89+
"ja" => "日本語",
90+
"ar" => "العربية",
91+
"pl" => "Polski",
92+
"sv" => "Svenska",
93+
"no" => "Norsk",
94+
"da" => "Dansk",
95+
"fi" => "Suomi"
96+
}.freeze
97+
98+
LOCALE_FLAGS = {
99+
"en" => "🇬🇧",
100+
"es" => "🇪🇸",
101+
"de" => "🇩🇪",
102+
"fr" => "🇫🇷",
103+
"nl" => "🇳🇱",
104+
"pt" => "🇵🇹",
105+
"it" => "🇮🇹",
106+
"ru" => "🇷🇺",
107+
"zh" => "🇨🇳",
108+
"ja" => "🇯🇵",
109+
"ar" => "🇸🇦",
110+
"pl" => "🇵🇱",
111+
"sv" => "🇸🇪",
112+
"no" => "🇳🇴",
113+
"da" => "🇩🇰",
114+
"fi" => "🇫🇮"
115+
}.freeze
116+
end
117+
end
118+
end

0 commit comments

Comments
 (0)