<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jude Miracle</title>
    <description>The latest articles on DEV Community by Jude Miracle (@miraclejudeiv).</description>
    <link>https://dev.to/miraclejudeiv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F605848%2F9a11aaed-8335-4679-b06d-bbc0605bcc1d.jpg</url>
      <title>DEV Community: Jude Miracle</title>
      <link>https://dev.to/miraclejudeiv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/miraclejudeiv"/>
    <language>en</language>
    <item>
      <title>Building in public #6: I let AI judge every Ad on my platform. Here's what happened.</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Mon, 02 Mar 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/building-in-public-6-i-let-ai-judge-every-ad-on-my-platform-heres-what-happened-2hj7</link>
      <guid>https://dev.to/miraclejudeiv/building-in-public-6-i-let-ai-judge-every-ad-on-my-platform-heres-what-happened-2hj7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I use AI to score every ad on my platform. It is helpful most of the time, but sometimes it makes incorrect judgments.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This feature excites me, but it also makes me nervous. When a sponsor submits an ad through Adsloty, the ad is analyzed by AI before the writer sees it. The AI scores how well the ad fits the newsletter's audience, checks the tone, rates clarity, estimates clicks, and gives the writer specific recommendations.&lt;/p&gt;

&lt;p&gt;The idea is straightforward: writers should have data to determine if an ad is suitable for their newsletter. They shouldn't have to guess.&lt;/p&gt;

&lt;p&gt;However, the reality is more complicated than the idea. I will explain how I built this system, what the prompts look like, and what happens when the AI confidently suggests rejecting a suitable ad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI analysis at all?
&lt;/h2&gt;

&lt;p&gt;A writer receives an ad request for a software tool. The newsletter focuses on technology, which seems like a good match. However, the ad copy is unclear. The call to action says "Learn more," and the description mentions features that are hard to understand without background information.&lt;/p&gt;

&lt;p&gt;The writer must decide what to do with limited information. Some writers handle this well, but most struggle—they are writers, not ad salespeople. They either approve all ads (which can harm their audience) or reject anything that isn’t clearly suitable (which can hurt their revenue).&lt;/p&gt;

&lt;p&gt;I aimed to provide them with a starting point. Not a decision, but a place to begin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: Gemini 2.5 Flash
&lt;/h2&gt;

&lt;p&gt;I chose Google's Gemini 2.5 Flash. It’s not the biggest or smartest model, but it is fast, affordable, and good enough for structured analysis. &lt;/p&gt;

&lt;p&gt;I made this choice on purpose. This task is about classification and scoring, not creative writing, so I don’t need the most advanced model. Flash does a good job for this type of work, and saving money is important when analyzing every single booking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="s"&gt;"contents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="s"&gt;"parts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="s"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="s"&gt;"generationConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"maxOutputTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"responseMimeType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;responseMimeType: "application/json"&lt;/code&gt; is very important. It tells Gemini to give back data in a structured JSON format instead of regular text. If you don’t use it, you might get responses like "Based on my analysis, I believe this ad would score approximately..." This type of response is not useful for a system that needs to store numbers in a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt: smaller than you'd think
&lt;/h2&gt;

&lt;p&gt;Here's the actual prompt I'm sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;build_analysis_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ad_copy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ad_title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;subscriber_range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;
        &lt;span class="py"&gt;.subscriber_count_range&lt;/span&gt;
        &lt;span class="nf"&gt;.as_deref&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unknown"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;r#"Ad: "{}" - {}
Newsletter: {} subs

JSON format:
{{"fit_score":75,"tone_analysis":"Pro","clarity_rating":"High","estimated_clicks_min":30,"estimated_clicks_max":60,"recommendations":["brief tip","brief tip","brief tip"]}}

Rules: score 0-100, tone 1-2 words, clarity High/Medium/Low, tips 3-5 words max. IMPORTANT: Return RAW JSON only. Do not wrap in markdown or code blocks."#&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ad_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ad_copy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subscriber_range&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The whole prompt.&lt;/p&gt;

&lt;p&gt;My first version was three paragraphs long. It explained Adsloty's business model, described what newsletter advertising is, defined each metric in detail, gave five examples of good and bad ads, and included instructions for edge cases. It was thorough but also slow, costly, and did not produce better results.&lt;/p&gt;

&lt;p&gt;I kept cutting unnecessary details. With each version, I removed a sentence and assessed if the quality changed. Most of the time, it didn’t. The model understands ad copy and what a newsletter audience is. I was providing more information than the model needed.&lt;/p&gt;

&lt;p&gt;The final prompt is very straightforward: ad title and copy, subscriber count, an example of the exact JSON format I want, constraints for each field, and a clear instruction about the format.&lt;/p&gt;

&lt;p&gt;I learned three key things about prompts for structured output:&lt;/p&gt;

&lt;p&gt;First, the example output is the most important part. The model replicates that structure faithfully, including field names, types, and shape.&lt;/p&gt;

&lt;p&gt;Second, clear constraints prevent errors. Saying "Score 0-100" stops the model from giving a score of 150. "Tone 1-2 words" prevents it from writing long paragraphs. "Tips 3-5 words max" keeps recommendations brief and useful.&lt;/p&gt;

&lt;p&gt;Third, I added the instruction "do not wrap in markdown" because I learned this from experience. Even with &lt;code&gt;responseMimeType&lt;/code&gt; set to JSON, the model sometimes wraps the response in triple backticks. So, I include that instruction and handle it during parsing. It's a double-check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing: trust nothing
&lt;/h2&gt;

&lt;p&gt;The model usually returns data in JSON format. Sometimes, it may return something similar to JSON. It can also wrap responses in markdown or cut off mid-word. You need to be prepared to handle all these variations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;extract_json_from_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="nf"&gt;.trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Handle ```&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="err"&gt;```&lt;/span&gt; &lt;span class="n"&gt;wrappers&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="nf"&gt;.find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"```

json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;json_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;json_start&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"

```"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;json_start&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;json_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Handle generic ```&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="err"&gt;```&lt;/span&gt; &lt;span class="n"&gt;wrappers&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="nf"&gt;.find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"```

"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;json_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;json_start&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"

