feat(marketing): add product hunt badge (#10)
This commit is contained in:
parent
a7186cd700
commit
3dec3895d5
@ -15,6 +15,7 @@
|
|||||||
//// You should have received a copy of the GNU Affero General Public License
|
//// You should have received a copy of the GNU Affero General Public License
|
||||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import fluxer_marketing/badge_proxy
|
||||||
import fluxer_marketing/config
|
import fluxer_marketing/config
|
||||||
import fluxer_marketing/geoip
|
import fluxer_marketing/geoip
|
||||||
import fluxer_marketing/i18n
|
import fluxer_marketing/i18n
|
||||||
@ -24,7 +25,6 @@ import fluxer_marketing/middleware/cache_middleware
|
|||||||
import fluxer_marketing/router
|
import fluxer_marketing/router
|
||||||
import fluxer_marketing/visionary_slots
|
import fluxer_marketing/visionary_slots
|
||||||
import fluxer_marketing/web
|
import fluxer_marketing/web
|
||||||
import gleam/erlang/atom.{type Atom}
|
|
||||||
import gleam/erlang/process
|
import gleam/erlang/process
|
||||||
import gleam/http/request
|
import gleam/http/request
|
||||||
import gleam/list
|
import gleam/list
|
||||||
@ -40,15 +40,18 @@ pub fn main() {
|
|||||||
let assert Ok(cfg) = config.load_config()
|
let assert Ok(cfg) = config.load_config()
|
||||||
|
|
||||||
let i18n_db = i18n.setup_database()
|
let i18n_db = i18n.setup_database()
|
||||||
|
|
||||||
let slots_cache =
|
let slots_cache =
|
||||||
visionary_slots.start(visionary_slots.Settings(
|
visionary_slots.start(visionary_slots.Settings(
|
||||||
api_host: cfg.api_host,
|
api_host: cfg.api_host,
|
||||||
rpc_secret: cfg.gateway_rpc_secret,
|
rpc_secret: cfg.gateway_rpc_secret,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
let badge_cache = badge_proxy.start_cache()
|
||||||
|
|
||||||
let assert Ok(_) =
|
let assert Ok(_) =
|
||||||
wisp_mist.handler(
|
wisp_mist.handler(
|
||||||
handle_request(_, i18n_db, cfg, slots_cache),
|
handle_request(_, i18n_db, cfg, slots_cache, badge_cache),
|
||||||
cfg.secret_key_base,
|
cfg.secret_key_base,
|
||||||
)
|
)
|
||||||
|> mist.new
|
|> mist.new
|
||||||
@ -64,11 +67,11 @@ 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,
|
||||||
) -> wisp.Response {
|
) -> wisp.Response {
|
||||||
let locale = get_request_locale(req)
|
let locale = get_request_locale(req)
|
||||||
|
|
||||||
let base_url = cfg.marketing_endpoint <> cfg.base_path
|
let base_url = cfg.marketing_endpoint <> cfg.base_path
|
||||||
|
|
||||||
let country_code = geoip.country_code(req, cfg.geoip_host)
|
let country_code = geoip.country_code(req, cfg.geoip_host)
|
||||||
|
|
||||||
let user_agent = case request.get_header(req, "user-agent") {
|
let user_agent = case request.get_header(req, "user-agent") {
|
||||||
@ -97,6 +100,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
use <- wisp.log_request(req)
|
use <- wisp.log_request(req)
|
||||||
@ -116,18 +120,21 @@ fn handle_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let duration = monotonic_milliseconds() - start
|
let duration = monotonic_milliseconds() - start
|
||||||
|
|
||||||
metrics.track_request(ctx, req, response.status, duration)
|
metrics.track_request(ctx, req, response.status, duration)
|
||||||
|
|
||||||
response |> cache_middleware.add_cache_headers
|
response |> cache_middleware.add_cache_headers
|
||||||
}
|
}
|
||||||
|
|
||||||
fn monotonic_milliseconds() -> Int {
|
type TimeUnit {
|
||||||
do_monotonic_time(atom.create("millisecond"))
|
Millisecond
|
||||||
}
|
}
|
||||||
|
|
||||||
@external(erlang, "erlang", "monotonic_time")
|
@external(erlang, "erlang", "monotonic_time")
|
||||||
fn do_monotonic_time(unit: Atom) -> Int
|
fn erlang_monotonic_time(unit: TimeUnit) -> Int
|
||||||
|
|
||||||
|
fn monotonic_milliseconds() -> Int {
|
||||||
|
erlang_monotonic_time(Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
fn get_request_locale(req: wisp.Request) -> Locale {
|
fn get_request_locale(req: wisp.Request) -> Locale {
|
||||||
case wisp.get_cookie(req, "locale", wisp.PlainText) {
|
case wisp.get_cookie(req, "locale", wisp.PlainText) {
|
||||||
|
|||||||
215
fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam
Normal file
215
fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
//// Copyright (C) 2026 Fluxer Contributors
|
||||||
|
////
|
||||||
|
//// This file is part of Fluxer.
|
||||||
|
////
|
||||||
|
//// Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
//// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
//// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
//// (at your option) any later version.
|
||||||
|
////
|
||||||
|
//// Fluxer is distributed in the hope that it will be useful,
|
||||||
|
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
//// GNU Affero General Public License for more details.
|
||||||
|
////
|
||||||
|
//// You should have received a copy of the GNU Affero General Public License
|
||||||
|
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import gleam/erlang/process
|
||||||
|
import gleam/http/request
|
||||||
|
import gleam/httpc
|
||||||
|
import gleam/option.{type Option}
|
||||||
|
import wisp.{type Response}
|
||||||
|
|
||||||
|
const product_hunt_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light"
|
||||||
|
|
||||||
|
const stale_after_ms = 300_000
|
||||||
|
|
||||||
|
const receive_timeout_ms = 5000
|
||||||
|
|
||||||
|
const fetch_timeout_ms = 4500
|
||||||
|
|
||||||
|
pub opaque type Cache {
|
||||||
|
Cache(subject: process.Subject(ServerMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerMessage {
|
||||||
|
Get(process.Subject(Option(String)))
|
||||||
|
RefreshDone(fetched_at: Int, svg: Option(String))
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheEntry {
|
||||||
|
CacheEntry(svg: String, fetched_at: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
type State {
|
||||||
|
State(cache: Option(CacheEntry), is_refreshing: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_cache() -> Cache {
|
||||||
|
let started = process.new_subject()
|
||||||
|
let _ = process.spawn_unlinked(fn() { run(started) })
|
||||||
|
|
||||||
|
let assert Ok(subject) = process.receive(started, within: 1000)
|
||||||
|
Cache(subject: subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(started: process.Subject(process.Subject(ServerMessage))) {
|
||||||
|
let subject = process.new_subject()
|
||||||
|
process.send(started, subject)
|
||||||
|
|
||||||
|
let initial = State(cache: option.None, is_refreshing: False)
|
||||||
|
loop(subject, initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loop(subject: process.Subject(ServerMessage), state: State) {
|
||||||
|
let new_state = case process.receive(subject, within: stale_after_ms) {
|
||||||
|
Ok(Get(reply_to)) -> handle_get(subject, reply_to, state)
|
||||||
|
|
||||||
|
Ok(RefreshDone(fetched_at, svg)) ->
|
||||||
|
handle_refresh_done(fetched_at, svg, state)
|
||||||
|
|
||||||
|
Error(_) -> maybe_refresh_in_background(subject, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
loop(subject, new_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_get(
|
||||||
|
subject: process.Subject(ServerMessage),
|
||||||
|
reply_to: process.Subject(Option(String)),
|
||||||
|
state: State,
|
||||||
|
) -> State {
|
||||||
|
let now = monotonic_time_ms()
|
||||||
|
|
||||||
|
case state.cache {
|
||||||
|
option.None -> {
|
||||||
|
let svg = fetch_badge_svg()
|
||||||
|
process.send(reply_to, svg)
|
||||||
|
|
||||||
|
let new_cache = case svg {
|
||||||
|
option.Some(content) ->
|
||||||
|
option.Some(CacheEntry(svg: content, fetched_at: now))
|
||||||
|
option.None -> option.None
|
||||||
|
}
|
||||||
|
|
||||||
|
State(cache: new_cache, is_refreshing: False)
|
||||||
|
}
|
||||||
|
|
||||||
|
option.Some(entry) -> {
|
||||||
|
let is_stale = now - entry.fetched_at > stale_after_ms
|
||||||
|
|
||||||
|
process.send(reply_to, option.Some(entry.svg))
|
||||||
|
|
||||||
|
case is_stale && !state.is_refreshing {
|
||||||
|
True -> {
|
||||||
|
spawn_refresh(subject)
|
||||||
|
State(..state, is_refreshing: True)
|
||||||
|
}
|
||||||
|
False -> state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_refresh_done(
|
||||||
|
fetched_at: Int,
|
||||||
|
svg: Option(String),
|
||||||
|
state: State,
|
||||||
|
) -> State {
|
||||||
|
let new_cache = case svg {
|
||||||
|
option.Some(content) ->
|
||||||
|
option.Some(CacheEntry(svg: content, fetched_at: fetched_at))
|
||||||
|
option.None -> state.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
State(cache: new_cache, is_refreshing: False)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_refresh_in_background(
|
||||||
|
subject: process.Subject(ServerMessage),
|
||||||
|
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)
|
||||||
|
State(..state, is_refreshing: True)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ -> state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_refresh(subject: process.Subject(ServerMessage)) {
|
||||||
|
let _ =
|
||||||
|
process.spawn_unlinked(fn() {
|
||||||
|
let fetched_at = monotonic_time_ms()
|
||||||
|
let svg = fetch_badge_svg()
|
||||||
|
process.send(subject, RefreshDone(fetched_at, svg))
|
||||||
|
})
|
||||||
|
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_badge(cache: Cache) -> Option(String) {
|
||||||
|
let reply_to = process.new_subject()
|
||||||
|
process.send(cache.subject, Get(reply_to))
|
||||||
|
|
||||||
|
case process.receive(reply_to, within: receive_timeout_ms) {
|
||||||
|
Ok(svg) -> svg
|
||||||
|
Error(_) -> option.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn product_hunt(cache: Cache) -> Response {
|
||||||
|
case get_badge(cache) {
|
||||||
|
option.Some(content) -> {
|
||||||
|
wisp.response(200)
|
||||||
|
|> wisp.set_header("content-type", "image/svg+xml")
|
||||||
|
|> wisp.set_header(
|
||||||
|
"cache-control",
|
||||||
|
"public, max-age=300, stale-while-revalidate=600",
|
||||||
|
)
|
||||||
|
|> wisp.set_header("vary", "Accept")
|
||||||
|
|> wisp.string_body(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
option.None -> {
|
||||||
|
wisp.response(503)
|
||||||
|
|> wisp.set_header("content-type", "text/plain")
|
||||||
|
|> wisp.set_header("retry-after", "60")
|
||||||
|
|> wisp.string_body("Badge temporarily unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_badge_svg() -> Option(String) {
|
||||||
|
let assert Ok(req0) = request.to(product_hunt_url)
|
||||||
|
let req =
|
||||||
|
req0
|
||||||
|
|> request.prepend_header("accept", "image/svg+xml")
|
||||||
|
|> request.prepend_header("user-agent", "FluxerMarketing/1.0")
|
||||||
|
|
||||||
|
let config =
|
||||||
|
httpc.configure()
|
||||||
|
|> httpc.timeout(fetch_timeout_ms)
|
||||||
|
|
||||||
|
case httpc.dispatch(config, req) {
|
||||||
|
Ok(resp) if resp.status >= 200 && resp.status < 300 ->
|
||||||
|
option.Some(resp.body)
|
||||||
|
_ -> option.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeUnit {
|
||||||
|
Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(erlang, "erlang", "monotonic_time")
|
||||||
|
fn erlang_monotonic_time(unit: TimeUnit) -> Int
|
||||||
|
|
||||||
|
fn monotonic_time_ms() -> Int {
|
||||||
|
erlang_monotonic_time(Millisecond)
|
||||||
|
}
|
||||||
@ -22,40 +22,6 @@ import lustre/attribute
|
|||||||
import lustre/element.{type Element}
|
import lustre/element.{type Element}
|
||||||
import lustre/element/html
|
import lustre/element/html
|
||||||
|
|
||||||
fn hn_logo() -> Element(a) {
|
|
||||||
element.element(
|
|
||||||
"svg",
|
|
||||||
[
|
|
||||||
attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
|
||||||
attribute.attribute("viewBox", "4 4 188 188"),
|
|
||||||
attribute.attribute("width", "20"),
|
|
||||||
attribute.attribute("height", "20"),
|
|
||||||
attribute.class("block flex-shrink-0 rounded-[3px]"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
element.element(
|
|
||||||
"path",
|
|
||||||
[
|
|
||||||
attribute.attribute("d", "m4 4h188v188h-188z"),
|
|
||||||
attribute.attribute("fill", "#f60"),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
element.element(
|
|
||||||
"path",
|
|
||||||
[
|
|
||||||
attribute.attribute(
|
|
||||||
"d",
|
|
||||||
"m73.2521756 45.01 22.7478244 47.39130083 22.7478244-47.39130083h19.56569631l-34.32352071 64.48661468v41.49338532h-15.98v-41.49338532l-34.32352071-64.48661468z",
|
|
||||||
),
|
|
||||||
attribute.attribute("fill", "#fff"),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(ctx: Context) -> Element(a) {
|
pub fn render(ctx: Context) -> Element(a) {
|
||||||
let i18n_ctx = i18n.get_context(ctx.i18n_db, ctx.locale)
|
let i18n_ctx = i18n.get_context(ctx.i18n_db, ctx.locale)
|
||||||
|
|
||||||
@ -66,12 +32,7 @@ pub fn render(ctx: Context) -> Element(a) {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
hn_logo(),
|
|
||||||
html.p([attribute.class("text-sm md:text-base text-white/90")], [
|
html.p([attribute.class("text-sm md:text-base text-white/90")], [
|
||||||
html.span([attribute.class("font-medium")], [
|
|
||||||
html.text(g_(i18n_ctx, "From HN?")),
|
|
||||||
html.text(" "),
|
|
||||||
]),
|
|
||||||
html.text(g_(i18n_ctx, "Try it without an email at")),
|
html.text(g_(i18n_ctx, "Try it without an email at")),
|
||||||
html.text(" "),
|
html.text(" "),
|
||||||
html.a(
|
html.a(
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import fluxer_marketing/components/hackernews_banner
|
|||||||
import fluxer_marketing/components/platform_download_button
|
import fluxer_marketing/components/platform_download_button
|
||||||
import fluxer_marketing/i18n
|
import fluxer_marketing/i18n
|
||||||
import fluxer_marketing/locale
|
import fluxer_marketing/locale
|
||||||
import fluxer_marketing/web.{type Context}
|
import fluxer_marketing/web.{type Context, prepend_base_path}
|
||||||
import kielet.{gettext as g_}
|
import kielet.{gettext as g_}
|
||||||
import lustre/attribute
|
import lustre/attribute
|
||||||
import lustre/element.{type Element}
|
import lustre/element.{type Element}
|
||||||
@ -84,6 +84,27 @@ 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.a(
|
||||||
|
[
|
||||||
|
attribute.href(
|
||||||
|
"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.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.div(
|
html.div(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -22,16 +22,21 @@ import gleam/string
|
|||||||
import wisp
|
import wisp
|
||||||
|
|
||||||
pub fn add_cache_headers(res: Response(wisp.Body)) -> Response(wisp.Body) {
|
pub fn add_cache_headers(res: Response(wisp.Body)) -> Response(wisp.Body) {
|
||||||
let content_type =
|
case list.key_find(res.headers, "cache-control") {
|
||||||
list.key_find(res.headers, "content-type")
|
Ok(_) -> res
|
||||||
|> result.unwrap("")
|
Error(_) -> {
|
||||||
|
let content_type =
|
||||||
|
list.key_find(res.headers, "content-type")
|
||||||
|
|> result.unwrap("")
|
||||||
|
|
||||||
let cache_header = case should_cache(content_type) {
|
let cache_header = case should_cache(content_type) {
|
||||||
True -> "public, max-age=31536000, immutable"
|
True -> "public, max-age=31536000, immutable"
|
||||||
False -> "no-cache"
|
False -> "no-cache"
|
||||||
|
}
|
||||||
|
|
||||||
|
response.set_header(res, "cache-control", cache_header)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.set_header(res, "cache-control", cache_header)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_cache(content_type: String) -> Bool {
|
fn should_cache(content_type: String) -> Bool {
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
//// You should have received a copy of the GNU Affero General Public License
|
//// You should have received a copy of the GNU Affero General Public License
|
||||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import fluxer_marketing/badge_proxy
|
||||||
import fluxer_marketing/geoip
|
import fluxer_marketing/geoip
|
||||||
import fluxer_marketing/help_center
|
import fluxer_marketing/help_center
|
||||||
import fluxer_marketing/locale
|
import fluxer_marketing/locale
|
||||||
@ -48,6 +49,10 @@ pub fn handle_request(req: Request, ctx: Context) -> Response {
|
|||||||
[] -> home_page.render(req, ctx)
|
[] -> home_page.render(req, ctx)
|
||||||
["_locale"] -> handle_locale_change(req, ctx)
|
["_locale"] -> handle_locale_change(req, ctx)
|
||||||
["_debug", "geoip"] -> handle_geoip_debug(req, ctx)
|
["_debug", "geoip"] -> handle_geoip_debug(req, ctx)
|
||||||
|
|
||||||
|
["api", "badges", "product-hunt"] ->
|
||||||
|
badge_proxy.product_hunt(ctx.badge_cache)
|
||||||
|
|
||||||
["robots.txt"] -> handle_robots_txt()
|
["robots.txt"] -> handle_robots_txt()
|
||||||
["sitemap.xml"] -> handle_sitemap(ctx)
|
["sitemap.xml"] -> handle_sitemap(ctx)
|
||||||
["terms"] -> terms_page.render(req, ctx)
|
["terms"] -> terms_page.render(req, ctx)
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
//// You should have received a copy of the GNU Affero General Public License
|
//// You should have received a copy of the GNU Affero General Public License
|
||||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import fluxer_marketing/badge_proxy
|
||||||
import fluxer_marketing/locale.{type Locale}
|
import fluxer_marketing/locale.{type Locale}
|
||||||
import fluxer_marketing/visionary_slots.{type VisionarySlots}
|
import fluxer_marketing/visionary_slots.{type VisionarySlots}
|
||||||
import gleam/option.{type Option}
|
import gleam/option.{type Option}
|
||||||
@ -55,6 +56,7 @@ 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user