diff --git a/README.md b/README.md
index 7f3fb19..f546bf2 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
# POAP Family
-[POAP.family](https://poap.family) is a tool to discover the POAPs that different collectors have in common. Read the [FAQ](https://poap.notion.site/poap/POAP-Family-FAQ-cef29bc0bb8c4f8f936164d988a944cc).
+[POAP.family](https://poap.family) is a tool to discover the POAPs that different collectors have in common.
diff --git a/functions/renderEvent.js b/functions/renderEvent.js
deleted file mode 100644
index a732454..0000000
--- a/functions/renderEvent.js
+++ /dev/null
@@ -1,92 +0,0 @@
-const axios = require('axios')
-
-const FAMILY_URL = 'https://poap.family'
-const FAMILY_API_URL = 'https://api.poap.family'
-const IS_BOT = /(bot|check|cloud|crawler|download|monitor|preview|scan|spider|google|qwantify|yahoo|facebookexternalhit|flipboard|tumblr|vkshare|whatsapp|curl|perl|python|wget|heritrix|ia_archiver)/i
-
-async function getEventInfo(eventId) {
- const response = await axios.get(`${FAMILY_API_URL}/event/${eventId}?metrics=true&fresh=true`)
- const event = response.data
- if (
- typeof event !== 'object' ||
- !('event' in event) || typeof event.event !== 'object' ||
- !('owners' in event) || !Array.isArray(event.owners) ||
- !('ts' in event) || typeof event.ts !== 'number' ||
- !('metrics' in event) || !event.metrics ||
- !('emailReservations' in event.metrics) || typeof event.metrics.emailReservations !== 'number' ||
- !('ts' in event.metrics) || (typeof event.metrics.ts !== 'number' && event.metrics.ts !== null)
- ) {
- throw new Error(`Event ${eventId} invalid response`)
- }
- return {
- event: event.event,
- supply: event.owners.length,
- emailReservations: event.metrics.emailReservations,
- }
-}
-
-exports.handler = async function(request, context, callback) {
- const eventId = request.path.split('/').pop()
-
- if (!IS_BOT.test(request.headers['user-agent'])) {
- return {
- statusCode: 301,
- headers: {
- location: `${FAMILY_URL}/r/event/${eventId}`,
- },
- }
- }
-
- let event, supply, emailReservations
- try {
- const eventInfo = await getEventInfo(eventId)
- event = eventInfo.event
- supply = eventInfo.supply
- emailReservations = eventInfo.emailReservations
- } catch (err) {
- if (err?.response?.status === 404) {
- return { statusCode: 404 }
- }
- console.error('Fetch event info failed', err)
- return { statusCode: 503 }
- }
-
- return {
- statusCode: 200,
- body: `
-
-
-
-
- POAP Family: ${event.name}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${event.name}
-
- - Supply
- - ${supply}
- - Email reservations
- - ${emailReservations}
-
- ${event.start_date}
${event.city && event.country ? `${event.city}, ${event.country}
` : ''}
-
-
-`,
- }
-}
diff --git a/functions/renderEvents.js b/functions/renderEvents.js
deleted file mode 100644
index 0f9c4e4..0000000
--- a/functions/renderEvents.js
+++ /dev/null
@@ -1,139 +0,0 @@
-const axios = require('axios')
-
-const FAMILY_URL = 'https://poap.family'
-const FAMILY_API_URL = 'https://api.poap.family'
-const IS_BOT = /(bot|check|cloud|crawler|download|monitor|preview|scan|spider|google|qwantify|yahoo|facebookexternalhit|flipboard|tumblr|vkshare|whatsapp|curl|perl|python|wget|heritrix|ia_archiver)/i
-
-function parseEventIds(rawIds) {
- let eventIds = rawIds.split(',')
- .filter((value, index, all) => all.indexOf(value) === index)
- .map((value) => parseInt(value.trim()))
- .filter((eventId) => !isNaN(eventId))
- eventIds.sort((a, b) => a - b)
- return eventIds
-}
-
-async function getEventsInfo(eventIds) {
- const [events, owners, metrics] = await Promise.all([
- getEvents(eventIds),
- getOwners(eventIds),
- getMetrics(eventIds),
- ])
- if (!events) {
- return null
- }
- const eventsInfo = {}
- for (const eventId of eventIds) {
- if (eventId in events) {
- eventsInfo[eventId] = {
- event: events[eventId],
- supply: eventId in owners ? owners[eventId].length : 0,
- emailReservations: eventId in metrics ? metrics[eventId].emailReservations : 0,
- }
- }
- }
- return eventsInfo
-}
-
-async function getEvents(eventIds) {
- const response = await axios.get(`${FAMILY_API_URL}/events/${eventIds.map((eventId) => encodeURIComponent(eventId)).join(',')}?fresh=true`)
- const events = response.data
- if (typeof events !== 'object') {
- throw new Error(`Events invalid response (type ${typeof events} expected object)`)
- }
- return events
-}
-
-async function getOwners(eventIds) {
- const response = await axios.get(`${FAMILY_API_URL}/events/${eventIds.map((eventId) => encodeURIComponent(eventId)).join(',')}/owners?fresh=true`)
- const owners = response.data
- if (typeof owners !== 'object') {
- throw new Error(`Events owners invalid response (type ${typeof owners} expected object)`)
- }
- return owners
-}
-
-async function getMetrics(eventIds) {
- const response = await axios.get(`${FAMILY_API_URL}/events/${eventIds.map((eventId) => encodeURIComponent(eventId)).join(',')}/metrics`)
- const metrics = response.data
- if (typeof metrics !== 'object') {
- throw new Error(`Events metrics invalid response (type ${typeof metrics} expected object)`)
- }
- return metrics
-}
-
-exports.handler = async function(request, context, callback) {
- const rawIds = request.path.split('/').pop()
- const eventIds = parseEventIds(rawIds)
-
- if (!IS_BOT.test(request.headers['user-agent'])) {
- return {
- statusCode: 301,
- headers: {
- location: `${FAMILY_URL}/r/events/${eventIds.join(',')}`,
- },
- }
- }
-
- let eventsInfo
- try {
- eventsInfo = await getEventsInfo(eventIds)
- } catch (err) {
- if (err?.response?.status === 404) {
- return { statusCode: 404 }
- }
- console.error('Fetch events info failed', err)
- return { statusCode: 503 }
- }
-
- let totalSupply = 0, totalEmailReservations = 0
- let titles = [], trs = []
- for (const { event, supply, emailReservations } of Object.values(eventsInfo)) {
- totalSupply += supply
- totalEmailReservations += emailReservations
- titles.push(event.name)
- trs.push(`${event.name}${event.start_date} ${event.city && event.country ? `${event.city}, ${event.country} ` : ''} | ${supply} | ${emailReservations} |
`)
- }
-
- return {
- statusCode: 200,
- body: `
-
-
-
-
- POAP Family: ${titles.join(', ')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${titles.join(', ')}
-
- - Total supply
- - ${totalSupply}
- - Total email reservations
- - ${totalEmailReservations}
-
-
- Event | Supply | Email reservations |
- ${trs.join('')}
-
-
-
-`,
- }
-}
diff --git a/netlify/edge-functions/event.js b/netlify/edge-functions/event.js
new file mode 100644
index 0000000..7715b65
--- /dev/null
+++ b/netlify/edge-functions/event.js
@@ -0,0 +1,116 @@
+import axios from 'https://esm.sh/axios'
+
+const FAMILY_URL = 'https://poap.family'
+const FAMILY_API_URL = 'https://api.poap.family'
+
+function getQueryString(requestUrl) {
+ const searchParams = new URL(requestUrl).searchParams.toString()
+ return searchParams ? `?${searchParams}` : ''
+}
+
+function getEventId(requestUrl) {
+ const url = new URL(requestUrl)
+ const [, rawEventId] = url.pathname.match(/event\/([^/]+)/)
+ const eventId = parseInt(rawEventId)
+ if (isNaN(eventId)) {
+ throw new Error(`Event invalid Id param`)
+ }
+ return eventId
+}
+
+async function getEventInfo(eventId) {
+ const response = await axios.get(`${FAMILY_API_URL}/event/${eventId}?metrics=true&fresh=true`)
+ const event = response.data
+ if (
+ typeof event !== 'object' ||
+ !('event' in event) ||
+ typeof event.event !== 'object' ||
+ !('owners' in event) ||
+ !Array.isArray(event.owners) ||
+ !('metrics' in event) ||
+ !('emailReservations' in event.metrics) ||
+ typeof event.metrics.emailReservations !== 'number'
+ ) {
+ throw new Error(`Event ${eventId} invalid response`)
+ }
+ return {
+ event: event.event,
+ supply: event.owners.length,
+ emailReservations: event.metrics.emailReservations,
+ }
+}
+
+function replaceMeta(html, title, description, image, url) {
+ return html
+ .replace(/([^<]+)<\/title>/, `$1: ${title}`)
+ .replace(//g, ``)
+ .replace(//, ``)
+ .replace(//g, ``)
+ .replace(//g, ``)
+ .replace(//g, ``)
+}
+
+function escapeHtml(str) {
+ return str
+ .replace(/(\r\n|\n|\r)/gm, '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+export default async function handler(request, context) {
+ const eventId = getEventId(request.url)
+ const queryString = getQueryString(request.url)
+
+ const response = await context.next()
+ const html = await response.text()
+
+ let eventInfo
+ try {
+ eventInfo = await getEventInfo(eventId)
+ } catch (err) {
+ if (err?.response?.status === 404) {
+ return new Response(html, {
+ status: 404,
+ headers: {
+ 'content-type': 'text/html',
+ },
+ })
+ }
+ return new Response(html, {
+ status: 503,
+ headers: {
+ 'content-type': 'text/html',
+ },
+ })
+ }
+
+ const title = escapeHtml(eventInfo.event.name)
+ const description = escapeHtml(
+ `[ ${eventInfo.supply} + ${eventInfo.emailReservations} ] ` +
+ `${eventInfo.event.start_date}` +
+ `${eventInfo.event.city && eventInfo.event.country
+ ? ` ${eventInfo.event.city}, ${eventInfo.event.country}`
+ : ''
+ }`
+ )
+ const image = eventInfo.event.image_url
+ const url = `${FAMILY_URL}/event/${eventId}${queryString}`
+
+ return new Response(
+ replaceMeta(
+ html,
+ title,
+ description,
+ image,
+ url
+ ),
+ response
+ )
+}
+
+export const config = {
+ path: '/event/*',
+}
diff --git a/netlify/edge-functions/events.js b/netlify/edge-functions/events.js
new file mode 100644
index 0000000..9d60888
--- /dev/null
+++ b/netlify/edge-functions/events.js
@@ -0,0 +1,172 @@
+import axios from 'https://esm.sh/axios'
+
+const FAMILY_URL = 'https://poap.family'
+const FAMILY_API_URL = 'https://api.poap.family'
+
+function getQueryString(requestUrl) {
+ const searchParams = new URL(requestUrl).searchParams.toString()
+ return searchParams ? `?${searchParams}` : ''
+}
+
+function parseEventIds(rawIds) {
+ let eventIds = rawIds.split(',')
+ .filter((value, index, all) => all.indexOf(value) === index)
+ .map((value) => parseInt(value.trim()))
+ .filter((eventId) => !isNaN(eventId))
+ eventIds.sort((a, b) => a - b)
+ return eventIds
+}
+
+function getRawEventIds(requestUrl) {
+ const url = new URL(requestUrl)
+ const [, rawEventIds] = url.pathname.match(/events\/([^/]+)/)
+ return rawEventIds
+}
+
+async function getEventsInfo(eventIds) {
+ const [events, owners, metrics] = await Promise.all([
+ getEvents(eventIds),
+ getOwners(eventIds),
+ getMetrics(eventIds),
+ ])
+ const eventsInfo = {}
+ for (const eventId of eventIds) {
+ if (eventId in events) {
+ eventsInfo[eventId] = {
+ event: events[eventId],
+ supply: eventId in owners ? owners[eventId].length : 0,
+ emailReservations: eventId in metrics ? metrics[eventId].emailReservations : 0,
+ }
+ }
+ }
+ return eventsInfo
+}
+
+async function getEvents(eventIds) {
+ const response = await axios.get(`${FAMILY_API_URL}/events/${eventIds.map((eventId) => encodeURIComponent(eventId)).join(',')}?fresh=true`)
+ const events = response.data
+ if (typeof events !== 'object') {
+ throw new Error(`Events invalid response (type ${typeof events} expected object)`)
+ }
+ return events
+}
+
+async function getOwners(eventIds) {
+ const response = await axios.get(`${FAMILY_API_URL}/events/${eventIds.map((eventId) => encodeURIComponent(eventId)).join(',')}/owners?fresh=true`)
+ const owners = response.data
+ if (typeof owners !== 'object') {
+ throw new Error(`Events owners invalid response (type ${typeof owners} expected object)`)
+ }
+ return owners
+}
+
+async function getMetrics(eventIds) {
+ const response = await axios.get(`${FAMILY_API_URL}/events/${eventIds.map((eventId) => encodeURIComponent(eventId)).join(',')}/metrics`)
+ const metrics = response.data
+ if (typeof metrics !== 'object') {
+ throw new Error(`Events metrics invalid response (type ${typeof metrics} expected object)`)
+ }
+ return metrics
+}
+
+function replaceMeta(html, title, description, image, url) {
+ return html
+ .replace(/([^<]+)<\/title>/, `$1: ${title}`)
+ .replace(//g, ``)
+ .replace(//, ``)
+ .replace(//g, ``)
+ .replace(//g, ``)
+ .replace(//g, ``)
+}
+
+function escapeHtml(str) {
+ return str
+ .replace(/(\r\n|\n|\r)/gm, '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+export default async function handler(request, context) {
+ const rawEventIds = getRawEventIds(request.url)
+ const queryString = getQueryString(request.url)
+
+ const eventIds = parseEventIds(rawEventIds)
+
+ const response = await context.next()
+ const html = await response.text()
+
+ if (eventIds.length === 0) {
+ return new Response(html, {
+ status: 404,
+ headers: {
+ 'content-type': 'text/html',
+ },
+ })
+ }
+
+ if (rawEventIds !== eventIds.join(',')) {
+ return Response.redirect(
+ new URL(`/events/${eventIds.join(',')}${queryString}`, request.url)
+ )
+ }
+
+ if (eventIds.length === 1) {
+ return Response.redirect(
+ new URL(`/event/${eventIds[0]}${queryString}`, request.url)
+ )
+ }
+
+ let eventsInfo
+ try {
+ eventsInfo = await getEventsInfo(eventIds)
+ } catch (err) {
+ if (err?.response?.status === 404) {
+ return new Response(html, {
+ status: 404,
+ headers: {
+ 'content-type': 'text/html',
+ },
+ })
+ }
+ return new Response(html, {
+ status: 503,
+ headers: {
+ 'content-type': 'text/html',
+ },
+ })
+ }
+
+ let totalSupply = 0
+ let totalEmailReservations = 0
+ let names = []
+ for (const eventInfo of Object.values(eventsInfo)) {
+ totalSupply += eventInfo.supply
+ totalEmailReservations += eventInfo.emailReservations
+ names = [...names, eventInfo.event.name]
+ }
+
+ const title = escapeHtml(names.join(', '))
+ const description = escapeHtml(
+ `[ ${totalSupply} + ${totalEmailReservations} ]`
+ )
+ const image = `${FAMILY_URL}/images/${eventIds.join(',')}`
+ const url = `${FAMILY_URL}/events/${eventIds.join(',')}${queryString}`
+
+ return new Response(
+ replaceMeta(
+ html,
+ title,
+ description,
+ image,
+ url
+ ),
+ response
+ )
+}
+
+export const config = {
+ path: '/events/*',
+}
diff --git a/netlify/edge-functions/images.jsx b/netlify/edge-functions/images.jsx
new file mode 100644
index 0000000..ce05d80
--- /dev/null
+++ b/netlify/edge-functions/images.jsx
@@ -0,0 +1,121 @@
+/** @jsxImportSource https://esm.sh/react */
+import axios from 'https://esm.sh/axios'
+import { ImageResponse } from 'https://deno.land/x/og_edge/mod.ts'
+
+const FAMILY_API_URL = 'https://api.poap.family'
+
+function parseEventIds(rawIds) {
+ let eventIds = rawIds.split(',')
+ .filter((value, index, all) => all.indexOf(value) === index)
+ .map((value) => parseInt(value.trim()))
+ .filter((eventId) => !isNaN(eventId))
+ eventIds.sort((a, b) => a - b)
+ return eventIds
+}
+
+function getEventIds(requestUrl) {
+ const url = new URL(requestUrl)
+ const [, rawEventIds] = url.pathname.match(/images\/([^/]+)/)
+ return parseEventIds(rawEventIds)
+}
+
+async function getEvents(eventIds) {
+ const response = await axios.get(`${FAMILY_API_URL}/events/${eventIds.map((eventId) => encodeURIComponent(eventId)).join(',')}?fresh=true`)
+ const events = response.data
+ if (typeof events !== 'object') {
+ throw new Error(`Events invalid response (type ${typeof events} expected object)`)
+ }
+ return events
+}
+
+function renderEventsImages(events, canvas, size, pos) {
+ if (events.length === 0) {
+ return []
+ }
+
+ const angle = 360 / events.length
+ const offset = size / 2
+ const radius = (canvas - size) / 2
+ const center = pos + offset + radius
+
+ const images = []
+
+ events.forEach((event, i) => {
+ const x = center + radius * Math.cos(angle * i * Math.PI / 180)
+ const y = center + radius * Math.sin(angle * i * Math.PI / 180)
+
+ images.push(
+
+ )
+ })
+
+ return images
+}
+
+export default async function handler(request, context) {
+ const eventIds = getEventIds(request.url)
+
+ let events
+ try {
+ events = await getEvents(eventIds)
+ } catch (err) {
+ if (err?.response?.status === 404) {
+ return new Response(null, {
+ status: 404,
+ })
+ }
+ return new Response(null, {
+ status: 503,
+ })
+ }
+
+ const eventsLeft = Object.values(events)
+ .map((event) => ({ event, sort: Math.random() }))
+ .sort((a, b) => a.sort - b.sort)
+ .map(({ event }) => event)
+
+ const images = [
+ ...renderEventsImages(eventsLeft.splice(0, 12), 512, 128, 0),
+ ...renderEventsImages(eventsLeft.splice(0, 12), 256, 64, 128),
+ ...renderEventsImages(eventsLeft.splice(0, 12), 128, 32, 192),
+ ]
+
+ return new ImageResponse(
+ (
+
+ {images}
+
+ ),
+ {
+ width: 512,
+ height: 512,
+ }
+ )
+}
+
+export const config = {
+ path: '/images/*',
+}
diff --git a/package.json b/package.json
index 80c9245..fe5d256 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@poap-xyz/poap-family",
- "version": "1.9.3",
+ "version": "1.10.0",
"author": {
"name": "POAP",
"url": "https://poap.xyz"
diff --git a/public/_redirects b/public/_redirects
index 1d3b858..f9efe6d 100644
--- a/public/_redirects
+++ b/public/_redirects
@@ -1,5 +1,3 @@
-/event/* /.netlify/functions/renderEvent/:route 200!
-/events/* /.netlify/functions/renderEvents/:route 200!
-/faq https://poap.notion.site/POAP-Family-FAQ-cef29bc0bb8c4f8f936164d988a944cc 301
-/help https://poap.zendesk.com/hc/en-us/articles/24008770288909-How-to-navigate-POAP-Family 301
-/* /index.html 200
+/faq https://poap.notion.site/POAP-Family-FAQ-cef29bc0bb8c4f8f936164d988a944cc 301
+/help https://poap.zendesk.com/hc/en-us/articles/24008770288909-How-to-navigate-POAP-Family 301
+/* /index.html 200
diff --git a/public/index.html b/public/index.html
index e767cc0..5d40a62 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,15 +4,21 @@
POAP Family
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/App.js b/src/app/App.js
index 73ef13c..f8c85b2 100644
--- a/src/app/App.js
+++ b/src/app/App.js
@@ -7,7 +7,7 @@ import Event from '../pages/Event'
import Events from '../pages/Events'
import PageError from '../components/PageError'
import EventsPageError from '../components/EventsPageError'
-import { eventLoader, eventRedirect, eventsLoader, eventsRedirect } from '../loaders/event'
+import { eventLoader, eventsLoader } from '../loaders/event'
import Last from '../pages/Last'
import Settings from '../pages/Settings'
import FeedbackList from '../pages/FeedbackList'
@@ -40,20 +40,12 @@ function App() {
index: true,
element: ,
},
- {
- path: '/r/event/:eventId',
- loader: eventRedirect,
- },
{
path: '/event/:eventId',
loader: eventLoader,
element: ,
errorElement: ,
},
- {
- path: '/r/events/:eventIds',
- loader: eventsRedirect,
- },
{
path: '/events/:eventIds',
loader: eventsLoader,
diff --git a/src/loaders/event.js b/src/loaders/event.js
index 1f279e1..68d1114 100644
--- a/src/loaders/event.js
+++ b/src/loaders/event.js
@@ -307,59 +307,10 @@ async function eventsLoader({ params, request }) {
return events
}
-function eventRedirect({ params, request }) {
- const searchParams = new URL(request.url).searchParams.toString()
- return new Response('', {
- status: 301,
- statusText: 'Redirect to event',
- headers: {
- location: `/event/${params.eventId}${searchParams ? `?${searchParams}` : ''}`,
- },
- })
-}
-
-function eventsRedirect({ params, request }) {
- const searchParams = new URL(request.url).searchParams.toString()
- const eventIds = parseEventIds(params.eventIds)
- if (eventIds.length === 0) {
- throw new Response('', {
- status: 404,
- statusText: 'Events not found',
- })
- }
- if (params.eventIds !== eventIds.join(',')) {
- throw new Response('', {
- status: 301,
- statusText: 'Events given unordered',
- headers: {
- location: `/events/${eventIds.join(',')}${searchParams ? `?${searchParams}` : ''}`,
- },
- })
- }
- if (eventIds.length === 1) {
- throw new Response('', {
- status: 301,
- statusText: 'One event',
- headers: {
- location: `/event/${eventIds[0]}${searchParams ? `?${searchParams}` : ''}`,
- },
- })
- }
- return new Response('', {
- status: 301,
- statusText: 'Redirect to events',
- headers: {
- location: `/events/${params.eventIds}${searchParams ? `?${searchParams}` : ''}`,
- },
- })
-}
-
export {
searchEvents,
fetchEvent,
fetchEventsOrErrors,
eventLoader,
eventsLoader,
- eventRedirect,
- eventsRedirect,
}