```"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;json_start&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;json_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Raw JSON — the happy path&lt;/span&gt;
    &lt;span class="n"&gt;trimmed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After extraction, I validate every field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Clamp fit score to valid range&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;fit_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.fit_score&lt;/span&gt;&lt;span class="nf"&gt;.clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Validate clarity rating&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;clarity_rating&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.clarity_rating&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"High"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"Medium"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"Low"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.clarity_rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Medium"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// Safe default&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Sanity check click estimates&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clicks_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clicks_max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.estimated_clicks_min&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.estimated_clicks_max&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.estimated_clicks_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.estimated_clicks_max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.estimated_clicks_max&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.estimated_clicks_min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Swap if backwards&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also handle truncated JSON. Sometimes the response gets cut off — a recommendation array missing its closing bracket, or the entire object missing its final brace. Instead of failing, I try to fix it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Auto-fix truncated JSON&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="nf"&gt;.ends_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'}'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="nf"&gt;.ends_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;']'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;']'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is this method a bit unconventional? Yes. But does it work? Yes, it does. In real-life situations, you'll use what the model provides, not what it ideally should provide.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the analysis runs
&lt;/h2&gt;

&lt;p&gt;There are three triggers:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Automatic, when payment is made.&lt;/em&gt; When a sponsor completes their purchase and Stripe sends a notification, the backend starts a background task to run the analysis. This process does not block other tasks the booking is created right away, and the AI results get added when they are ready.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;trigger_ai_analysis_background&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;run_ai_analysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;info!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AI analysis completed for booking {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;warn!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AI analysis failed for booking {}: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the AI call fails—like a timeout, a rate limit issue, or a bad response—the booking is still valid. The writer will just see the ad without a score. The analysis adds to the information but does not block anything.&lt;/p&gt;

&lt;p&gt;A batch job runs every 15 minutes. This background job checks for any bookings that didn't get processed—possibly because the AI was down when the webhook was triggered or due to a timing issue. It looks for bookings without an analysis timestamp and processes up to 10 at a time, waiting 500ms between each call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Find bookings that need analysis&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;sqlx&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;query_as&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Booking&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;r#"SELECT * FROM bookings
    WHERE ai_analysis_timestamp IS NULL
        AND cancelled_at IS NULL
        AND status IN ('pending', 'paid')
    ORDER BY created_at DESC
    LIMIT $1"#&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;.bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;.fetch_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Manual trigger. Writers can rerun the analysis on any booking. They might want a new score or the first analysis might have been done before they updated their audience description. Just click a button to get a new analysis.&lt;/p&gt;

&lt;p&gt;Three layers. If the first analysis misses something, the second one catches it. If the writer needs a redo, the third layer takes care of it. No booking should go without a score for more than 15 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the writer sees
&lt;/h2&gt;

&lt;p&gt;When a writer opens a booking request in their dashboard, the AI analysis is easy to find. It is prominently displayed, not hidden in a tab or behind a "show details" link.&lt;/p&gt;

&lt;p&gt;The fit score gets a color:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getFitScoreColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-green-400&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Great fit&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-blue-400&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Good fit&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-yellow-400&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Okay fit&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-orange-400&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                   &lt;span class="c1"&gt;// Questionable fit&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below the score: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tone analysis: Professional and Direct&lt;/li&gt;
&lt;li&gt;Clarity rating: High&lt;/li&gt;
&lt;li&gt;Estimated click range: 45 to 120 clicks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recommendations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make the CTA more specific.&lt;/li&gt;
&lt;li&gt;Add social proof.&lt;/li&gt;
&lt;li&gt;The tone matches the audience well. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These points help writers decide quickly, in 30 seconds instead of 5 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it actually work?
&lt;/h2&gt;

&lt;p&gt;Most of the time, about 80%, the fit scores of the AI are reasonable. It usually gets the tone right and offers helpful recommendations. For simple cases, like a developer tool ad in a tech newsletter, the AI performs well. It might give a score of 85, say the tone is "Technical, Professional," show high clarity, and provide click estimates that make sense.&lt;/p&gt;

&lt;p&gt;The other 20% is where it gets tricky.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the AI is confidently wrong
&lt;/h2&gt;

&lt;p&gt;I’ve noticed three types of bad scores:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The false positive.&lt;/em&gt; A crypto trading platform ad submitted to a personal finance newsletter scored 78. The AI noticed "finance" in both but missed the audience's intent. The newsletter targets people learning to budget, not those interested in trading cryptocurrencies.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The false negative.&lt;/em&gt; A well-written ad for a writing tool submitted to a creator economy newsletter got a score of 52. The ad's poetic style led the model to think it was unclear, even though it was perfect for writers. The AI confused creativity with a lack of clarity because it defines clarity as straightforward.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The hallucinated precision.&lt;/em&gt; The AI might say "Estimated clicks: 847-1,203" for a newsletter with 5,000 subscribers. It doesn't actually know the newsletter's click-through rate or how engaged the audience is. It produces a number that sounds exact but is completely made up. Confident and useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;I didn’t fix the AI; I changed the message.&lt;/p&gt;

&lt;p&gt;The fit score is just a starting point, not a final decision. The interface says “AI Smart Critique,” not “AI Decision.” The approve and reject buttons are always visible, no matter the score. A score of 40 can be approved, and a score of 95 can be rejected.&lt;/p&gt;

&lt;p&gt;I thought about automatically rejecting anything below a certain score, but I’m glad I didn’t. A new writer might be excited to see a 40 because it’s their first paying sponsor. Meanwhile, a writer with 50,000 subscribers might reject a 90 because the brand doesn’t match their values. Context is important, and the AI doesn’t have all the details.&lt;/p&gt;

&lt;p&gt;For click estimates, I might add a warning or remove them completely. They are the least reliable part of the analysis and can mislead more than help. A writer seeing “estimated 800 clicks” might approve an ad expecting that outcome, then feel disappointed when it only gets 50.&lt;/p&gt;

&lt;p&gt;The recommendations are the most useful part. They are valuable not because they are always correct, but because they give the writer specific points to think about. For example, saying “the CTA could be more specific” helps the writer focus on the CTA instead of just skimming it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost question
&lt;/h2&gt;

&lt;p&gt;Every analysis costs money. Gemini 2.5 Flash is inexpensive just a fraction of a cent per call but it can add up. If Adsloty gets 1,000 bookings a month, that means at least 1,000 API calls, plus retries and manual re-analyses. That's why I chose Flash instead of Pro. The quality difference for this task is small, but the cost difference is large. I also keep the prompt minimal to save costs fewer input tokens mean a lower cost per call. &lt;/p&gt;

&lt;p&gt;Right now, the AI analysis costs less than $0.01 per booking. The platform fee for a $100 booking is $10. The AI cost is small compared to that. However, I am monitoring it because if the prompt gets longer or I switch to a more advanced model for special cases, the costs could change. &lt;/p&gt;

&lt;h2&gt;
  
  
  Here’s what I would do differently
&lt;/h2&gt;

&lt;p&gt;If I were starting over, I would do three things: &lt;/p&gt;

&lt;p&gt;First, I would include the newsletter's niche and audience details in the prompt, not just the subscriber count. Currently, the prompt is so short that the model has to guess the audience based only on subscriber numbers. That’s why it gets confused about "finance" audiences. More context in the prompt would cost a few more tokens but would greatly improve the accuracy of the fit score. &lt;/p&gt;

&lt;p&gt;Second, I would set up a feedback loop from the start. When a writer approves or rejects a booking, it shows whether the AI’s score was helpful. If writers often approve ads that the AI scored at 50, the scoring is likely wrong. I’m not gathering that data yet, but I should be. &lt;/p&gt;

&lt;p&gt;Third, I would be more careful with click estimates. They should probably be ranges based on industry benchmarks, not just numbers from the AI. Or I should remove them until I have real click tracking data to compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest takeaway
&lt;/h2&gt;

&lt;p&gt;AI features look great in demos and presentations. However, in real use, they often have problems. The model makes errors frequently enough that you cannot rely on it completely, but it is also correct often enough that removing it would make the product less effective.&lt;/p&gt;

&lt;p&gt;The key is how you frame it. Think of AI as a helper, not as the one making decisions. It helps writers get started, highlights things they might overlook, and saves time on clear choices, allowing them to focus on the tougher decisions.&lt;/p&gt;

&lt;p&gt;If you’re adding AI features to your product, my advice is to focus less on the prompts and more on what happens when the prompts fail. And they will. Regularly. Users will evaluate your product based on how well it deals with failures, not just on how great the best outcomes are.&lt;/p&gt;

&lt;p&gt;Next time, I’ll write about the embeddable widget how writers can add a "Sponsor this newsletter" button to their sites and what it took to create a JavaScript widget that works on any website without causing issues.&lt;/p&gt;

&lt;p&gt;More soon.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>rust</category>
    </item>
    <item>
      <title>Building in public #5: The dashboard</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Sat, 28 Feb 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/building-in-public-5-the-dashboard-1j21</link>
      <guid>https://dev.to/miraclejudeiv/building-in-public-5-the-dashboard-1j21</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Writers need useful information, not just a simple statement like "you earned $500 this month."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I've been thinking about this a lot lately. Adsloty's booking process works well, and money transfers correctly. But when a writer logs in, what do they see? A list of bookings and a number. That's not enough information.&lt;/p&gt;

&lt;p&gt;If you make money from your newsletter by selling ad slots, you need to know more about how it works. Which sponsors return for more ads? Is your average booking amount increasing or decreasing? Are you turning down too many requests? Which months are busy, and which are slow?&lt;/p&gt;

&lt;p&gt;Without this information, it's hard to make informed decisions. You can't set better prices if you don't know your conversion rate. You can't plan your schedule without seeing seasonal trends. You can't negotiate with a returning sponsor if you don’t realize they’ve spent $3,000 with you over six months.&lt;/p&gt;

&lt;p&gt;So, I created the dashboard I would want if I were running a newsletter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revenue analytics
&lt;/h2&gt;

&lt;p&gt;When a writer logs into Adsloty, the first thing they see is a summary of their revenue. It shows not just one number, but the complete picture.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Writer revenue stats: one query, all the context&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;writer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;total_earned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;
        &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;available_balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pending_balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;completed_payouts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pending_payouts&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payouts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;writer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see all your earnings and balances in one place. Your dashboard shows how much you’ve made, what money is available, and what is still pending. It also compares completed payouts to pending ones, so you can easily understand your financial status.&lt;/p&gt;

&lt;p&gt;You can view your earnings by different time periods — this month, last month, or overall — and by each sponsor. This breakdown is useful. If you notice that one company has booked five times in three months, it shows that it's a good relationship to maintain. You might want to reach out with a loyalty discount or offer premium placement. This data helps you make decisions without the platform needing to do it for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Serialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;DashboardStatsResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;total_revenue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;total_bookings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;active_bookings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;pending_bookings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;available_slots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;pending_payout_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;average_booking_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;average_fit_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;last_booking_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;cache_age_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;cache_age_seconds&lt;/code&gt; at the bottom. Dashboard stats don’t need to be updated in real-time. I save the combined stats and refresh them regularly. The writer can see how recent the data is, and I avoid overloading the database every time someone updates their dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance metrics
&lt;/h2&gt;

&lt;p&gt;Understanding your revenue is important, but knowing the reasons behind it helps even more. Here are some important metrics that writers should track:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Total bookings over time.&lt;/em&gt; Look at trends, not just numbers. Are your bookings going up each month? Did you get more bookings after tweeting about sponsorships? Did they drop in December? The trend is more important than the count.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Average booking value.&lt;/em&gt; If your average booking was $150 three months ago and is now $200, your price increase hasn’t hurt demand. If it went down, you might have raised prices too quickly.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Conversion rate.&lt;/em&gt; This is often overlooked. Out of every 10 booking requests, how many do you accept? If you reject 80% of requests, your listing might attract the wrong sponsors or your standards may be too high for your audience size. This is valuable information to improve upon.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI fit score trends.&lt;/em&gt; This metric is specific to Adsloty. Each ad gets an AI score before you see it. I can show you the average fit score over time. If it’s going up, you’re attracting the right sponsors. If it’s going down, it might be time to update your niche description.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Most popular slots.&lt;/em&gt; Which days get the most bookings? Which newsletter positions sell best? If Tuesday slots fill up but Thursday ones don't, you might want to change their prices or stop offering Thursdays altogether.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL deep dive
&lt;/h2&gt;

&lt;p&gt;I got a bit technical while calculating these metrics. It was quite challenging to do it efficiently.&lt;/p&gt;

&lt;p&gt;At first, I used a straightforward method: I ran a separate query for each metric. For total revenue, I made one query. For average booking value, another. I did the same for month-over-month growth and conversion rate. This method worked, but it required eight database requests to load the dashboard, making it feel slow.&lt;/p&gt;

&lt;p&gt;Then I discovered PostgreSQL window functions, which changed everything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Running totals, growth rates, and trends in a single pass&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer_payout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer_payout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;avg_booking_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;-- Month-over-month growth&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer_payout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer_payout&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;revenue_change&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;-- Running total&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer_payout&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cumulative_revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;-- Conversion rate per month&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'confirmed'&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;FLOAT&lt;/span&gt;
        &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conversion_rate&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'12 months'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I have one question. I want to calculate running totals, monthly growth, conversion rates, and averages directly in the database. This method is faster than using Rust, where I would have to manually go through data.&lt;/p&gt;

&lt;p&gt;Using the &lt;code&gt;LAG()&lt;/code&gt; function, I can get last month's revenue to work out growth. The &lt;code&gt;SUM() OVER()&lt;/code&gt; function lets me find the running total. The &lt;code&gt;NULLIF&lt;/code&gt; function helps avoid division by zero when there are months with no bookings.&lt;/p&gt;

&lt;p&gt;I am really thankful that I chose Postgres for this. The query optimizer works really well. What used to take eight queries and 200 milliseconds now only takes one query and 30 milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visual layer
&lt;/h2&gt;

&lt;p&gt;Numbers in a table are helpful. Numbers in a chart tell a story.&lt;/p&gt;

&lt;p&gt;I used Recharts for my frontend visualizations. I tried other charting libraries like Chart.js, D3, and Nivo, but Recharts was the best fit for my needs. It works well with React, allows for customization, and is easy to use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ResponsiveContainer&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"100%"&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AreaChart&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;revenueData&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;defs&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;linearGradient&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"revenueGradient"&lt;/span&gt; &lt;span class="na"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;x2&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;y2&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;stop&lt;/span&gt; &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"5%"&lt;/span&gt; &lt;span class="na"&gt;stopColor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#4F46E5"&lt;/span&gt; &lt;span class="na"&gt;stopOpacity&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;stop&lt;/span&gt; &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"95%"&lt;/span&gt; &lt;span class="na"&gt;stopColor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#4F46E5"&lt;/span&gt; &lt;span class="na"&gt;stopOpacity&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;linearGradient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;defs&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;XAxis&lt;/span&gt;
      &lt;span class="na"&gt;dataKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"month"&lt;/span&gt;
      &lt;span class="na"&gt;tickFormatter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MMM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;YAxis&lt;/span&gt; &lt;span class="na"&gt;tickFormatter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`$&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tooltip&lt;/span&gt;
      &lt;span class="na"&gt;formatter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`$&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;labelFormatter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MMMM yyyy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Area&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"monotone"&lt;/span&gt;
      &lt;span class="na"&gt;dataKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"revenue"&lt;/span&gt;
      &lt;span class="na"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#4F46E5"&lt;/span&gt;
      &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"url(#revenueGradient)"&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;AreaChart&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ResponsiveContainer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revenue is shown with an area chart, which highlights trends with a gradient fill. Bookings are displayed as a bar chart, making it easier to compare individual events. The conversion rate uses a line chart to show if it is going up or down.&lt;/p&gt;

&lt;p&gt;I have also added a booking calendar view. Writers can quickly see which dates are booked, which have pending requests, and which are available. While this may seem simple, it required bringing together booking data, blackout dates (when the writer is unavailable), and availability settings into one view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Empty states that don't make you feel bad
&lt;/h2&gt;

&lt;p&gt;This is a small issue that really matters.&lt;/p&gt;

&lt;p&gt;When new writers sign up, their dashboard shows no data. They see zero bookings and zero revenue. Every chart is blank. This can feel discouraging, as if the platform is already telling them they are failing.&lt;/p&gt;

&lt;p&gt;I focused on improving these empty states. Instead of blank charts, new writers see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"No bookings yet. Your first one is coming," with a link to check their listing.&lt;/li&gt;
&lt;li&gt;The revenue chart shows a note saying, "Your revenue trend will appear here."&lt;/li&gt;
&lt;li&gt;The calendar shows available slots highlighted, with a positive note — "You have 12 slots available this month."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are no sad empty boxes and no "0" in big letters. The dashboard should feel like a starting line, not a report card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-time notifications
&lt;/h2&gt;

&lt;p&gt;When a new booking comes in, the writer should see it right away without refreshing their dashboard. &lt;/p&gt;

&lt;p&gt;I thought about using WebSockets for this, but then I realized that I only need one-way communication. The server tells the client about the event, but the client doesn't need to respond back. WebSockets allow two-way communication and are more complicated than I need.&lt;/p&gt;

&lt;p&gt;Server-Sent Events (SSE) are a better choice. They use one continuous HTTP connection, where the server sends updates to the client. The client listens for these updates. If the connection drops, the browser reconnects automatically. Every browser supports this with &lt;code&gt;EventSource&lt;/code&gt;, so no extra library is needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Backend: SSE endpoint for real-time notifications&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;notification_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;State&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auth_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AuthUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Sse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Infallible&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;BroadcastStream&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.notification_tx&lt;/span&gt;&lt;span class="nf"&gt;.subscribe&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;.filter_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="py"&gt;.user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;auth_user&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                        &lt;span class="nf"&gt;.event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"notification"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="nf"&gt;.data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nn"&gt;Sse&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.keep_alive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nn"&gt;axum&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;response&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;KeepAlive&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Frontend: three lines to listen&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/notifications/stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidateQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a booking comes in, the writer sees a notification pop up. The dashboard updates automatically, so there is no need to refresh or poll for updates.&lt;/p&gt;

&lt;p&gt;The backend sends a signal every 30 seconds to keep the connection alive, preventing proxies and load balancers from closing it due to inactivity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sponsor's side
&lt;/h2&gt;

&lt;p&gt;Sponsors need a simple dashboard to check their campaigns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;See all campaigns with their status (active, completed, pending)&lt;/li&gt;
&lt;li&gt;View total spending and spending by newsletter&lt;/li&gt;
&lt;li&gt;Access booking history with ad performance (once click tracking is available)&lt;/li&gt;
&lt;li&gt;Quickly rebook — one click to book the same newsletter again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I made the sponsor dashboard simple on purpose. Sponsors want to know two things: "Did my ad run?" and "Was it worth it?" The dashboard should provide answers to both questions quickly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Sponsor booking history — includes writer and newsletter details&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;sqlx&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;query_as&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SponsorBookingView&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;r#"
    SELECT
        b.id, b.status, b.amount, b.newsletter_date,
        b.ad_title, b.ad_url,
        w.newsletter_name, w.subscriber_count,
        b.ai_fit_score, b.created_at
    FROM bookings b
    JOIN writers w ON b.writer_id = w.id
    WHERE b.sponsor_id = $1
    ORDER BY b.created_at DESC
    "#&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;.bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sponsor_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;.fetch_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's still missing
&lt;/h2&gt;

&lt;p&gt;I'm being clear about what still needs to be done:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click tracking.&lt;/em&gt; This is the biggest gap. Sponsors want to know how many people clicked their ad. Writers want to show that their audience is engaged. I need to create a system that tracks clicks before sending users to the sponsor's URL. This is not too difficult, but it involves the widget, the booking model, and the analytics pipeline, so I will tackle it in a focused sprint.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A/B testing insights.&lt;/em&gt; Eventually, I want sponsors to test two versions of their ad copy and see which one works better. We need click tracking first, so this is on hold.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Audience overlap analysis.&lt;/em&gt; If a sponsor books ads in three newsletters, are they reaching the same audience three times or three different ones? This is a complex issue. I have some ideas, but it will come later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs I found
&lt;/h2&gt;

&lt;p&gt;Building the dashboard revealed three issues in the booking process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Some payouts showed "pending" indefinitely. The job that processes payouts looked for bookings marked as "confirmed," but some had already changed to "completed" by the time the job ran. As a result, the payout remained stuck.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Approved bookings did not change to "completed" after the publication date. The date comparison used UTC, while the newsletter dates were stored without time zone information. For example, a booking for February 20th in EST was compared to February 20th UTC. Depending on when the job ran, it might miss the deadline.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The dashboard statistics counted refunded bookings in the "total bookings," which distorted the count and made the conversion rate appear incorrect.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three issues required only one or two lines of code to fix. Without the dashboard highlighting these problems, they could have gone unnoticed.&lt;/p&gt;

&lt;p&gt;The hidden advantage of creating analytics is that it encourages you to examine your data, which can reveal flaws in your logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Advice for building a marketplace dashboard
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Cache your data calculations. Dashboards refresh often, and running heavy queries each time will slow down your database.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use window functions. If you are calculating trends or totals in your application code, it’s too much work. Let Postgres do it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Design empty states first. Your first users will see these before they see any data. Make these states feel like an opportunity, not a failure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Choose SSE over WebSockets for one-way updates. It’s simpler, reconnects automatically, and doesn’t require a client library.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Start building the dashboard early. Don’t wait for user requests. It will help you spot bugs in your system that you wouldn’t find otherwise.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next, I might write about AI fit scoring in detail. I will cover how the prompts work, whether the scores are useful, and what to do when the AI gives bad advice confidently.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>rust</category>
      <category>buildinpublic</category>
      <category>postgres</category>
    </item>
    <item>
      <title>I built a booking system for Newsletter Ads. Here's every decision I made.</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Fri, 27 Feb 2026 14:33:10 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/i-built-a-booking-system-for-newsletter-ads-heres-every-decision-i-made-1k6n</link>
      <guid>https://dev.to/miraclejudeiv/i-built-a-booking-system-for-newsletter-ads-heres-every-decision-i-made-1k6n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I created the booking flow today. This marks the shift of Adsloty from being just a backend project to becoming a real product.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my last two posts, I talked about important but hidden features like authentication and payments. While these are necessary, they aren't what draws people to the platform. &lt;/p&gt;

&lt;p&gt;The booking flow is different. This is what users will actually engage with. It’s the process a sponsor goes through to find a newsletter, choose a date, submit their ad, and make a payment. It’s also the experience for a writer when a new booking request arrives in their dashboard, complete with an AI analysis.&lt;/p&gt;

&lt;p&gt;If this experience is complicated, then nothing else matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was aiming for
&lt;/h2&gt;

&lt;p&gt;I wanted booking an ad slot to be as easy as booking a flight. You browse, choose, pay, and you’re done. It shouldn’t feel like filling out a loan application or sending a cold email and waiting three days for a reply.&lt;/p&gt;

&lt;p&gt;Adsloty aims to remove the hassle between deciding to sponsor a newsletter and completing the booking. Each extra step, unnecessary form field, or promise of “we’ll get back to you” makes it easier for someone to leave the page.&lt;/p&gt;

&lt;p&gt;I designed the process for speed and clarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sponsor's experience
&lt;/h2&gt;

&lt;p&gt;Here's what sponsors see, step by step:&lt;/p&gt;

&lt;p&gt;First, they discover. Sponsors look through newsletters grouped by topic—like tech, finance, wellness, or the creator economy—that match their products. Each listing shows key details: the number of subscribers, average open rates, cost per slot, and available dates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Newsletter discovery with filters&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newsletters&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;newsletters&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;niche&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minSubscribers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sortBy&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;newsletterApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;niche&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;min_subscribers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;minSubscribers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maxPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sort_by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sortBy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No vanity metrics and no vague pricing. Everything is clear and easy to understand. A sponsor should be able to evaluate a newsletter in under 30 seconds.&lt;/p&gt;

&lt;p&gt;Next is the booking form. After choosing a newsletter, the sponsor selects an available date and fills in their ad details: title, description, call-to-action text, link, and an optional image. That’s all they need to do.&lt;/p&gt;

&lt;p&gt;I spent a lot of time deciding which fields to make required. My first version had twelve fields. I cut it down to six. Each field I removed was something I thought was nice to have but would slow people down. You can ask for more information later, but you can't win back a user who left because the form felt like homework.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bookingSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;newsletter_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pick a date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;ad_title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;ad_description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;ad_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter a valid URL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;ad_cta_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;ad_image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, payment is easy. With one click, the sponsor opens Stripe Checkout. They see exactly what they're paying, confirm the payment, and that’s it. No need to enter card details on my site. I don't have to maintain or secure a custom payment form. Stripe's checkout takes care of everything—this includes PCI compliance, 3D Secure, Apple Pay, and Google Pay. I receive a notification when the payment is confirmed.&lt;/p&gt;

&lt;p&gt;The whole process from browsing newsletters to confirming payment can take less than two minutes. That was the goal.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In the future I plan making it a escrow based system.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The writer's experience
&lt;/h2&gt;

&lt;p&gt;The writer's dashboard shows a new booking request. It doesn’t just say "someone wants to buy an ad slot." It provides additional context.&lt;/p&gt;

&lt;p&gt;This is where the AI analysis comes in.&lt;/p&gt;

&lt;p&gt;When a sponsor submits their ad, the backend sends it to Google's Gemini API along with the newsletter's audience data. Before the writer even looks at the request, the AI has already scored it.:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AI analysis runs automatically when a booking is created&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;analysis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gemini_client&lt;/span&gt;&lt;span class="nf"&gt;.analyze_ad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AdAnalysisRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ad_title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="py"&gt;.ad_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ad_description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="py"&gt;.ad_description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ad_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="py"&gt;.ad_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ad_cta_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="py"&gt;.ad_cta_text&lt;/span&gt;&lt;span class="nf"&gt;.as_deref&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;newsletter_niche&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;newsletter&lt;/span&gt;&lt;span class="py"&gt;.niche&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;newsletter_audience_description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;newsletter&lt;/span&gt;&lt;span class="py"&gt;.audience_description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;subscriber_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newsletter&lt;/span&gt;&lt;span class="py"&gt;.subscriber_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;average_open_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newsletter&lt;/span&gt;&lt;span class="py"&gt;.open_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Returns structured data, not a wall of text&lt;/span&gt;
&lt;span class="c1"&gt;// {&lt;/span&gt;
&lt;span class="c1"&gt;//   fit_score: 82,&lt;/span&gt;
&lt;span class="c1"&gt;//   tone_analysis: "Professional, Direct",&lt;/span&gt;
&lt;span class="c1"&gt;//   clarity_rating: "High",&lt;/span&gt;
&lt;span class="c1"&gt;//   estimated_clicks_min: 45,&lt;/span&gt;
&lt;span class="c1"&gt;//   estimated_clicks_max: 120,&lt;/span&gt;
&lt;span class="c1"&gt;//   recommendations: [&lt;/span&gt;
&lt;span class="c1"&gt;//     "CTA could be more specific — 'Start free trial' outperforms 'Learn more'",&lt;/span&gt;
&lt;span class="c1"&gt;//     "Ad copy aligns well with tech audience expectations",&lt;/span&gt;
&lt;span class="c1"&gt;//     "Consider adding a specific metric or social proof"&lt;/span&gt;
&lt;span class="c1"&gt;//   ]&lt;/span&gt;
&lt;span class="c1"&gt;// }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will receive a fit score between 0 and 100 for your ad. The score is based on tone analysis, clarity rating, and an estimated click range. You will also get three to five specific recommendations.&lt;/p&gt;

&lt;p&gt;You don’t have to wonder if an ad is a good fit. Just open the request to see the score, read the recommendations, and decide whether to approve or reject with one button.&lt;/p&gt;

&lt;p&gt;Initially, I thought about automatically rejecting ads that scored below a certain level. I chose not to do that. The AI is a tool to help, not a barrier. A score of 40 might be just right for a writer starting out who wants to earn money. A score of 90 might still be rejected if the writer doesn't like the brand. The writer makes the final decision, while the AI provides helpful information.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state machine
&lt;/h2&gt;

&lt;p&gt;Here's where things became more complex.&lt;/p&gt;

&lt;p&gt;A booking is not simply "booked" or "not booked." It has a process. This process must be clear and organized because money is involved in every step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;draft → pending_payment → paid → confirmed → completed
                                           → rejected → refunded
                       → expired
                       → cancelled → refunded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me explain each step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Draft.&lt;/strong&gt; The sponsor started filling out the form but hasn’t paid yet. They may have gotten distracted or are comparing newsletters. Their booking is still in the system, so they can return to it later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending payment.&lt;/strong&gt; The sponsor clicked "Book now" and was directed to Stripe Checkout. We are waiting for the payment confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid.&lt;/strong&gt; Stripe confirmed the payment. The writer gets notified, and we run an AI analysis. The booking appears in the writer's dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirmed.&lt;/strong&gt; The writer approved the ad. It is locked in for the scheduled date, and a payout record is created with a "pending" status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completed.&lt;/strong&gt; The publication date has passed. Our system marks it as complete and processes the payout to the writer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rejected.&lt;/strong&gt; The writer declined the ad. The sponsor receives a full refund, and the payout record is canceled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expired.&lt;/strong&gt; The writer did not respond within the time limit, so we automatically refund the sponsor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancelled.&lt;/strong&gt; The sponsor canceled the booking before the writer confirmed it. We process a refund.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these steps triggers different actions—like updating the database, sending email notifications, or processing payouts and refunds. There are rules about which steps can follow others. For example, you can’t go from "completed" back to "draft," and you can’t reject a booking that has already been refunded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;BookingStatus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;can_transition_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;BookingStatus&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;PendingPayment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;PendingPayment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Paid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;PendingPayment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Expired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Paid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Confirmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Paid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Confirmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Confirmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Refunded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Cancelled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Refunded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I handle this in the backend. Every status update first checks with &lt;code&gt;can_transition_to&lt;/code&gt;. If the transition isn’t valid, it shows an error. There are no exceptions. This one function has likely prevented more bugs than anything else I’ve written for this project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notifications at every step
&lt;/h2&gt;

&lt;p&gt;Nobody should be confused about their booking status. So, I created email notifications for each important update:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the sponsor pays, they get an email: "Your booking is confirmed, waiting for writer approval."&lt;/li&gt;
&lt;li&gt;When a writer receives a new request, they get: "New ad request for [newsletter name] — review it now."&lt;/li&gt;
&lt;li&gt;When the writer approves the ad, the sponsor gets: "Your ad has been approved for [date]."&lt;/li&gt;
&lt;li&gt;If the writer rejects it, the sponsor gets: "Your ad wasn't approved — a refund is on its way."&lt;/li&gt;
&lt;li&gt;After the ad completes, the writer gets: "Payout of $X is being processed."&lt;/li&gt;
&lt;li&gt;When the payout arrives, the writer gets: "You've been paid."
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After writer confirms a booking&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;confirm_booking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AppResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Validate state transition&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="py"&gt;.status&lt;/span&gt;&lt;span class="nf"&gt;.can_transition_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;BookingStatus&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Confirmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;AppError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;BadRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Cannot confirm this booking"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Update status&lt;/span&gt;
    &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;update_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;BookingStatus&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Confirmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Create pending payout&lt;/span&gt;
    &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;payout&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create_payout_tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="py"&gt;.writer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="py"&gt;.writer_payout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="py"&gt;.currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Notify the sponsor&lt;/span&gt;
    &lt;span class="nf"&gt;send_booking_confirmed_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The emails are straightforward. They explain what happened and what will happen next, without any sales language. When it comes to money, being clear is more important than being clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The embeddable widget
&lt;/h2&gt;

&lt;p&gt;I built something this week that I'm really excited about: writers can now add a booking widget directly to their newsletter website.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!--&lt;/span&gt; &lt;span class="nx"&gt;Drop&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="nx"&gt;your&lt;/span&gt; &lt;span class="nx"&gt;site&lt;/span&gt; &lt;span class="o"&gt;--&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://adsloty.com/widget.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your-writer-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#4F46E5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sponsor this newsletter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/script&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It creates a button that sponsors can click to view the writer's available dates and pricing. They can start the booking process directly from the writer's site without needing to visit Adsloty. This makes the writer's website a sales page for their ads.&lt;/p&gt;

&lt;p&gt;Writers can customize the colors, button text, and placement to ensure it fits seamlessly with their site. I am also tracking impressions, clicks, and conversion rates for each widget, so writers can see how many visitors become sponsors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still rough
&lt;/h2&gt;

&lt;p&gt;I won't pretend it's perfect. The discovery page needs better filters and search options. The user interface works, but it still doesn't feel great. The AI analysis can take a few seconds, so I need to add proper loading states instead of the spinner I added in a hurry.&lt;/p&gt;

&lt;p&gt;My analytics are basic. I'm tracking numbers, but I need to present them better. Writers should be able to see trends, compare months, and know which topics generate the most revenue. That's in the works.&lt;/p&gt;

&lt;h2&gt;
  
  
  But the core loop works
&lt;/h2&gt;

&lt;p&gt;A sponsor can find a newsletter, book a slot, pay, and submit their ad. A writer can review the request using AI analysis, approve it, and get paid automatically after the publication date.&lt;/p&gt;

&lt;p&gt;This is the core of the product. Everything else is just added features.&lt;/p&gt;

&lt;p&gt;If you run a newsletter and want to try this during the beta phase—or if you are a brand that has found sponsoring newsletters difficult—I would really like to know what you think. What would make this more useful for you?&lt;/p&gt;

&lt;p&gt;More updates soon.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>rust</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I integrated Stripe into a two-sided marketplace. Here's what actually happens</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Mon, 23 Feb 2026 12:51:02 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/i-integrated-stripe-into-a-two-sided-marketplace-heres-what-actually-happens-2mpb</link>
      <guid>https://dev.to/miraclejudeiv/i-integrated-stripe-into-a-two-sided-marketplace-heres-what-actually-happens-2mpb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I spent all day making money move. It was harder than I expected.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At some point in building a marketplace, you shift from adding features to setting up the behind-the-scenes systems. This includes payment processing. This work often goes unnoticed and rarely gets shared online, but if mistakes happen, real people can lose real money.&lt;/p&gt;

&lt;p&gt;That’s how I spent my entire week.&lt;/p&gt;

&lt;p&gt;Adsloty is a marketplace where sponsors pay to reserve ad slots in newsletters. Writers earn money after the ads run. While this sounds simple, there is a complex system behind it that ensures every penny goes to the right place at the right time.&lt;/p&gt;

&lt;p&gt;Here’s what I built, what went wrong, and what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe Connect: paying writers directly
&lt;/h2&gt;

&lt;p&gt;The first decision was about how writers get paid. I could collect all the money myself and manually send payouts. However, that would be a nightmare to handle and a legal headache right from the start.&lt;/p&gt;

&lt;p&gt;Instead, I chose Stripe Connect. Each writer links their own Stripe account to Adsloty. When a sponsor books an ad slot, the payment goes through our platform. Stripe automatically splits the payment: the writer's share goes to their connected account, and the platform fee stays with me.&lt;/p&gt;

&lt;p&gt;The onboarding flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Generate a Stripe Connect onboarding link for the writer&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;account_link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe_client&lt;/span&gt;
    &lt;span class="nf"&gt;.create_account_link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="py"&gt;.stripe_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}/dashboard/settings?stripe=return"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="py"&gt;.frontend_url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}/dashboard/settings?stripe=refresh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="py"&gt;.frontend_url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"account_onboarding"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The writer clicks a link, enters their banking details on Stripe's secure page, and then returns. I never handle their bank information. Stripe takes care of identity checks and verification. That alone makes the processing fee worth it.&lt;/p&gt;

&lt;p&gt;However, I did not expect that onboarding would not always be quick. Sometimes, Stripe needs to verify identities, which can take hours or even days. So, I had to keep track of the onboarding status and stop writers from accepting paid bookings until their accounts are fully active.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payment flow
&lt;/h2&gt;

&lt;p&gt;When a sponsor wants to book an ad slot, here's what happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The sponsor fills in the ad details and selects a date.&lt;/li&gt;
&lt;li&gt;The frontend creates a checkout session through the backend.&lt;/li&gt;
&lt;li&gt;The backend calculates the platform fee and sets up a Stripe Checkout Session.&lt;/li&gt;
&lt;li&gt;The sponsor pays on Stripe's hosted checkout page.&lt;/li&gt;
&lt;li&gt;Stripe sends a message to confirm the payment.&lt;/li&gt;
&lt;li&gt;The backend creates the booking and adds a pending payout record.&lt;/li&gt;
&lt;li&gt;After the publication date, the payout goes to the writer.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Creating the checkout session with the platform fee split&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;platform_fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_platform_fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot_price&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe_client&lt;/span&gt;
    &lt;span class="nf"&gt;.create_checkout_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CheckoutParams&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;slot_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;application_fee_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;platform_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;transfer_data_destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="py"&gt;.stripe_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BookingMetadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;writer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;sponsor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;newsletter_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;success_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}/bookings/{}?status=success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frontend_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;cancel_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}/bookings/{}?status=cancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frontend_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform charges a 10% fee. I carefully considered this amount. If it’s too high, writers will leave. If it’s too low, the business won’t be sustainable. I believe 10% is fair for the services the platform offers, including discovery, booking management, AI analysis, and payment handling. During the beta phase, I will not charge this fee at all for the first three months to attract early users.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;calculate_platform_fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;fee_percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PLATFORM_FEE_PERCENTAGE"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or_else&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"10.0"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;dec!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;fee_percentage&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nd"&gt;dec!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.round_dp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I made the fee adjustable using an environment variable. I've noticed that hardcoding financial details is a common mistake in many projects. When running a promotion or changing prices, you don't want to have to redeploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks
&lt;/h2&gt;

&lt;p&gt;Stripe does not simply tell your frontend that a payment succeeded and leave it at that. The actual confirmation comes through webhooks. Stripe sends an HTTP request to your backend with the event details. This is the accurate source of information, not the redirect URL or the frontend callback. It’s the webhook that matters.&lt;/p&gt;

&lt;p&gt;However, webhooks can arrive late, arrive twice, or not arrive at all (and then retry). Your code needs to handle all these scenarios.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;handle_stripe_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;State&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HeaderMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AppResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Verify the webhook signature — never trust unverified webhooks&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;
        &lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"stripe-signature"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.to_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;AppError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;BadRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Missing stripe signature"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe_client&lt;/span&gt;
        &lt;span class="nf"&gt;.verify_webhook_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.config.stripe.webhook_secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Check if we've already processed this event (idempotency)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;event_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Already handled, just acknowledge&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Persist the event before processing&lt;/span&gt;
    &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="py"&gt;.event_type&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"checkout.session.completed"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handle_checkout_completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"charge.refunded"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handle_refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"charge.dispute.created"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handle_dispute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="c1"&gt;// Ignore events we don't care about&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here are a few important points to keep in mind:&lt;/p&gt;

&lt;p&gt;First, verify signatures. Every webhook from Stripe is signed. If you skip this step, anyone could send fake "payment succeeded" messages to your endpoint and get free products. In a marketplace that handles money, this check is essential.&lt;/p&gt;

&lt;p&gt;Second, handle events only once. I store every webhook event ID in the database before processing it. If Stripe sends the same event again, I check the ID, see it's already been handled, and return a 200 response without doing anything. This prevents issues like processing the same payment twice or creating duplicate bookings.&lt;/p&gt;

&lt;p&gt;Third, save the original event before I process it. If my processing fails partway through, I still have the event saved. I can replay it, debug it, or solve any issues manually. When money is involved, it’s crucial to have a clear record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payout lifecycle
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. A payout isn't just about sending money to the writer. It has a process that involves several stages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pending → processing → completed
                    → failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a sponsor makes a payment, we create a payout record that has a status of "pending." This record stays in that state until the ad's publication date arrives. After that date, a background job processes it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Background job: process pending payouts for completed bookings&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;process_pending_payouts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AppResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;payout&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find_pending_payouts_for_completed_bookings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;payout&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;stripe_client&lt;/span&gt;&lt;span class="nf"&gt;.create_transfer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;payout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;payout&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mark_completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payout&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payout_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;payout&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Payout transfer failed"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;payout&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mark_failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payout&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why can't we pay writers right away when the sponsor pays? It’s because the ad hasn’t run yet. If the writer doesn’t publish the ad or the sponsor asks for a refund before the date, we need to cancel the payout. Once we send the money, getting it back can be very difficult.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases
&lt;/h2&gt;

&lt;p&gt;The happy path is straightforward: the sponsor pays, the ad runs, and the writer gets paid. But then reality hits:&lt;/p&gt;

&lt;p&gt;Sometimes, sponsors change their minds before the ad is published. When that happens, I need to refund the entire amount, cancel the pending payment, and update the booking status. I must do all of this in one transaction so everything stays consistent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="s"&gt;"charge.refunded"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Cancel the payout if it hasn't been sent yet&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;payout&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;cancel_payout_if_pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Update booking status&lt;/span&gt;
    &lt;span class="nn"&gt;db&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mark_refunded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Failed Transfers.&lt;/em&gt; Sometimes, Stripe cannot send money to a writer's account. This might happen if their bank rejects the transfer or if there are issues with their Stripe account. When this occurs, the payout is marked as &lt;code&gt;failed&lt;/code&gt;. I must inform the writer so they can update their account details and try again.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disputes.&lt;/em&gt; A sponsor's bank may start a chargeback. This is the worst-case scenario. The money gets taken back, and I need to handle it carefully. This involves updating the booking, notifying both parties, and possibly flagging the account.&lt;/p&gt;

&lt;p&gt;Each of these situations requires its own solution, state updates, and notifications. It may not be exciting work, but if we skip any steps, someone will eventually lose money and trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned this week
&lt;/h2&gt;

&lt;p&gt;Payments in a marketplace are not just a feature; they are the foundation of the system. You can't simply add them in later—they impact everything. This includes the booking flow, the notification system, the user dashboard, the admin tools, error handling, and background jobs. Everything is connected.&lt;/p&gt;

&lt;p&gt;If I could give myself one piece of advice, it would be to start with the payment flow first. Design the booking process around how money moves, not the other way around. I followed a similar path, but I had to go back and make changes because I had incorrect assumptions about how payments work.&lt;/p&gt;

&lt;p&gt;Additionally, read Stripe's documentation thoroughly. Don’t just skim the quickstart guide. Pay attention to the webhook best practices, the Connect account lifecycle, and the dispute handling guide. It may be dense, but every page will save you a lot of time later when you need to debug issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The booking process is where a sponsor selects a newsletter, picks a date, writes their advertisement, and makes a payment. This is also where our AI fit scoring comes in, analyzing the advertisement in relation to the newsletter's audience before the writer sees it.&lt;/p&gt;

&lt;p&gt;This is what makes Adsloty unique. Payments are the base, but the booking experience is the core product.&lt;/p&gt;

&lt;p&gt;More soon&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>rust</category>
    </item>
    <item>
      <title>Building in public #2: The auth rabbit hole</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Sat, 21 Feb 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/building-in-public-2-the-auth-rabbit-hole-436i</link>
      <guid>https://dev.to/miraclejudeiv/building-in-public-2-the-auth-rabbit-hole-436i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I spent a week on authentication. It nearly broke me.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Last time, I explained what Adsloty is and why I'm creating it. This time, I want to talk about something less exciting: authentication.&lt;/p&gt;

&lt;p&gt;I knew handling authentication wouldn't be easy. This isn't just a simple blog or a to-do app. Adsloty deals with real money. Sponsors pay for ad slots, and writers receive payments. When money flows through your platform, the phrase "it works on my machine" isn’t enough. You must consider what happens when someone tries to break in.&lt;/p&gt;

&lt;p&gt;I learned this lesson the hard way. Here’s what went wrong and how I fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The brute force problem
&lt;/h2&gt;

&lt;p&gt;My first login setup was very basic. It included an endpoint that accepts an email and password, checks if they match, and returns a token. There were no limits or tracking, which meant someone could easily try many password guesses without any barriers.&lt;/p&gt;

&lt;p&gt;To fix this, I made some changes to the system. I added a counter in the database to track failed login attempts. Each incorrect password increases this counter. After five failed attempts, the account locks for 30 minutes. If the user logs in successfully, the counter resets.&lt;/p&gt;

&lt;p&gt;I also ensured users could easily regain access. If they reset their password, the account unlocks right away. Locking users out of their accounts without a way to recover is a sure way to lose them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// after a failed login attempt
let locked = db::user::record_login_failure(&amp;amp;state.db, user.id).await?;

if user.is_locked() {
    return Err(AppError::ForbiddenWithReason(
        "Account locked. Please wait 30 minutes or reset your password to unlock.".to_string(),
    ));
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// on successful login, reset the counter
db::user::record_login_success(&amp;amp;state.db, user.id, None).await?;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This solution isn’t complicated and doesn’t include advanced features like machine learning to find suspicious activity. However, it works, and I could implement it in a day. I can always add more sophisticated features later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token theft and the rotation fix
&lt;/h2&gt;

&lt;p&gt;This one scared me more.&lt;/p&gt;

&lt;p&gt;I originally used refresh tokens that lasted for 30 days. When you logged in, you would receive a short-lived access token and a long-lived refresh token. You used the refresh token to get new access tokens when the old ones expired. This is standard practice.&lt;/p&gt;

&lt;p&gt;The problem was that if someone stole the refresh token—through a hacked device or a leaked log, for example—they could have access for 30 days without you knowing.&lt;/p&gt;

&lt;p&gt;The solution was token rotation. Now, every time someone uses a refresh token, it gets canceled right away, and a new one is issued. The old token becomes invalid as soon as it is exchanged. So, if an attacker steals a token and tries to use it after the real user has already used it, it will fail. The time an attacker can use a stolen token shrinks from 30 days down to the time it takes the real user to make their next request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// find the existing refresh token
let token_hash = TokenGenerator::hash_token(&amp;amp;refresh_token_plain);
let token = db::token::find_refresh_token(&amp;amp;state.db, &amp;amp;token_hash)
    .await?
    .ok_or(AppError::Unauthorized)?;

// revoke it immediately — it's now dead
db::token::revoke_refresh_token(&amp;amp;state.db, &amp;amp;token_hash, Some("Used for refresh")).await?;

// issue a brand new one
let (new_refresh_token_plain, expires_at) = TokenGenerator::generate_refresh_token();
let new_refresh_token_hash = TokenGenerator::hash_token(&amp;amp;new_refresh_token_plain);

db::token::create_refresh_token(
    &amp;amp;state.db,
    user.id,
    &amp;amp;new_refresh_token_hash,
    expires_at,
    None,
    None,
)
.await?;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three steps. Use it, kill it, replace it. If an attacker replays the old token, step one fails because step two already ran.&lt;/p&gt;

&lt;p&gt;It made the token process more complex, with more database updates and more possible issues to handle. But for a platform that deals with payments, it’s essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cookie bug that only existed in production
&lt;/h2&gt;

&lt;p&gt;This situation was frustrating because everything worked well on my local machine.&lt;/p&gt;

&lt;p&gt;I store authentication tokens in HttpOnly cookies. For production, these cookies must have the &lt;code&gt;Secure&lt;/code&gt; flag so they are only sent over HTTPS. That makes sense. However, I didn't realize at first that a cookie marked &lt;code&gt;Secure&lt;/code&gt; won't be set on &lt;code&gt;localhost&lt;/code&gt; when using plain HTTP.&lt;/p&gt;

&lt;p&gt;After I deployed and tested the login, it just didn’t work. There were no errors. The server set the cookie, but the browser ignored it silently. I spent too much time looking at the network tabs before I figured out what was wrong.&lt;/p&gt;

&lt;p&gt;The solution was to adjust the cookie settings based on the environment. In production, I use &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;HttpOnly&lt;/code&gt;, and &lt;code&gt;SameSite=Lax&lt;/code&gt;. In development, I use the same settings but without the &lt;code&gt;Secure&lt;/code&gt; flag. It took only a few lines of code to fix, but it took hours to find the problem. It was the kind of bug that makes you doubt your career choices.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fn create_auth_cookies(
    access_token: &amp;amp;str,
    refresh_token: &amp;amp;str,
    user_role: &amp;amp;str,
    is_production: bool,
) -&amp;gt; HeaderMap {
    let secure_flag = if is_production { "; Secure" } else { "" };
    let same_site = "; SameSite=Lax";
    let http_only = "; HttpOnly";

    let access_cookie = format!(
        "accessToken={}; Max-Age=3600{}{}{}",
        access_token, http_only, secure_flag, same_site
    );

    // ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then where it's called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let is_production = state.config.env == Environment::Production;
let headers = create_auth_cookies(&amp;amp;access_token, &amp;amp;refresh_token, "writer", is_production);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One boolean. That's all it took. But finding out I needed it cost me an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two users, two completely different flows
&lt;/h2&gt;

&lt;p&gt;Adsloty has two types of users: writers and sponsors. They use the same login form, but the process is very different for each.&lt;/p&gt;

&lt;p&gt;Writers need to provide details about their newsletter, like its name, URL, and subscriber count. They must also complete an onboarding process before they can list ad slots. Sponsors only need to give their company name to get started.&lt;/p&gt;

&lt;p&gt;At first, I tried to use a single registration endpoint with an &lt;code&gt;if&lt;/code&gt; statement to handle both types. This approach became messy within an hour. There were different validation rules, different database tables, and different onboarding steps. Trying to put everything into one handler made the code hard to read.&lt;/p&gt;

&lt;p&gt;So, I separated the registration into two endpoints, one for writers and one for sponsors. I created different profile tables in the database. The JWT includes the user's role, and every protected route checks if the user is logged in and allowed to access that area. A sponsor cannot enter the writer’s dashboard, and a writer cannot access campaign management.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// router — clean separation, no branching
pub fn auth_public_router() -&amp;gt; Router&amp;lt;AppState&amp;gt; {
    Router::new()
        .route("/register/writer", post(register_writer))
        .route("/register/sponsor", post(register_sponsor))
        .route("/login", post(login))
        // ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the JWT generation that carries the role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let role_str = match user.role {
    UserRole::Writer =&amp;gt; "writer",
    UserRole::Sponsor =&amp;gt; "sponsor",
    UserRole::Admin =&amp;gt; "admin",
};

let access_token = jwt_service.generate_access_token(user.id, &amp;amp;user.email, role_str)?;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This method requires more code, but it keeps each path clear and easy to understand. If something goes wrong in the writer registration process, I know exactly where to find the issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logging everything
&lt;/h2&gt;

&lt;p&gt;One decision I made early on that I'm thankful for is logging every authentication event. This includes each successful login, every failed attempt, every token refresh, every email verification, and every password reset. All this information goes into an &lt;code&gt;auth_events&lt;/code&gt; table, which includes timestamps, user IDs, IP addresses, and whether each event succeeded or failed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;db::token::log_auth_event(
    &amp;amp;state.db,
    AuthEventParams {
        user_id: Some(user.id),
        event_type: "login_failed",
        ip_address: None,
        user_agent: None,
        success: false,
        failure_reason: Some("Invalid credentials"),
    },
)
.await?;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every auth function ends with one of these. It's repetitive to write, but when someone emails you saying, 'I can't log in,' you'll be glad you have it.&lt;/p&gt;

&lt;p&gt;I haven't needed this data for a real incident yet. But when you're handling other people's money, saying "I don't know what happened" is not acceptable. When something goes wrong, and it always does, I want to know exactly what happened and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building auth for a payments app
&lt;/h2&gt;

&lt;p&gt;Don't underestimate this task. It's not something you can do in a weekend. Every shortcut you take will likely cause problems when real money is involved.&lt;/p&gt;

&lt;p&gt;Lock accounts after failed login attempts. Rotate your tokens regularly. Make your cookie configuration aware of the environment from the start. Keep user types separate instead of complicating things with conditionals. Also, log more information than you think you need.&lt;/p&gt;

&lt;p&gt;None of this is new or exciting. But making sure you do it correctly, in the right order, and without any gaps is where you will spend your time.&lt;/p&gt;

&lt;p&gt;Next time, I’ll probably write about integrating Stripe Connect and the challenges of managing webhooks in a two-sided marketplace. That has been its own adventure.&lt;/p&gt;

&lt;p&gt;Until then.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>buildinpublic</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building in Public: Adsloty, a Newsletter Ad Marketplace</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Thu, 19 Feb 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/building-in-public-adsloty-a-newsletter-ad-marketplace-2nh8</link>
      <guid>https://dev.to/miraclejudeiv/building-in-public-adsloty-a-newsletter-ad-marketplace-2nh8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I'm building Adsloty. Here's why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you run a newsletter, you've probably dealt with this: a brand reaches out wanting to sponsor an issue. Cool. Then starts the back-and-forth. Pricing negotiations over email. Figuring out dates. Chasing invoices. Copy-pasting ad copy into your template manually. It works, but barely.&lt;/p&gt;

&lt;p&gt;And if you're on the other side (sponsor), a brand trying to get in front of a newsletter audience, it's just as painful. You're DMing creators, waiting days for replies, comparing pricing across spreadsheets, and hoping the audience actually fits your product.&lt;/p&gt;

&lt;p&gt;I kept seeing this friction and thought: there has to be a better way.&lt;/p&gt;

&lt;p&gt;So I started building Adsloty.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F46lzs1jedv1f3f1gvzab.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F46lzs1jedv1f3f1gvzab.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Adsloty is a self-service marketplace where newsletter writers list their ad slots and sponsors book them directly. No emails back and forth. No awkward pricing conversations. Writers set their price, sponsors browse and book, and the platform handles payments and payouts.&lt;/p&gt;

&lt;p&gt;Think of it like Calendly meets Gumroad, but specifically for newsletter ads.&lt;/p&gt;

&lt;p&gt;The part I'm most excited about: when a sponsor submits an ad, it gets analyzed by AI before the writer even sees it. It scores how well the ad fits the newsletter's audience, checks the tone, rates the clarity of the copy, and gives the writer an estimated click range. So instead of guessing whether an ad is a good fit, you get actual data to help you decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm building it
&lt;/h2&gt;

&lt;p&gt;Honestly? Because I wanted it to exist.&lt;/p&gt;

&lt;p&gt;The newsletter space is booming. More people are starting newsletters, more brands want to reach those audiences, but the infrastructure to connect them is still mostly manual. The tools that exist are either too enterprise (built for massive publishers) or too scrappy (a Typeform and a Stripe link).&lt;/p&gt;

&lt;p&gt;I wanted something in the middle. Something a solo newsletter creator could set up in minutes, embed on their site, and start earning from — without hiring a sales team or building a media kit.&lt;/p&gt;

&lt;p&gt;And I wanted to build it myself. Not because I have to, but because I genuinely enjoy solving this kind of problem end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tech stack
&lt;/h2&gt;

&lt;p&gt;This is the part some of you are here for, so let me break it down.&lt;/p&gt;

&lt;p&gt;The backend is Rust, running on Axum. I know, I know Rust for a web app might seem like overkill, probably. But I'm learning a lot, and the performance headroom means I won't need to rewrite anything when traffic picks up. But I've grown to love writing Rust. The type system catches so many bugs before they even make it to runtime. SQLx gives me compile-time checked SQL queries against Postgres, which means if my query doesn't match the schema, it won't even compile. That alone has saved me hours of debugging.&lt;/p&gt;

&lt;p&gt;The frontend is Next.js with the App Router, React 19, and Tailwind CSS. State management is Zustand for global state and TanStack Query for server state. Forms are handled with React Hook Form and Zod for validation. Nothing groundbreaking here, I picked tools that let me move fast without fighting the framework.&lt;/p&gt;

&lt;p&gt;For payments, it's Stripe all the way. Checkout Sessions for sponsors, Connect for paying out writers. The platform takes a 10% fee on each booking, which is how the business sustains itself. During beta, that fee is waived for the first three months.&lt;/p&gt;

&lt;p&gt;The AI scoring uses Google's Gemini API. When a sponsor submits an ad, the backend sends the ad copy along with the newsletter's audience data to Gemini and gets back a structured analysis: fit score, tone, clarity, estimated clicks, and recommendations. It's not meant to replace the writer's judgment — it's meant to give them a head start.&lt;/p&gt;

&lt;p&gt;For everything else: Cloudinary for image hosting, Sentry for error tracking on both frontend and backend, Argon2 for password hashing, JWT with refresh token rotation for auth, and Governor for rate limiting.&lt;/p&gt;

&lt;p&gt;The database is Postgres. Nothing fancy. Just well-structured tables, proper indexes, and views for the dashboard queries.&lt;/p&gt;

&lt;p&gt;I will use this project to improve my skills in DevOps by deploying and managing it with Kubernetes. This involves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Containerizing services with Docker
and- Deploying both backend and frontend in a Kubernetes cluster &lt;/li&gt;
&lt;li&gt;Managing settings with ConfigMaps and Secrets &lt;/li&gt;
&lt;li&gt;Setting up Services and Ingress &lt;/li&gt;
&lt;li&gt;Managing scaling and rolling updates &lt;/li&gt;
&lt;li&gt;Ensuring observability and health checks &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The aim is to treat this as a production system, not just an application.&lt;/p&gt;

&lt;p&gt;I'm going to keep building in public. I'll share what's working, what's breaking, what decisions I'm making, and why. Not the curated everything is going great version. The real version.&lt;/p&gt;

&lt;p&gt;If you're running a newsletter and want to try it out when it launches, or if you're a brand looking for a better way to sponsor newsletters, I'd love to hear from you.&lt;/p&gt;

&lt;p&gt;More soon.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>rust</category>
      <category>startup</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>CSS Preprocessors: LESS and SASS</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Sun, 30 Jan 2022 18:21:07 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/css-preprocessors-less-and-sass-160h</link>
      <guid>https://dev.to/miraclejudeiv/css-preprocessors-less-and-sass-160h</guid>
      <description>&lt;p&gt;CSS is used to style content on a web page written in HTML, giving it a very nice look and it determines where content is displayed on the site. It also helps your website attract potential customers to your site.&lt;/p&gt;

&lt;p&gt;But sometimes, using regular CSS to style larger and complex web pages can be a pain in the ass it’s error-prone; and it’s time-consuming. It causes us to move slowly and it makes it harder to maintain.&lt;/p&gt;

&lt;p&gt;Another disadvantage of using regular CSS is that it works differently on different browsers. There might be cross-browser issues while using regular CSS. IE and Opera support CSS as different logic.&lt;/p&gt;

&lt;p&gt;With CSS Preprocessors, it deals with compatibility issues with browsers by being compatible across different browsers. It makes the CSS structure easier to read and maintain. A CSS preprocessor is a scripting language that extends CSS and then compiles it back to regular CSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goal
&lt;/h2&gt;

&lt;p&gt;In this article, we will discuss two types of CSS preprocessors: LESS and SASS. we will discuss, differentiate and show the similarities between the two preprocessors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is LESS?
&lt;/h2&gt;

&lt;p&gt;Less stands for Leaner Style Sheets. LESS is a dynamic preprocessor style sheet language that can be used on different browsers and can be compiled into Cascading Style Sheets (CSS) and run on the client-side or server-side. It is an open-source project and it was previously written in Ruby but now it's been replaced with JavaScript which makes it run on the client-side and complies with data very fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is SASS?
&lt;/h2&gt;

&lt;p&gt;SASS stands for Syntactically Awesome Style Sheet. SASS is a preprocessor scripting language that is interpreted or compiled into Cascading Style Sheets (CSS). It is the superset of CSS and it is based on Ruby. &lt;br&gt;
My friend Isaac stated SASS as&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SASS is short for Syntactically Awesome Style Sheet. I know, I know, syntactically sounds like a lot no? Well, what it means is that the lines( syntax ) of SASS code are so awesome that it is known as CSS with superpowers. 😀&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;SASS consists of two syntaxes:&lt;/p&gt;

&lt;p&gt;Original SASS syntax (indented syntax) - It uses indentation to separate code blocks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;button
    display: inline-flex
    background-color: #111
    color: #fff
    padding: 1em 2em
    border: none
    border-radius: 25px
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file extension is &lt;code&gt;.sass&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SCSS (sassy CSS)&lt;/strong&gt; - It has the formatting of CSS, it uses braces to denote code blocks. The file extension is &lt;code&gt;.scss&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Similarities and Differences between LESS and SASS
&lt;/h2&gt;

&lt;p&gt;Both LESS and SASS have similar features but what differs is the way they are written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Variables
&lt;/h2&gt;

&lt;p&gt;Both CSS preprocessors allow the use of variables in your stylesheet. Variables are one of the most commonly used items in any programming language. LESS and SASS allows the user to define a value once and reuse it throughout the entire stylesheet. Therefore, keep your code consistent and from repetition.&lt;/p&gt;

&lt;p&gt;SASS declares variable with a dollar symbol ($)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ff:  "Lato", sans-serif;
$p-color: #111;

// it can be applied anywhere in our stylesheet
body {
  font-family: $ff;
  color: $p-color;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LESS declare a variable with @ symbol&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@ff:  "Lato", sans-serif;
@p-color: #111;

// it can be applied anywhere in our stylesheet
#header {
  font-family: $ff;
  color: $p-color;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mathematical operator
&lt;/h2&gt;

&lt;p&gt;They both provide support for some arithmetical operations. +, -, /,* can be used to operate on any number, color or variable. This saves a lot of time when you are using variables and you feel like working on simple mathematics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SASS&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$fontSize: 8px;
body {
   font-size: $fontSize * 2;
}

$big-screen: 1016px;
$small-screen: $big-screen / 2;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;LESS&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@fontSize: 8px;
body {
   font-size: @fontSize * 2;
}

@big-screen: 1016px;
@small-screen: @big-screen / 2;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mixins
&lt;/h2&gt;

&lt;p&gt;They support the use of Mixins. Mixins are used to create styles that can be used and reused anywhere in your stylesheet without recreating non-semantic classes.&lt;/p&gt;

&lt;p&gt;In SASS a @mixin directive is used to define the mixin and &lt;a class="mentioned-user" href="https://dev.to/include"&gt;@include&lt;/a&gt; is used to include mixin in a document.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.button {
  box-shadow: 0px 0px 3px 0 rgba(0,0,0,0.3)
  border-radius: 30px;
  transistion: transition: all 0.5s ease-in-out;
}

// to include mixin in any part of our stylsheet:
.cta {
  color: #111;
  @button;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LESS make use of . symbols both in defining and including them in our stylesheet and brackets at the end, just like how you call a function in javascript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.button {
  box-shadow: 15px 5px 3px 0 rgba(0,0,0,0.3)
  border-radius: 30px;
  transistion: transition: all 0.5s ease-in-out;
}

.cta {
  color: #111;
  .button();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mixins can also take arguments to enhance their utility, this is called PARAMETRIC MIXINS.&lt;/p&gt;

&lt;p&gt;Examples of parametric mixins in SASS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@mixin round-borders ($radius) {
  border-radius: $radius;
  -moz-border-radius: $radius;
  -webkit-border-radius: $radius;
}

// And here’s how we can mix it into various rulesets:
.box {
  @include $round-borders(5px);
}

.button {
  @include $round-borders(30px);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;LESS&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.round-borders (@radius) {
  border-radius: @radius;
  -moz-border-radius: @radius;
  -webkit-border-radius: @radius;
}

// And here’s how we can mix it into various rulesets:
.box {
  .round-borders(5px);
}

.button {
  .round-borders(30px);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parametric mixins can also have default values for their parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; @mixin round-borders ($radius: 5px) {
  border-radius: $radius;
  -moz-border-radius: $radius;
  -webkit-border-radius: $radius;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A mixin can also be used in another mixin, and it can also be used to return values. Mixins can store different values or parameters and call that function using @return.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nesting
&lt;/h2&gt;

&lt;p&gt;Nesting is the enclosure of one block of code inside another. It helps your code to be concise and it imitates the structure of your HTML. It also prevents us from rewriting selectors multiple times and makes your code easier to read and maintain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;header {
  color: black;
  nav {
    font-size: 12px;
  }
  .logo {
    width: 300px;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is also possible to use pseudo-selectors with your mixins using this method.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.circle {
   width: 500px;
  height: 500px;
  position: relative;

  &amp;amp;:after {
    content: " ";
    position: absolute;
    top: 0;
    left: 0;
    height: 200px;
    widht: 200px;
    background: yellow;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NOTE: you can import predefine CSS preprocessor classes into other classes&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;In this article, we introduced and talked about two CSS preprocessors: LESS and SASS, their similarities and differences, and why you should start using it instead of regular CSS. They offer so much more, they allow you to use functions and conditional statements. Learn more about &lt;a href="https://sass-lang.com/"&gt;SASS&lt;/a&gt; and &lt;a href="https://lesscss.org/"&gt;LESS&lt;/a&gt; .****&lt;/p&gt;

</description>
      <category>css</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to use Page Queries and Static Queries in Gatsby Application Using GraphQL</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Sat, 29 Jan 2022 21:35:48 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/how-to-use-page-queries-and-static-queries-in-gatsby-application-using-graphql-9a6</link>
      <guid>https://dev.to/miraclejudeiv/how-to-use-page-queries-and-static-queries-in-gatsby-application-using-graphql-9a6</guid>
      <description>&lt;p&gt;Gatsby is known for building blazing fast websites and apps by leveraging a combination of front-end technologies such as ReactJS, Webpack and GraphQL. It is also known for its massive ecosystem of plugins that use different kinds of plugins to pull data from different data sources into Gatsby. Once it gets the desired data, it uses GraphQL to query that data.&lt;/p&gt;

&lt;p&gt;Gatsby is not just a static site generator that builds static websites (coding individual HTML pages and getting those pages ready to serve users ahead of time) but also a progressive app generator, where you can leverage all the static functionality and still be able to query the dynamic data (renders differently based on any number of changing data inputs, such as the user's location, time of day, or user actions.).&lt;/p&gt;

&lt;h3&gt;
  
  
  There are two parts to any web app
&lt;/h3&gt;

&lt;p&gt;Static&lt;br&gt;
Dynamic&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Today, we will focus on the static part of a web app.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Static Data
&lt;/h2&gt;

&lt;p&gt;Just as the name may be, it means data that is fixed. A fixed data set/data that remains the same after it's collected or websites that contain stable contents that are displayed using web pages. Examples like a product detail page. You don’t change product images or product attributes every few minutes.&lt;/p&gt;

&lt;p&gt;In Gatsby, we can query this type of data with two types of queries. Static Query and Page Query. When building our website and apps with Gatsby, we sometimes don’t know when to use Page Query and Static Query. In this article, we will know the difference between Page Query and Static Query and when to use them.&lt;/p&gt;

&lt;p&gt;Before we get started, let us know the meaning of the query.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Query in programming means a question or request that is made by a user or another computer or device. For example, Google Search, the text you enter is known as query and each word is known as a keyword. In the Database a query is a field used to locate information within a database or another location.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Note: The action of performing a query can be referred to as querying. When looking something up in a database, you're querying the database.&lt;/p&gt;
&lt;h2&gt;
  
  
  Static Query
&lt;/h2&gt;

&lt;p&gt;Static Query is used to query data inside a component. In Gatsby, they are not dependent on an external value to fetch the data. We can use them anywhere, including on the pages. Examples like layouts and navbar. Gatsby handles Static GraphQL queries in two varieties. Static queries using the  component, and static queries using the useStaticQuery hook.&lt;/p&gt;
&lt;h2&gt;
  
  
  Using Static Query Component
&lt;/h2&gt;

&lt;p&gt;Gatsby v2 introduces the Static Query component , a new API that allows components to retrieve data via a GraphQL query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { graphql, Link, useStaticQuery } from 'gatsby';
import React from 'react';

export default function Navbar() {
  return (
    &amp;lt;StaticQuery
      query={graphql`
        query {
          site {
            siteMetadata {
              title
            }
          }
        }
      `}
      render={data =&amp;gt; (
        &amp;lt;nav&amp;gt;
            &amp;lt;Link to='/'&amp;gt;{data.site.siteMetadata.title}&amp;lt;/Link&amp;gt;
            &amp;lt;div className="links"&amp;gt;
              &amp;lt;Link to="/"&amp;gt;Home&amp;lt;/Link&amp;gt;
              &amp;lt;Link to="/about"&amp;gt;About&amp;lt;/Link&amp;gt;
              &amp;lt;Link to="/projects"&amp;gt;Projects&amp;lt;/Link&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/nav&amp;gt;
      )}
    /&amp;gt;
  )
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Using Static Query Hook
&lt;/h2&gt;

&lt;p&gt;useStaticQuery is a hook that takes a GraphQL query and returns your data. That's it, no more Render Props necessary to use a Static Query It simplifies the use of a static query component  and makes it cleaner, brief and straight to the point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { graphql, Link, useStaticQuery } from 'gatsby';
import React from 'react';

export default function Navbar() {
  const data = useStaticQuery(graphql`
    {
      site{
        siteMetadata{
          title
        }
      }
    }
  `);
  const { title } = data.site.siteMetadata;
  return (
    &amp;lt;nav&amp;gt;
        &amp;lt;Link to="/"&amp;gt;{title}&amp;lt;/Link&amp;gt;
        &amp;lt;div className="links"&amp;gt;
          &amp;lt;Link to="/"&amp;gt;Home&amp;lt;/Link&amp;gt;
          &amp;lt;Link to="/about"&amp;gt;About&amp;lt;/Link&amp;gt;
          &amp;lt;Link to="/projects"&amp;gt;Projects&amp;lt;/Link&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/nav&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Notice a few things here&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;We're using another tagged template literal to pass on our query.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;We no longer need the name of the query (it was just MyQuery).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;We've added the constant for the data above the return of our JSX.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;We're using the data inside of our JSX to get the title (data.site.siteMetadata.title).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Another thing about using static query hooks is that you can create your own custom hooks that use useStaticQuery in them. For example, you need to query the site title several times in your app. Instead of a call to useStaticQuery in each component, you can extract it out to a custom hook. You can learn how to create &lt;a href="https://www.gatsbyjs.com/blog/2019-02-20-introducing-use-static-query/#:~:text=React%20Hooks%20are%20cool.,use%20state%20in%20functional%20components."&gt;custom hooks&lt;/a&gt; in Gatsby&lt;/p&gt;

&lt;h2&gt;
  
  
  Page Query
&lt;/h2&gt;

&lt;p&gt;Gatsby’s graphql tag enables page components to query data via a GraphQL query. If we want to query data for specific pages we generally opt for Page Query. For example, our About page will use a page query. Generally, we use Page Queries to dynamically generate templates/pages. For example, think of the project detail pages where you display all the details about your project on your portfolio website, if you have so many projects it means so many pages. We can do this by using createPages hook in your gatsby-node.js file. All we need is a path and a unique identifier for each Project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const path = require('path');

exports.createPages = async ({ graphql, actions }) =&amp;gt; {

    const { data } = await graphql(`
        query Projects {
            allMarkdownRemark(sort: {fields: frontmatter___date, order: DESC}) {
                nodes {
                    frontmatter {
                        slug
                    }
                }
            }
        }
    `);

    data.allMarkdownRemark.nodes.forEach(node =&amp;gt; {
        actions.createPage({
            path: '/projects/' + node.frontmatter.slug,
            component: path.resolve('./src/templates/project-details.js'),
            context: { slug: node.frontmatter.slug }
        });
    });
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take a look at the above code. All we are doing is fetching a unique ID(slug) related to each project and its path alias for each project from a data source which in our case is a slug and that is the slug of that particular project. Then we are passing this data to our template file as a context value. We can access this value at &lt;strong&gt;/src/templates/ProjectDetail.js&lt;/strong&gt;. Now in our &lt;strong&gt;ProjectDetail&lt;/strong&gt; component, we can use the unique ID(slug) to query data for each project. Take a look at the code below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import React from 'react';
import Layout from '../components/Layout';
import Img from 'gatsby-image';
import * as styles from '../styles/project-details.module.css';
import { graphql } from 'gatsby';

export default function ProjectDetails({ data }) {
    const { stack, title, featuredImg} = data.markdownRemark.frontmatter
    return (
        &amp;lt;Layout&amp;gt;
            &amp;lt;div className={styles.details}&amp;gt;
                &amp;lt;h2&amp;gt;{title}&amp;lt;/h2&amp;gt;
                &amp;lt;h3&amp;gt;{stack}&amp;lt;/h3&amp;gt;
                &amp;lt;div className={styles.featured}&amp;gt;
                    &amp;lt;Img fluid={featuredImg.childImageSharp.fluid} /&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/Layout&amp;gt;
    )
}
export const query = graphql`
query ProjectsDetails($slug: String) {
    markdownRemark(frontmatter: {slug: {eq: $slug}}) {
      frontmatter {
        stack
        title
        featuredImg {
          childImageSharp {
            fluid {
              ...GatsbyImageSharpFluid
            }
          }
        }
      }
    }
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are using the slug variable to get a specific markdown file and then once we have that we are getting all the data from it and then we can access all of this data inside this &lt;strong&gt;projectDetails&lt;/strong&gt; components. Gatsby uses the variable value at build time to generate the Project details for each project. To learn more about page queries, visit this &lt;a href="https://www.gatsbyjs.org/docs/page-query/"&gt;link&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Difference between Page Query and Static Query
&lt;/h2&gt;

&lt;p&gt;Page queries can accept variables (via pageContext) but can only be added to page components.&lt;br&gt;
Static Query does not accept variables. This is because static queries are used inside specific components and can appear lower in the component tree including pages.&lt;br&gt;
Depending on the use case, if we want to query data for specific pages we generally opt for Page Query whereas Static Query is used to query data inside a component.&lt;br&gt;
A single component that's used throughout the application will use a static query, whereas a dynamic page like our About page will use a page query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Hope you now have a nice overview of how, when and where to use queries in your gatsby applications.&lt;/p&gt;

&lt;p&gt;If you would like to chat or have any questions, drop them in the comments I’m always happy to talk.&lt;/p&gt;

&lt;p&gt;Thanks for reading and happy coding!&lt;/p&gt;

</description>
      <category>react</category>
      <category>gatsby</category>
      <category>graphql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Recipe layout | Codepen-challenge</title>
      <dc:creator>Jude Miracle</dc:creator>
      <pubDate>Tue, 15 Jun 2021 00:33:22 +0000</pubDate>
      <link>https://dev.to/miraclejudeiv/recipe-layout-codepen-challenge-2b5i</link>
      <guid>https://dev.to/miraclejudeiv/recipe-layout-codepen-challenge-2b5i</guid>
      <description>&lt;p&gt;&lt;a href="https://codepen.io/JudeIV/pen/abJPdBW"&gt;https://codepen.io/JudeIV/pen/abJPdBW&lt;/a&gt;&lt;/p&gt;

</description>
      <category>codepen</category>
      <category>codenewbie</category>
      <category>css</category>
    </item>
  </channel>
</rss>
