Verifizierte Verkaufsbenachrichtigung mit… | LaunchFast
LaunchFast Logo LaunchFast
Blog
2.407 Wörter 13 Min. Lesezeit

Verifizierte Verkaufsbenachrichtigung mit Polar und Astro erstellen

Zeigen Sie echte Kaufaktivität auf Ihrer Marketing-Website, indem Sie aktuelle Polar-Bestellungen serverseitig abrufen, Antworten am Edge cachen und eine schlanke verifizierte Benachrichtigung im Browser rendern – ohne gefälschte Pop-ups.

Rishi Raj Jain
Rishi Raj Jain Autor
Verifizierte Verkaufsbenachrichtigung mit Polar und Astro erstellen

„Jemand aus Deutschland hat vor 2 Stunden Pro gekauft.“ Diese Zeile findet man auf fast jeder SaaS-Landingpage – und oft ist sie komplett erfunden. Besser ist es, echte Bestellungen aus Ihrem Zahlungsanbieter anzuzeigen. Wenn Sie mit Polar verkaufen, können Sie aktuelle Bestellungen über die offizielle API abrufen, einen kleinen JSON-Endpunkt aus Ihrer Astro-Anwendung bereitstellen und dem Client ein dezentes Toast mit dem Label Verifiziert rendern lassen – damit Besucher wissen, dass das Signal aus Ihrem Checkout stammt und nicht aus einem Zufallsgenerator.

Sponsored

Hochwertige Starter-Kits mit integriertem Authentifizierungsfluss (Auth.js), Objekt-Uploads (AWS, Clouflare R2, Firebase Storage, Supabase Storage), integrierten Zahlungen (Stripe, LemonSqueezy), E-Mail-Verifizierungsablauf (Resend, Postmark, Sendgrid) und viel mehr . Kompatibel mit jeder Datenbank (Redis, Postgres, MongoDB, SQLite, Firestore).

Get all 3 kits Bundle ↗

One-time license · Lifetime updates

In dieser Anleitung binden Sie das Polar SDK in eine Astro-Server-Route ein, deployen auf Vercel und fügen eine schwebende Benachrichtigungskomponente hinzu, die durch aktuelle Verkäufe rotiert. Das Muster entspricht dem, das wir auf dieser Website verwenden: Geheimnisse nur auf dem Server, edge-freundliche Cache-Header und ein minimales Client-Skript.

Inhaltsverzeichnis

Voraussetzungen

Sie benötigen:

  • Node.js 20 oder höher
  • Ein Polar-Konto mit mindestens einem Produkt und (für aussagekräftige Demos) einigen Test- oder echten Bestellungen
  • Ein Vercel-Konto, wenn Sie dort deployen möchten

Wie die Bausteine zusammenspielen

1. Browser
→ GET /api/sale (Ihre Astro-Route)
→ Erhält JSON: Produktname, ISO-Zeitstempel, optionales Rechnungsland-Label
2. Astro-API-Route (Node / Vercel Function)
→ Verwendet POLAR_ACCESS_TOKEN mit Polar SDK
→ Listet Bestellungen (neueste zuerst), mappt auf eine kleine öffentliche Struktur
→ Setzt Cache-Control für CDN-Caching
3. Polar API
→ Autoritative Liste von Bestellungen und Positionen

Das Verifiziert-Badge in der UI signalisiert, dass die Nutzlast zum Anfragezeitpunkt serverseitig von Polar abgerufen wurde (oder aus einer gecachten Antwort stammt, die so erzeugt wurde). Halten Sie das Access Token aus Client-Bundles und Umgebungspräfixen fern.

Polar-Zugangstoken erstellen

  1. Öffnen Sie das Polar-Dashboard und melden Sie sich an.
  2. Gehen Sie zu den Settings Ihrer Organisation und suchen Sie Developers (die Bezeichnung kann leicht variieren).
  3. Erstellen Sie ein Token mit der Berechtigung orders:read.
  4. Kopieren Sie das Token einmalig und speichern Sie es sicher – Sie fügen es in Vercel und lokal in .env als POLAR_ACCESS_TOKEN ein. (Committen Sie diesen Wert niemals in Git!)

