Crea una notificación verificada de ventas… | LaunchFast
LaunchFast Logo LaunchFast
Blog
2518 palabras 13 min de lectura

Crea una notificación verificada de ventas recientes con Polar y Astro

Muestre actividad de compra real en su sitio de marketing listando pedidos recientes de Polar del lado del servidor, almacenando respuestas en caché en el edge y renderizando una notificación verificada ligera en el navegador, sin pop-ups falsos.

Rishi Raj Jain
Rishi Raj Jain Autor
Crea una notificación verificada de ventas recientes con Polar y Astro

«Alguien en Alemania acaba de comprar Pro hace 2 horas.» Esa frase aparece en casi todas las landing pages de SaaS y, a menudo, está completamente inventada. Un enfoque mejor es mostrar pedidos reales de su proveedor de pagos. Si vende con Polar, puede leer pedidos recientes a través de la API oficial, exponer un pequeño endpoint JSON desde su aplicación Astro y dejar que el cliente renderice un toast discreto con la etiqueta Verificado para que los visitantes sepan que la señal proviene de su checkout, no de un generador de números aleatorios.

Sponsored

Kits de inicio de alta calidad con flujo de autenticación integrada (Auth.js), carga de objetos (AWS, Clouflare R2, Firebase Storage, Supabase Storage), pagos integrados (Stripe, LemonSqueezy), flujo de verificación de correo electrónico (Resend, Postmark, Sendgrid) y mucho más . Compatible con cualquier base de datos (Redis, Postgres, MongoDB, SQLite, Firestore).

Get all 3 kits Bundle ↗

One-time license · Lifetime updates

En esta guía, conectará el Polar SDK a una ruta de servidor de Astro, lo desplegará en Vercel y añadirá un componente de notificación flotante que rota entre ventas recientes. El patrón coincide con el que usamos en este sitio: secretos solo en el servidor, cabeceras de caché compatibles con el edge y un script de cliente mínimo.

Tabla de contenidos

Requisitos previos

Necesitará:

  • Node.js 20 o posterior
  • Una cuenta de Polar con al menos un producto y (para demos significativas) algunos pedidos de prueba o reales
  • Una cuenta de Vercel si desea desplegar allí

Cómo encajan las piezas

1. Navegador
→ GET /api/sale (su ruta Astro)
→ Recibe JSON: nombre del producto, marca de tiempo ISO, etiqueta opcional de país de facturación
2. Ruta API de Astro (Node / Vercel Function)
→ Usa POLAR_ACCESS_TOKEN con Polar SDK
→ Lista pedidos (más recientes primero), los mapea a una estructura pública pequeña
→ Establece Cache-Control para caché en CDN
3. API de Polar
→ Lista autoritativa de pedidos y líneas de artículo

La insignia Verificado en la interfaz comunica que la carga útil se obtuvo de Polar en el servidor en el momento de la solicitud (o proviene de una respuesta en caché generada de esa forma). Mantenga el token de acceso fuera de los bundles del cliente y de los prefijos de entorno.

Crear un token de acceso de Polar

  1. Abra el panel de Polar e inicie sesión.
  2. Vaya a Settings de su organización y busque Developers (el nombre puede variar ligeramente).
  3. Cree un token con permiso orders:read.
  4. Copie el token una vez y guárdelo de forma segura; lo añadirá a Vercel y al .env local como POLAR_ACCESS_TOKEN. (¡Nunca suba este valor a git!)

Configurar Astro con el adaptador de Vercel

Cree un proyecto nuevo (o use uno existente con output: 'server'):

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

Añada el adaptador de Vercel para que las rutas API se ejecuten como funciones serverless al desplegar:

Terminal window
npx astro add vercel --yes

Asegúrese de que astro.config.mjs use salida server. Una configuración mínima se ve así:

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

Instale el Polar SDK:

Terminal window
npm install @polar-sh/sdk

Implementar la ruta API

