feat(marketing): add product of the day badge (#17)
This commit is contained in:
parent
bb4ab2bcaa
commit
e191cfb15e
@ -47,11 +47,21 @@ pub fn main() {
|
|||||||
rpc_secret: cfg.gateway_rpc_secret,
|
rpc_secret: cfg.gateway_rpc_secret,
|
||||||
))
|
))
|
||||||
|
|
||||||
let badge_cache = badge_proxy.start_cache()
|
let badge_featured_cache =
|
||||||
|
badge_proxy.start_cache(badge_proxy.product_hunt_featured_url)
|
||||||
|
let badge_top_post_cache =
|
||||||
|
badge_proxy.start_cache(badge_proxy.product_hunt_top_post_url)
|
||||||
|
|
||||||
let assert Ok(_) =
|
let assert Ok(_) =
|
||||||
wisp_mist.handler(
|
wisp_mist.handler(
|
||||||
handle_request(_, i18n_db, cfg, slots_cache, badge_cache),
|
handle_request(
|
||||||
|
_,
|
||||||
|
i18n_db,
|
||||||
|
cfg,
|
||||||
|
slots_cache,
|
||||||
|
badge_featured_cache,
|
||||||
|
badge_top_post_cache,
|
||||||
|
),
|
||||||
cfg.secret_key_base,
|
cfg.secret_key_base,
|
||||||
)
|
)
|
||||||
|> mist.new
|
|> mist.new
|
||||||
@ -67,7 +77,8 @@ fn handle_request(
|
|||||||
i18n_db,
|
i18n_db,
|
||||||
cfg: config.Config,
|
cfg: config.Config,
|
||||||
slots_cache: visionary_slots.Cache,
|
slots_cache: visionary_slots.Cache,
|
||||||
badge_cache: badge_proxy.Cache,
|
badge_featured_cache: badge_proxy.Cache,
|
||||||
|
badge_top_post_cache: badge_proxy.Cache,
|
||||||
) -> wisp.Response {
|
) -> wisp.Response {
|
||||||
let locale = get_request_locale(req)
|
let locale = get_request_locale(req)
|
||||||
|
|
||||||
@ -100,7 +111,8 @@ fn handle_request(
|
|||||||
release_channel: cfg.release_channel,
|
release_channel: cfg.release_channel,
|
||||||
visionary_slots: visionary_slots.current(slots_cache),
|
visionary_slots: visionary_slots.current(slots_cache),
|
||||||
metrics_endpoint: cfg.metrics_endpoint,
|
metrics_endpoint: cfg.metrics_endpoint,
|
||||||
badge_cache: badge_cache,
|
badge_featured_cache: badge_featured_cache,
|
||||||
|
badge_top_post_cache: badge_top_post_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
use <- wisp.log_request(req)
|
use <- wisp.log_request(req)
|
||||||
|
|||||||
@ -21,7 +21,9 @@ import gleam/httpc
|
|||||||
import gleam/option.{type Option}
|
import gleam/option.{type Option}
|
||||||
import wisp.{type Response}
|
import wisp.{type Response}
|
||||||
|
|
||||||
const product_hunt_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light"
|
pub const product_hunt_featured_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light"
|
||||||
|
|
||||||
|
pub const product_hunt_top_post_url = "https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1057558&theme=light&period=daily&t=1767529639613"
|
||||||
|
|
||||||
const stale_after_ms = 300_000
|
const stale_after_ms = 300_000
|
||||||
|
|
||||||
@ -46,45 +48,46 @@ type State {
|
|||||||
State(cache: Option(CacheEntry), is_refreshing: Bool)
|
State(cache: Option(CacheEntry), is_refreshing: Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_cache() -> Cache {
|
pub fn start_cache(url: String) -> Cache {
|
||||||
let started = process.new_subject()
|
let started = process.new_subject()
|
||||||
let _ = process.spawn_unlinked(fn() { run(started) })
|
let _ = process.spawn_unlinked(fn() { run(started, url) })
|
||||||
|
|
||||||
let assert Ok(subject) = process.receive(started, within: 1000)
|
let assert Ok(subject) = process.receive(started, within: 1000)
|
||||||
Cache(subject: subject)
|
Cache(subject: subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(started: process.Subject(process.Subject(ServerMessage))) {
|
fn run(started: process.Subject(process.Subject(ServerMessage)), url: String) {
|
||||||
let subject = process.new_subject()
|
let subject = process.new_subject()
|
||||||
process.send(started, subject)
|
process.send(started, subject)
|
||||||
|
|
||||||
let initial = State(cache: option.None, is_refreshing: False)
|
let initial = State(cache: option.None, is_refreshing: False)
|
||||||
loop(subject, initial)
|
loop(subject, url, initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn loop(subject: process.Subject(ServerMessage), state: State) {
|
fn loop(subject: process.Subject(ServerMessage), url: String, state: State) {
|
||||||
let new_state = case process.receive(subject, within: stale_after_ms) {
|
let new_state = case process.receive(subject, within: stale_after_ms) {
|
||||||
Ok(Get(reply_to)) -> handle_get(subject, reply_to, state)
|
Ok(Get(reply_to)) -> handle_get(subject, reply_to, url, state)
|
||||||
|
|
||||||
Ok(RefreshDone(fetched_at, svg)) ->
|
Ok(RefreshDone(fetched_at, svg)) ->
|
||||||
handle_refresh_done(fetched_at, svg, state)
|
handle_refresh_done(fetched_at, svg, state)
|
||||||
|
|
||||||
Error(_) -> maybe_refresh_in_background(subject, state)
|
Error(_) -> maybe_refresh_in_background(subject, url, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
loop(subject, new_state)
|
loop(subject, url, new_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_get(
|
fn handle_get(
|
||||||
subject: process.Subject(ServerMessage),
|
subject: process.Subject(ServerMessage),
|
||||||
reply_to: process.Subject(Option(String)),
|
reply_to: process.Subject(Option(String)),
|
||||||
|
url: String,
|
||||||
state: State,
|
state: State,
|
||||||
) -> State {
|
) -> State {
|
||||||
let now = monotonic_time_ms()
|
let now = monotonic_time_ms()
|
||||||
|
|
||||||
case state.cache {
|
case state.cache {
|
||||||
option.None -> {
|
option.None -> {
|
||||||
let svg = fetch_badge_svg()
|
let svg = fetch_badge_svg(url)
|
||||||
process.send(reply_to, svg)
|
process.send(reply_to, svg)
|
||||||
|
|
||||||
let new_cache = case svg {
|
let new_cache = case svg {
|
||||||
@ -103,7 +106,7 @@ fn handle_get(
|
|||||||
|
|
||||||
case is_stale && !state.is_refreshing {
|
case is_stale && !state.is_refreshing {
|
||||||
True -> {
|
True -> {
|
||||||
spawn_refresh(subject)
|
spawn_refresh(subject, url)
|
||||||
State(..state, is_refreshing: True)
|
State(..state, is_refreshing: True)
|
||||||
}
|
}
|
||||||
False -> state
|
False -> state
|
||||||
@ -128,13 +131,14 @@ fn handle_refresh_done(
|
|||||||
|
|
||||||
fn maybe_refresh_in_background(
|
fn maybe_refresh_in_background(
|
||||||
subject: process.Subject(ServerMessage),
|
subject: process.Subject(ServerMessage),
|
||||||
|
url: String,
|
||||||
state: State,
|
state: State,
|
||||||
) -> State {
|
) -> State {
|
||||||
let now = monotonic_time_ms()
|
let now = monotonic_time_ms()
|
||||||
|
|
||||||
case state.cache, state.is_refreshing {
|
case state.cache, state.is_refreshing {
|
||||||
option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> {
|
option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> {
|
||||||
spawn_refresh(subject)
|
spawn_refresh(subject, url)
|
||||||
State(..state, is_refreshing: True)
|
State(..state, is_refreshing: True)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,11 +146,11 @@ fn maybe_refresh_in_background(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_refresh(subject: process.Subject(ServerMessage)) {
|
fn spawn_refresh(subject: process.Subject(ServerMessage), url: String) {
|
||||||
let _ =
|
let _ =
|
||||||
process.spawn_unlinked(fn() {
|
process.spawn_unlinked(fn() {
|
||||||
let fetched_at = monotonic_time_ms()
|
let fetched_at = monotonic_time_ms()
|
||||||
let svg = fetch_badge_svg()
|
let svg = fetch_badge_svg(url)
|
||||||
process.send(subject, RefreshDone(fetched_at, svg))
|
process.send(subject, RefreshDone(fetched_at, svg))
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -185,8 +189,8 @@ pub fn product_hunt(cache: Cache) -> Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_badge_svg() -> Option(String) {
|
fn fetch_badge_svg(url: String) -> Option(String) {
|
||||||
let assert Ok(req0) = request.to(product_hunt_url)
|
let assert Ok(req0) = request.to(url)
|
||||||
let req =
|
let req =
|
||||||
req0
|
req0
|
||||||
|> request.prepend_header("accept", "image/svg+xml")
|
|> request.prepend_header("accept", "image/svg+xml")
|
||||||
|
|||||||
@ -84,27 +84,59 @@ pub fn render(ctx: Context) -> Element(a) {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
hackernews_banner.render(ctx),
|
hackernews_banner.render(ctx),
|
||||||
html.div([attribute.class("mt-6 flex justify-center")], [
|
html.div(
|
||||||
html.a(
|
[
|
||||||
[
|
attribute.class(
|
||||||
attribute.href(
|
"mt-6 flex flex-wrap items-center justify-center gap-4",
|
||||||
"https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer",
|
),
|
||||||
),
|
],
|
||||||
attribute.target("_blank"),
|
[
|
||||||
attribute.attribute("rel", "noopener noreferrer"),
|
html.a(
|
||||||
],
|
[
|
||||||
[
|
attribute.href(
|
||||||
html.img([
|
"https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer",
|
||||||
attribute.alt(
|
|
||||||
"Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt",
|
|
||||||
),
|
),
|
||||||
attribute.attribute("width", "250"),
|
attribute.target("_blank"),
|
||||||
attribute.attribute("height", "54"),
|
attribute.attribute("rel", "noopener noreferrer"),
|
||||||
attribute.src(prepend_base_path(ctx, "/api/badges/product-hunt")),
|
],
|
||||||
]),
|
[
|
||||||
],
|
html.img([
|
||||||
),
|
attribute.alt(
|
||||||
]),
|
"Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt",
|
||||||
|
),
|
||||||
|
attribute.attribute("width", "250"),
|
||||||
|
attribute.attribute("height", "54"),
|
||||||
|
attribute.src(prepend_base_path(
|
||||||
|
ctx,
|
||||||
|
"/api/badges/product-hunt",
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.a(
|
||||||
|
[
|
||||||
|
attribute.href(
|
||||||
|
"https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-fluxer",
|
||||||
|
),
|
||||||
|
attribute.target("_blank"),
|
||||||
|
attribute.attribute("rel", "noopener noreferrer"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
html.img([
|
||||||
|
attribute.alt(
|
||||||
|
"Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt Top Post",
|
||||||
|
),
|
||||||
|
attribute.attribute("width", "250"),
|
||||||
|
attribute.attribute("height", "54"),
|
||||||
|
attribute.src(prepend_base_path(
|
||||||
|
ctx,
|
||||||
|
"/api/badges/product-hunt-top-post",
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
html.div(
|
html.div(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -51,7 +51,10 @@ pub fn handle_request(req: Request, ctx: Context) -> Response {
|
|||||||
["_debug", "geoip"] -> handle_geoip_debug(req, ctx)
|
["_debug", "geoip"] -> handle_geoip_debug(req, ctx)
|
||||||
|
|
||||||
["api", "badges", "product-hunt"] ->
|
["api", "badges", "product-hunt"] ->
|
||||||
badge_proxy.product_hunt(ctx.badge_cache)
|
badge_proxy.product_hunt(ctx.badge_featured_cache)
|
||||||
|
|
||||||
|
["api", "badges", "product-hunt-top-post"] ->
|
||||||
|
badge_proxy.product_hunt(ctx.badge_top_post_cache)
|
||||||
|
|
||||||
["robots.txt"] -> handle_robots_txt()
|
["robots.txt"] -> handle_robots_txt()
|
||||||
["sitemap.xml"] -> handle_sitemap(ctx)
|
["sitemap.xml"] -> handle_sitemap(ctx)
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import gleam/http/request
|
|||||||
import gleam/httpc
|
import gleam/httpc
|
||||||
import gleam/int
|
import gleam/int
|
||||||
import gleam/json
|
import gleam/json
|
||||||
|
import gleam/option
|
||||||
import gleam/result
|
import gleam/result
|
||||||
import gleam/string
|
import gleam/string
|
||||||
import wisp
|
import wisp
|
||||||
@ -40,9 +41,10 @@ pub opaque type Cache {
|
|||||||
|
|
||||||
type ServerMessage {
|
type ServerMessage {
|
||||||
Get(process.Subject(VisionarySlots))
|
Get(process.Subject(VisionarySlots))
|
||||||
|
RefreshDone(fetched_at: Int, slots: option.Option(VisionarySlots))
|
||||||
}
|
}
|
||||||
|
|
||||||
const refresh_interval_ms = 300_000
|
const stale_after_ms = 300_000
|
||||||
|
|
||||||
const receive_timeout_ms = 200
|
const receive_timeout_ms = 200
|
||||||
|
|
||||||
@ -60,30 +62,124 @@ pub fn start(settings: Settings) -> Cache {
|
|||||||
Cache(name: name)
|
Cache(name: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CacheEntry {
|
||||||
|
CacheEntry(slots: VisionarySlots, fetched_at: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
type State {
|
||||||
|
State(cache: option.Option(CacheEntry), is_refreshing: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
fn run(name: process.Name(ServerMessage), settings: Settings) {
|
fn run(name: process.Name(ServerMessage), settings: Settings) {
|
||||||
let _ = process.register(process.self(), name)
|
let _ = process.register(process.self(), name)
|
||||||
let subject = process.named_subject(name)
|
let subject = process.named_subject(name)
|
||||||
let initial = fetch_slots(settings) |> result.unwrap(default_slots())
|
let initial_slots = fetch_slots(settings) |> result.unwrap(default_slots())
|
||||||
loop(subject, settings, initial)
|
let now = monotonic_time_ms()
|
||||||
|
let initial_state =
|
||||||
|
State(
|
||||||
|
cache: option.Some(CacheEntry(slots: initial_slots, fetched_at: now)),
|
||||||
|
is_refreshing: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
loop(subject, settings, initial_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn loop(
|
fn loop(
|
||||||
subject: process.Subject(ServerMessage),
|
subject: process.Subject(ServerMessage),
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
state: VisionarySlots,
|
state: State,
|
||||||
) {
|
) {
|
||||||
case process.receive(subject, within: refresh_interval_ms) {
|
let new_state = case process.receive(subject, within: stale_after_ms) {
|
||||||
Ok(Get(reply_to)) -> {
|
Ok(Get(reply_to)) -> handle_get(subject, reply_to, settings, state)
|
||||||
process.send(reply_to, state)
|
|
||||||
loop(subject, settings, state)
|
Ok(RefreshDone(fetched_at, slots)) ->
|
||||||
|
handle_refresh_done(fetched_at, slots, state)
|
||||||
|
|
||||||
|
Error(_) -> maybe_refresh_in_background(subject, settings, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
loop(subject, settings, new_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_get(
|
||||||
|
subject: process.Subject(ServerMessage),
|
||||||
|
reply_to: process.Subject(VisionarySlots),
|
||||||
|
settings: Settings,
|
||||||
|
state: State,
|
||||||
|
) -> State {
|
||||||
|
let now = monotonic_time_ms()
|
||||||
|
|
||||||
|
case state.cache {
|
||||||
|
option.None -> {
|
||||||
|
let slots = fetch_slots(settings) |> result.unwrap(default_slots())
|
||||||
|
process.send(reply_to, slots)
|
||||||
|
|
||||||
|
let entry = CacheEntry(slots: slots, fetched_at: now)
|
||||||
|
|
||||||
|
State(cache: option.Some(entry), is_refreshing: False)
|
||||||
}
|
}
|
||||||
Error(_) -> {
|
|
||||||
let updated = fetch_slots(settings) |> result.unwrap(state)
|
option.Some(entry) -> {
|
||||||
loop(subject, settings, updated)
|
let is_stale = now - entry.fetched_at > stale_after_ms
|
||||||
|
process.send(reply_to, entry.slots)
|
||||||
|
|
||||||
|
case is_stale && !state.is_refreshing {
|
||||||
|
True -> {
|
||||||
|
spawn_refresh(subject, settings)
|
||||||
|
State(..state, is_refreshing: True)
|
||||||
|
}
|
||||||
|
False -> state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_refresh_done(
|
||||||
|
fetched_at: Int,
|
||||||
|
slots: option.Option(VisionarySlots),
|
||||||
|
state: State,
|
||||||
|
) -> State {
|
||||||
|
let new_cache = case slots {
|
||||||
|
option.Some(data) ->
|
||||||
|
option.Some(CacheEntry(slots: data, fetched_at: fetched_at))
|
||||||
|
option.None -> state.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
State(cache: new_cache, is_refreshing: False)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_refresh_in_background(
|
||||||
|
subject: process.Subject(ServerMessage),
|
||||||
|
settings: Settings,
|
||||||
|
state: State,
|
||||||
|
) -> State {
|
||||||
|
let now = monotonic_time_ms()
|
||||||
|
|
||||||
|
case state.cache, state.is_refreshing {
|
||||||
|
option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> {
|
||||||
|
spawn_refresh(subject, settings)
|
||||||
|
State(..state, is_refreshing: True)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ -> state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_refresh(subject: process.Subject(ServerMessage), settings: Settings) {
|
||||||
|
let _ =
|
||||||
|
process.spawn_unlinked(fn() {
|
||||||
|
let fetched_at = monotonic_time_ms()
|
||||||
|
let result = fetch_slots(settings)
|
||||||
|
let payload = case result {
|
||||||
|
Ok(slots) -> option.Some(slots)
|
||||||
|
Error(_) -> option.None
|
||||||
|
}
|
||||||
|
process.send(subject, RefreshDone(fetched_at, payload))
|
||||||
|
})
|
||||||
|
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
pub fn current(cache: Cache) -> VisionarySlots {
|
pub fn current(cache: Cache) -> VisionarySlots {
|
||||||
let subject = process.named_subject(cache.name)
|
let subject = process.named_subject(cache.name)
|
||||||
let reply_to = process.new_subject()
|
let reply_to = process.new_subject()
|
||||||
@ -368,3 +464,14 @@ fn rpc_url(api_host: String) -> String {
|
|||||||
fn default_slots() -> VisionarySlots {
|
fn default_slots() -> VisionarySlots {
|
||||||
VisionarySlots(total: 0, bought: 0, remaining: 0)
|
VisionarySlots(total: 0, bought: 0, remaining: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimeUnit {
|
||||||
|
Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(erlang, "erlang", "monotonic_time")
|
||||||
|
fn erlang_monotonic_time(unit: TimeUnit) -> Int
|
||||||
|
|
||||||
|
fn monotonic_time_ms() -> Int {
|
||||||
|
erlang_monotonic_time(Millisecond)
|
||||||
|
}
|
||||||
|
|||||||
@ -56,7 +56,8 @@ pub type Context {
|
|||||||
release_channel: String,
|
release_channel: String,
|
||||||
visionary_slots: VisionarySlots,
|
visionary_slots: VisionarySlots,
|
||||||
metrics_endpoint: Option(String),
|
metrics_endpoint: Option(String),
|
||||||
badge_cache: badge_proxy.Cache,
|
badge_featured_cache: badge_proxy.Cache,
|
||||||
|
badge_top_post_cache: badge_proxy.Cache,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user