Astro mit dem Vercel-Adapter einrichten

Erstellen Sie ein neues Projekt (oder verwenden Sie ein bestehendes mit output: 'server'):

Terminal window
npm create astro@latest polar-sale-badge
cd polar-sale-badge

Fügen Sie den Vercel-Adapter hinzu, damit API-Routen beim Deploy als Serverless Functions laufen:

Terminal window
npx astro add vercel --yes

Stellen Sie sicher, dass astro.config.mjs server-Output verwendet. Eine minimale Konfiguration sieht so aus:

import { defineConfig } from 'astro/config'
import vercel from '@astrojs/vercel'
export default defineConfig({
output: 'server',
adapter: vercel(),
})

Installieren Sie das Polar SDK:

Terminal window
npm install @polar-sh/sdk

API-Route implementieren

Fügen Sie src/pages/api/sale.ts hinzu. Die wichtigen Details:

  1. export const prerender = false, damit Astro diese URL nicht statisch zur Build-Zeit backt.
  2. Instanziieren Sie Polar mit import.meta.env.POLAR_ACCESS_TOKEN.
  3. Rufen Sie polar.orders.list mit Sortierung neueste zuerst und einem kleinen limit auf, und mappen Sie jedes Element auf das, was die UI braucht (Produktname, createdAt, optionales Rechnungsland).
  4. Geben Sie Cache-Control zurück, damit das CDN von Vercel das JSON einen Tag cachen kann und gleichzeitig Revalidierung erlaubt (max-age=0, s-maxage=86400 ist ein sinnvoller Ausgangspunkt).
src/pages/api/sale.ts
export const prerender = false
import { Polar } from '@polar-sh/sdk'
const countryCodeToName: { [key: string]: string } = {
AF: 'Afghanistan',
242 collapsed lines
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua and Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia',
BA: 'Bosnia and Herzegovina',
BW: 'Botswana',
BV: 'Bouvet Island',
BR: 'Brazil',
IO: 'British Indian Ocean Territory',
BN: 'Brunei Darussalam',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
CV: 'Cape Verde',
KY: 'Cayman Islands',
CF: 'Central African Republic',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos (Keeling) Islands',
CO: 'Colombia',
KM: 'Comoros',
CG: 'Congo',
CD: 'Congo, Democratic Republic of the',
CK: 'Cook Islands',
CR: 'Costa Rica',
CI: "Côte d'Ivoire",
HR: 'Croatia',
CU: 'Cuba',
CY: 'Cyprus',
CZ: 'Czech Republic',
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
ET: 'Ethiopia',
FK: 'Falkland Islands (Malvinas)',
FO: 'Faroe Islands',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories',
GA: 'Gabon',
GM: 'Gambia',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GG: 'Guernsey',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HM: 'Heard Island and McDonald Islands',
VA: 'Holy See (Vatican City State)',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran, Islamic Republic of',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle of Man',
IL: 'Israel',
IT: 'Italy',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
KP: "Korea, Democratic People's Republic of",
KR: 'Korea, Republic of',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: "Lao People's Democratic Republic",
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libyan Arab Jamahiriya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macao',
MK: 'Macedonia, The Former Yugoslav Republic of',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
FM: 'Micronesia, Federated States of',
MD: 'Moldova, Republic of',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
MP: 'Northern Mariana Islands',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PW: 'Palau',
PS: 'Palestinian Territory, Occupied',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines',
PN: 'Pitcairn',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
RE: 'Réunion',
RO: 'Romania',
RU: 'Russian Federation',
RW: 'Rwanda',
BL: 'Saint Barthélemy',
SH: 'Saint Helena',
KN: 'Saint Kitts and Nevis',
LC: 'Saint Lucia',
MF: 'Saint Martin (French part)',
PM: 'Saint Pierre and Miquelon',
VC: 'Saint Vincent and the Grenadines',
WS: 'Samoa',
SM: 'San Marino',
ST: 'Sao Tome and Principe',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia and the South Sandwich Islands',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan',
SR: 'Suriname',
SJ: 'Svalbard and Jan Mayen',
SZ: 'Swaziland',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syrian Arab Republic',
TW: 'Taiwan, Province of China',
TJ: 'Tajikistan',
TZ: 'Tanzania, United Republic of',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad and Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks and Caicos Islands',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates',
GB: 'United Kingdom',
US: 'United States',
UM: 'United States Minor Outlying Islands',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VE: 'Venezuela',
VN: 'Viet Nam',
VG: 'Virgin Islands, British',
VI: 'Virgin Islands, U.S.',
WF: 'Wallis and Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe',
}
export async function GET() {
const polar = new Polar({
accessToken: import.meta.env.POLAR_ACCESS_TOKEN,
})
let orders: { date: string; billingCountry: string | undefined; name: string }[] = []
const result = await polar.orders.list({
sorting: ['-created_at'],
limit: 10,
})
for await (const page of result) {
orders = page.result.items
.map((item) => ({
name: item.product.name,
date: item.createdAt.toISOString(),
billingCountry: item.billingAddress?.country ? countryCodeToName[item.billingAddress.country] || item.billingAddress.country : undefined,
}))
.filter((order) => order.name.includes('LaunchFast'))
.slice(0, 5)
break
}
return new Response(JSON.stringify(orders), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=0, s-maxage=86400',
},
})
}