Añada src/pages/api/sale.ts. Los detalles importantes:

  1. export const prerender = false para que Astro no intente pre-renderizar esta URL estáticamente en tiempo de compilación.
  2. Instancie Polar con import.meta.env.POLAR_ACCESS_TOKEN.
  3. Llame a polar.orders.list con ordenación más recientes primero y un limit pequeño, luego mapee cada elemento solo a lo que la interfaz necesita (nombre del producto, createdAt, país de facturación opcional).
  4. Devuelva Cache-Control para que la CDN de Vercel pueda almacenar el JSON en caché durante un día permitiendo revalidación (max-age=0, s-maxage=86400 es un punto de partida razonable).
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',
},
})
}

Filtrar por producto: En producción puede que solo quiera ciertos SKU (por ejemplo, nombres que contengan su familia de productos). Tras el mapeo, .filter((order) => order.name.includes('YourBrand')) mantiene el toast relevante.

Nombres de países: Polar devuelve un código de país (como DE, US, etc.) en el campo billingAddress.country, no el nombre completo. Para mostrar el nombre correcto, mapee ese código con una tabla de consulta, como se muestra en la ruta API de ejemplo anterior.

Variables de entorno localmente y en Vercel

Cree .env (y añada .env a .gitignore si aún no está):

Terminal window
POLAR_ACCESS_TOKEN="polar_oat_…"

Para Vercel:

  1. Proyecto → Settings → Environment Variables
  2. Añada POLAR_ACCESS_TOKEN para Production (y Preview si prueba despliegues de PR)
  3. Vuelva a desplegar para que el nuevo valor esté disponible

Ejecute localmente:

Terminal window
npm run dev

Visite http://localhost:4321/api/sale y confirme que recibe un array JSON.

Añadir la notificación flotante de venta reciente

Cree un componente como src/components/SalesPopup.astro con el siguiente código:

src/components/SalesPopup.astro
---
---
<script>
let popupTimeout
let salesItems: any[] = []
let currentIndex = 0
function showSalePopup(item) {
// Eliminar si ya existe
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">
Alguien en <span class="font-bold text-branding">${item.billingCountry ? item.billingCountry : 'Desconocido'}</span> compró <span class="font-bold text-branding">${item.name}</span>
</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">Verificado</span></span>
</div>
`
document.body.appendChild(popup)
// Animar entrada
setTimeout(() => {
popup.classList.remove('translate-y-8', 'opacity-0', 'pointer-events-none')
popup.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto')
}, 10)
// Eliminar tras 3 s
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 `hace ${diff} segundos`
if (diff < 3600) return `hace ${Math.floor(diff / 60)} ${Math.floor(diff / 60) === 1 ? 'minuto' : 'minutos'}`
if (diff < 86400) return `hace ${Math.floor(diff / 3600)} ${Math.floor(diff / 3600) === 1 ? 'hora' : 'horas'}`
return `hace ${Math.floor(diff / 86400)} ${Math.floor(diff / 86400) === 1 ? 'día' : 'días'}`
}
function fetchAndShow() {
if (Array.isArray(salesItems) && salesItems.length > 0) {
// Secuencial, no aleatorio
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>

El código anterior hace lo siguiente:

  • fetch('/api/sale') tras cargar la página.
  • Almacena el array en memoria y rota entradas en un intervalo (por ejemplo, cada 10 segundos).
  • Renderiza una tarjeta fija pequeña (abajo a la derecha es lo habitual) con:
    • País (o «Desconocido» si prefiere no mostrar ubicación aproximada)
    • Nombre del producto
    • Tiempo relativo («hace 3 minutos») a partir del date ISO
    • Una fila Verificado con un icono de escudo que refuerza que los datos provienen de Polar

Mantenga el script ligero: APIs DOM simples o un pequeño helper para timeAgo son suficientes. Enlace la tarjeta a su sección de precios para que el aviso tenga una llamada a la acción clara.

Si desea cumplir expectativas de privacidad más estrictas, muestre solo la región u omita la geografía por completo y muestre solo producto y tiempo.

Resultado

Crea una notificación verificada de ventas recientes con Polar y Astro

Conclusión

Ahora tiene un feed de ventas respaldado por Polar: la ruta API de Astro gestiona el secreto, Vercel la sirve como función serverless con caché en CDN, y el navegador solo ve los campos anonimizados que elija exponer. Adapte el estilo a su marca, ajuste intervalos y filtros, y obtendrá prueba social creíble sin mantener un pipeline de analítica aparte para notificaciones «falsas».

Sigue leyendo