Nach Produkt filtern: In der Produktion möchten Sie eventuell nur bestimmte SKUs anzeigen (z. B. Namen, die Ihre Produktfamilie enthalten). Nach dem Mapping hält .filter((order) => order.name.includes('YourBrand')) das Toast relevant.

Ländernamen: Polar liefert einen Ländercode (wie DE, US usw.) im Feld billingAddress.country, nicht den vollständigen Ländernamen. Um den korrekten Ländernamen anzuzeigen, mappen Sie diesen Code mit einer Lookup-Tabelle – wie in der API-Route oben gezeigt.

Umgebungsvariablen lokal und auf Vercel

Erstellen Sie .env (und fügen Sie .env zu .gitignore hinzu, falls noch nicht geschehen):

Terminal window
POLAR_ACCESS_TOKEN="polar_oat_…"

Für Vercel:

  1. Projekt → Settings → Environment Variables
  2. Fügen Sie POLAR_ACCESS_TOKEN für Production hinzu (und Preview, wenn Sie PR-Deployments testen)
  3. Deployen Sie erneut, damit der neue Wert verfügbar ist

Lokal starten:

Terminal window
npm run dev

Besuchen Sie http://localhost:4321/api/sale und prüfen Sie, ob Sie ein JSON-Array erhalten.

Schwebende Benachrichtigung für aktuelle Verkäufe hinzufügen

Erstellen Sie eine Komponente wie src/components/SalesPopup.astro mit folgendem Code:

src/components/SalesPopup.astro
---
---
<script>
let popupTimeout
let salesItems: any[] = []
let currentIndex = 0
function showSalePopup(item) {
// Entfernen, falls bereits vorhanden
const existing = document.getElementById('sale-popup')
if (existing) existing.remove()
const popup = document.createElement('a')
popup.id = 'sale-popup'
popup.href = '/#pricing'
popup.className = `fixed bottom-6 right-6 z-50 bg-white shadow-lg rounded-xl px-6 py-4 flex flex-col items-start min-w-[270px] max-w-xs border border-gray-100 transition-transform duration-500 translate-y-8 opacity-0 pointer-events-none`
popup.innerHTML = `
<div class="text-sm text-gray-700">
Jemand aus <span class="font-bold text-branding">${item.billingCountry ? item.billingCountry : 'Unbekannt'}</span> hat <span class="font-bold text-branding">${item.name}</span> gekauft
</div>
<div class="mt-1 text-xs text-gray-500 flex items-center gap-2">
<span>${timeAgo(item.date)}</span>
<span>| <svg class="inline-block size-3 text-branding" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M269.4 2.9C265.2 1 260.7 0 256 0s-9.2 1-13.4 2.9L54.3 82.8c-22 9.3-38.4 31-38.3 57.2c.5 99.2 41.3 280.7 213.6 363.2c16.7 8 36.1 8 52.8 0C454.7 420.7 495.5 239.2 496 140c.1-26.2-16.3-47.9-38.3-57.2L269.4 2.9zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"></path></svg> <span class="text-branding font-semibold">Verifiziert</span></span>
</div>
`
document.body.appendChild(popup)
// Einblenden
setTimeout(() => {
popup.classList.remove('translate-y-8', 'opacity-0', 'pointer-events-none')
popup.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto')
}, 10)
// Nach 3 s entfernen
clearTimeout(popupTimeout)
popupTimeout = setTimeout(() => {
popup.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto')
popup.classList.add('translate-y-8', 'opacity-0', 'pointer-events-none')
setTimeout(() => popup.remove(), 500)
}, 3000)
}
function timeAgo(dateStr) {
const now = new Date()
const date = new Date(dateStr)
const diff = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diff < 60) return `vor ${diff} Sekunden`
if (diff < 3600) return `vor ${Math.floor(diff / 60)} ${Math.floor(diff / 60) === 1 ? 'Minute' : 'Minuten'}`
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} ${Math.floor(diff / 3600) === 1 ? 'Stunde' : 'Stunden'}`
return `vor ${Math.floor(diff / 86400)} ${Math.floor(diff / 86400) === 1 ? 'Tag' : 'Tagen'}`
}
function fetchAndShow() {
if (Array.isArray(salesItems) && salesItems.length > 0) {
// Sequenziell, nicht zufällig
const item = salesItems[currentIndex]
if (item && item.billingCountry) showSalePopup(item)
currentIndex = (currentIndex + 1) % salesItems.length
}
}
fetch('/api/sale')
.then((res) => res.json())
.then((res) => {
salesItems = Array.isArray(res) ? res : []
currentIndex = 0
fetchAndShow()
setInterval(fetchAndShow, 10000)
})
</script>

Der Code oben macht Folgendes:

  • fetch('/api/sale') nach dem Laden der Seite.
  • Speichert das Array im Speicher und rotiert Einträge in einem Intervall (z. B. alle 10 Sekunden).
  • Rendert eine kleine fixierte Karte (unten rechts ist üblich) mit:
    • Land (oder „Unbekannt“, wenn Sie keine grobe Standortangabe zeigen möchten)
    • Produktname
    • Relative Zeit („vor 3 Minuten“) aus dem ISO-date
    • Eine Verifiziert-Zeile mit Schild-Icon, die verdeutlicht, dass die Daten von Polar stammen

Halten Sie das Skript schlank: schlichte DOM-APIs oder ein kleines timeAgo-Hilfsmittel reichen aus. Verlinken Sie die Karte mit Ihrem Pricing-Bereich, damit der Hinweis eine klare Handlungsaufforderung hat.

Wenn Sie strengere Datenschutzerwartungen erfüllen möchten, zeigen Sie nur die Region an oder lassen Sie Geografie ganz weg und zeigen nur Produkt und Zeit.

Ergebnis

Verifizierte Verkaufsbenachrichtigung mit Polar und Astro erstellen

Fazit

Sie haben jetzt einen Polar-gestützten Verkaufs-Feed: Die Astro-API-Route verwaltet das Geheimnis, Vercel liefert sie als Serverless Function mit CDN-Caching, und der Browser sieht nur anonymisierte Felder, die Sie freigeben. Passen Sie das Styling an Ihre Marke an, optimieren Sie Intervalle und Filter – und Sie erhalten glaubwürdigen Social Proof, ohne eine separate Analytics-Pipeline für „gefälschte“ Benachrichtigungen zu pflegen.

Weiterlesen