Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ca92588
Merge pull request #56 from efdevcon/x402-pretix
didierkrux May 14, 2026
9034d34
Merge pull request #59 from efdevcon/x402-pretix
didierkrux May 15, 2026
bbaff5b
Update import path in next-env.d.ts to reflect new directory structure
didierkrux May 15, 2026
4a164d0
Merge pull request #62 from efdevcon/x402-pretix
didierkrux May 15, 2026
aa8ce85
fix
didierkrux May 15, 2026
04ea23a
Merge pull request #63 from efdevcon/x402-pretix
didierkrux May 15, 2026
0923b80
fix
didierkrux May 15, 2026
8d5621d
FormPage: use optional chaining on schema (avoid undefined access)
didierkrux May 15, 2026
fcf53a1
Implement pruning functionality in event cloning process
didierkrux May 16, 2026
a87a2a8
onchain
didierkrux May 16, 2026
70b38a8
Include purchase error context in support email for better troublesho…
didierkrux May 18, 2026
dc51aea
Update admin page titles
didierkrux May 18, 2026
55d3eaf
Devcon 8 India
didierkrux May 18, 2026
00843d0
Enhance refund process in admin tickets page
didierkrux May 19, 2026
82b1389
Enhance Matomo initialization in _app.tsx
didierkrux May 19, 2026
3bfd41c
Update TicketSharing component for Devcon 8 sharing functionality
didierkrux May 19, 2026
dde15e1
Refactor avatar resolution logic and improve sharing functionality
didierkrux May 19, 2026
a32053f
Refactor sharing links in TicketSharing component
didierkrux May 19, 2026
f5b6212
Update share text in TicketSharing component for clarity
didierkrux May 19, 2026
e61c890
merge
didierkrux May 19, 2026
cc3c260
pretix clone ignore
didierkrux May 19, 2026
e60f1d5
restore files
didierkrux May 19, 2026
aa39977
update conf
didierkrux May 19, 2026
6f49de7
Refactor generateImage function parameters for improved readability
didierkrux May 19, 2026
459e9a9
env
didierkrux May 19, 2026
96b7ba2
tix ticketing file
didierkrux May 19, 2026
0fefdfa
Merge branch 'main' into dev
didierkrux May 19, 2026
0488886
restore x402
didierkrux May 19, 2026
89bd2c0
Enhance ticketing configuration and URL handling
didierkrux May 19, 2026
0daf47b
Add ticket API and page for dynamic ticket sharing
didierkrux May 19, 2026
18b574a
Implement security enhancements and UI improvements for ticketing API
didierkrux May 19, 2026
ea56c21
Enhance avatar loading experience in TicketSharing component
didierkrux May 19, 2026
83d6505
fix tickets
didierkrux May 19, 2026
a851de9
fix
didierkrux May 19, 2026
eed78fe
Enhance ticket checkout process by integrating plugin settings
didierkrux May 19, 2026
2df00d7
Refactor store page notification display for better clarity
didierkrux May 19, 2026
e505714
testmode
didierkrux May 19, 2026
c7a6392
Enhance ticketing configuration and API response handling
didierkrux May 20, 2026
7f81177
Refactor ticket availability handling and API response
didierkrux May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions devcon/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,7 @@ generated-codes/

# Claude Code local settings
.claude/settings.local.json

# pretix clone script — snapshots and state files contain Pretix data and must not be committed
src/scripts/pretix/snapshots/
src/scripts/pretix/.clone-state-*.json
34 changes: 29 additions & 5 deletions devcon/src/components/domain/ticket-sharing/TicketSharing.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react'
import React, { useState, useCallback, useEffect, useRef } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useTilt } from './useTilt'
Expand Down Expand Up @@ -89,7 +89,22 @@ export function TicketSharing({ name, avatarUrl, share, pageUrl }: TicketSharing

const [copied, setCopied] = useState(false)
const [avatarError, setAvatarError] = useState(false)
const [avatarLoaded, setAvatarLoaded] = useState(false)
const avatarRef = useRef<HTMLImageElement | null>(null)
const handleAvatarError = useCallback(() => setAvatarError(true), [])
const handleAvatarLoad = useCallback(() => setAvatarLoaded(true), [])

// Catch the race where a cached (or preloaded) avatar finishes loading
// *before* React's `onLoad` handler is attached — the event fires but
// nothing listens, so `avatarLoaded` would stay false and the image
// would stay at opacity 0. Inspect `img.complete + naturalWidth > 0`
// post-mount to recover from that case.
useEffect(() => {
const img = avatarRef.current
if (img && img.complete && img.naturalWidth > 0) {
setAvatarLoaded(true)
}
}, [avatarUrl])

const hasAvatar = !!avatarUrl && !avatarError

Expand Down Expand Up @@ -145,10 +160,18 @@ export function TicketSharing({ name, avatarUrl, share, pageUrl }: TicketSharing
{hasAvatar && (
<div className={css.avatarCircle}>
<img
ref={avatarRef}
src={avatarUrl!}
alt={`${name}'s avatar`}
className={css.avatarImage}
className={cn(css.avatarImage, { [css.avatarLoaded]: avatarLoaded })}
onLoad={handleAvatarLoad}
onError={handleAvatarError}
// The image is preloaded in <Head> via `<link rel="preload">`,
// so by the time React renders this element the bytes are
// usually already cached. Eager loading + sync decode keeps
// the paint on the same frame as the rest of the ticket.
loading="eager"
decoding="sync"
/>
</div>
)}
Expand Down Expand Up @@ -185,7 +208,8 @@ export function TicketSharing({ name, avatarUrl, share, pageUrl }: TicketSharing
(() => {
const baseShareUrl = pageUrl?.replace(/\?share$/, '').replace(/\?share&/, '?').replace(/&share\b/, '').replace(/\/$/, '') || ''
const shareUrl = `${baseShareUrl}/`
const shareText = `I'm heading to Devcon India from 3–6 November in Mumbai!\n\nJoin me and the wider Ethereum community for a week of incredible talks, workshops, experiences and more!`
const shareText = `I just got my @EFDevcon ticket — paid for with ETH!\n\nNext stop: Mumbai 🇮🇳 Join me at Devcon 8 from November 3–6, 2026 for four days of big ideas, technical depth, community, and the people building the future of open source technology.`
const xText = `${shareText}\n\n${shareUrl}`
return (
<div className={css.shareSection}>
<span className={css.shareLabel}>Share</span>
Expand All @@ -194,7 +218,7 @@ export function TicketSharing({ name, avatarUrl, share, pageUrl }: TicketSharing
href="#"
onClick={e => {
e.preventDefault()
window.open(`https://x.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`, '_blank')
window.open(`https://x.com/intent/post?text=${encodeURIComponent(xText)}`, '_blank')
}}
className={css.shareIcon}
>
Expand All @@ -204,7 +228,7 @@ export function TicketSharing({ name, avatarUrl, share, pageUrl }: TicketSharing
href="#"
onClick={e => {
e.preventDefault()
window.open(`https://warpcast.com/~/compose?text=${encodeURIComponent(shareText)}&embeds[]=${encodeURIComponent(shareUrl)}`, '_blank')
window.open(`https://farcaster.xyz/~/compose?text=${encodeURIComponent(shareText)}&embeds[]=${encodeURIComponent(shareUrl)}`, '_blank')
}}
className={css.shareIcon}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,17 @@
width: 100%;
height: 100%;
object-fit: cover;
/* Fade in once the image's bytes are decoded — avoids the pop-in when the
<img> swaps from empty (grey circle background) to fully painted. The
preload in <Head> typically completes before this element mounts, so the
`.avatarLoaded` class is applied immediately and the fade is imperceptible
in the warm case. On a slow network the fade gives a smooth handoff. */
opacity: 0;
transition: opacity 200ms ease-out;

&.avatarLoaded {
opacity: 1;
}
}

.attendeeInfo {
Expand Down
64 changes: 52 additions & 12 deletions devcon/src/config/ticketing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,34 @@ const ENV_CONFIG = {
baseUrl: 'https://dcdev2.ticketh.xyz',
organizer: 'org',
event: '8',
// True when `baseUrl` is a Pretix-managed custom event domain (Pretix's
// multidomain feature serves the event at root — `/order/...`, not
// `/{organizer}/{event}/order/...`). Drives URL construction in
// status.ts and the checkout fallback redirect. Legacy slug-based
// Pretix instances (e.g. dcdev2.ticketh.xyz) leave this `false`.
customDomain: false,
x402ApiEnabled: true,
ticketDiscountId: '6',
defaultQuotaId: 116,
testmode: true,
},
checkout: {
pretixRedirectUrl: '',
useDaimoPay: false,
forcePretixRedirect: false,
// Buyer-facing support inbox surfaced as "Need help?" mailto in the
// checkout UI. Empty hides the link.
supportEmail: 'support@devcon.org',
},
payment: {
recipientAddress: '0xA163a78C0b811A984fFe1B98b4b1b95BAb24aAcD',
relayerAddress: '0xA163a78C0b811A984fFe1B98b4b1b95BAb24aAcD',
// Crypto-payment discount percentage. 0 disables the discount entirely
// (no UI, no API field, no math). Set per environment. Default 0 so a
// new environment doesn't accidentally ship with a discount nobody
// signed off on.
cryptoDiscountPercent: 3,
fiatEnabled: true,
enabledTokens: ['ETH', 'USDC', 'USDT0'] as readonly ('ETH' | 'USDC' | 'USDT0')[],
},
tax: {
vatPercent: 18,
Expand Down Expand Up @@ -47,37 +64,47 @@ const ENV_CONFIG = {
production: {
chainEnv: 'mainnet' as const,
pretix: {
baseUrl: 'https://mum.ticketh.xyz',
baseUrl: 'https://tickets.devcon.org',
organizer: 'devcon',
event: '8',
// tickets.devcon.org is a Pretix-managed custom event domain — the
// event is mounted at root, so user-facing URLs are /order/CODE/...
// (not /devcon/8/order/CODE/...). See development.pretix.customDomain
// for details.
customDomain: true,
x402ApiEnabled: false,
ticketDiscountId: '2',
defaultQuotaId: 116, // TODO: confirm production quota ID
// TODO: disable testmode for production
defaultQuotaId: 116,
testmode: true,
},
checkout: {
pretixRedirectUrl: '',
useDaimoPay: false,
forcePretixRedirect: false,
// Buyer-facing support inbox surfaced as "Need help?" mailto in the
// checkout UI. Empty hides the link.
supportEmail: 'support@devcon.org',
},
payment: {
recipientAddress: '0xA163a78C0b811A984fFe1B98b4b1b95BAb24aAcD',
// TODO: replace with production recipient address
// recipientAddress: '0xFc488aE9cB395B150574Aa5ce8a321c9100b1ee3',
cryptoDiscountPercent: 3,
recipientAddress: '0x403A3A81abA974dEb4faF20514ae34FAf9268E28',
relayerAddress: '0xA163a78C0b811A984fFe1B98b4b1b95BAb24aAcD',
// Crypto-payment discount percentage. 0 disables the discount entirely
// (no UI, no API field, no math). Set per environment. Default 0 so a
// new environment doesn't accidentally ship with a discount nobody
// signed off on.
cryptoDiscountPercent: 10,
fiatEnabled: true,
enabledTokens: ['ETH', 'USDC', 'USDT0'] as readonly ('ETH' | 'USDC' | 'USDT0')[],
},
tax: {
vatPercent: 18,
label: 'GST',
},
self: {
scope: 'devcon-india-local-discount',
// TODO: replace with production staging
staging: false,
// TODO: replace after event
requireEarlyAccess: false,
},
discount: {
// TODO: replace with india-early-bird
collection: 'india-early-bird',
},
aadhaar: {
Expand All @@ -96,6 +123,19 @@ const ENV_CONFIG = {

export const TICKETING = ENV_CONFIG[TICKETING_ENV]

/** Build a user-facing Pretix URL that respects the event's custom-domain
* setting. On a custom event domain (`tickets.devcon.org`) the event lives at
* root: `/order/CODE/...`. On a legacy slug-based instance, the path needs the
* `/{organizer}/{event}/` prefix. Pass the path INCLUDING leading slash, e.g.
* `'/order/ABCDE/secret/'`. Returns an absolute URL. */
export function pretixEventUrl(path: string): string {
const base = TICKETING.pretix.baseUrl.replace(/\/$/, '')
const eventPrefix = TICKETING.pretix.customDomain
? ''
: `/${TICKETING.pretix.organizer}/${TICKETING.pretix.event}`
return `${base}${eventPrefix}${path}`
}

/** Whether the chain environment is testnet (derived from config) */
export const isTestnet = TICKETING.chainEnv !== 'mainnet'

Expand Down
16 changes: 9 additions & 7 deletions devcon/src/hooks/useTicketAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import { useSyncExternalStore } from 'react'

export interface TicketAvailability {
available: boolean | null // null = not yet loaded
available_number: number | null
}

export type TicketAvailabilityMap = Record<string, TicketAvailability>

const POLL_INTERVAL_MS = 10_000
const EMPTY: TicketAvailability = { available: null, available_number: null }
// Poll once a minute. Wave-state UX doesn't need real-time accuracy — the
// "Open Now" / "Sold out" flip only has to land within ~90s of the actual
// event (60s poll + 30s server-side `cachedFetch` TTL in `services/pretix`),
// well inside the 5-minute grace window in `useEthEarlyBirdWave`. Combined
// with the endpoint's CDN cache header (`s-maxage=30, swr=60`), most polls
// don't even hit our function — the CDN serves them.
const POLL_INTERVAL_MS = 60_000
const EMPTY: TicketAvailability = { available: null }
const EMPTY_MAP: TicketAvailabilityMap = {}

// ── Singleton store ─────────────────────────────────────────────────────────
Expand All @@ -33,10 +38,7 @@ async function poll() {
if (!waves || typeof waves !== 'object') return
const next: TicketAvailabilityMap = {}
for (const [waveId, entry] of Object.entries(waves) as Array<[string, TicketAvailability]>) {
next[waveId] = {
available: !!entry.available,
available_number: entry.available_number ?? null,
}
next[waveId] = { available: !!entry.available }
}
latestMap = next
for (const cb of subscribers) cb()
Expand Down
2 changes: 1 addition & 1 deletion devcon/src/hooks/useWaveStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { TICKET_WAVES, type TicketWave } from 'config/waves'
import { useNow } from './useNow'
import { useTicketAvailabilityMap, type TicketAvailability } from './useTicketAvailability'

const NO_AVAILABILITY: TicketAvailability = { available: null, available_number: null }
const NO_AVAILABILITY: TicketAvailability = { available: null }

export type WaveStatus = 'live' | 'countdown' | 'closed' | 'tbd'

Expand Down
29 changes: 24 additions & 5 deletions devcon/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'slick-carousel/slick/slick.css'
import 'slick-carousel/slick/slick-theme.css'
import 'assets/css/index.scss'
import { SEO } from 'components/domain/seo'
import { init } from '@socialgouv/matomo-next'
import { init, push } from '@socialgouv/matomo-next'
// import { SessionProvider } from 'next-auth/react'
// import { Web3ModalProvider } from 'context/web3modal'
import { RecoilRoot } from 'recoil'
Expand Down Expand Up @@ -105,7 +105,14 @@ function App({ Component, pageProps }: any) {

React.useEffect(() => {
if (!matomoAdded && process.env.NODE_ENV === 'production') {
init({ url: MATOMO_URL, siteId: MATOMO_SITE_ID })
init({
url: MATOMO_URL,
siteId: MATOMO_SITE_ID,
onInitialization: () => {
push(['setCookieDomain', '*.devcon.org'])
push(['setExcludedQueryParams', ['code', 'gist']])
},
})
matomoAdded = true
}
}, [])
Expand All @@ -117,9 +124,21 @@ function App({ Component, pageProps }: any) {
</Head>
<RecoilRoot>
<SEO />
{TICKETING_ENV !== 'production' && isStorePage && (
<div style={{ position: 'fixed', bottom: 12, right: 12, background: '#f59e0b', color: '#000', padding: '8px 16px', fontSize: '16px', fontWeight: 700, borderRadius: 8, zIndex: 9999, pointerEvents: 'none', opacity: 0.9 }}>
{new URL(TICKETING.pretix.baseUrl).hostname.split('.')[0]} pretix shop
{isStorePage && (TICKETING_ENV !== 'production' || TICKETING.pretix.testmode) && (
<div className="fixed bottom-3 right-3 z-[9999] flex flex-row items-center gap-1.5 pointer-events-none whitespace-nowrap">
{(TICKETING_ENV !== 'production' || TICKETING.pretix.testmode) && (
<div className="bg-[#f59e0b] text-black font-bold rounded-lg opacity-90 text-[11px] sm:text-base px-2 py-1 sm:px-4 sm:py-2">
{new URL(TICKETING.pretix.baseUrl).hostname.split('.')[0]} Pretix
</div>
)}
{TICKETING.pretix.testmode && (
// Testmode is a Pretix flag, independent of TICKETING_ENV — surface
// it loudly so an operator who left testmode on in production sees
// it next to a real Stripe/x402 charge attempt.
<div className="bg-[#dc2626] text-white font-bold rounded-lg opacity-90 tracking-wider text-[11px] sm:text-base px-2 py-1 sm:px-4 sm:py-2">
TEST MODE - real crypto charges
</div>
)}
</div>
)}

Expand Down
59 changes: 57 additions & 2 deletions devcon/src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@ import GithubProvider from "next-auth/providers/github"
import CredentialsProvider from 'next-auth/providers/credentials'
import { SiweMessage } from "siwe"

/**
* M13: read NextAuth's CSRF token from the inbound request's cookie. The
* cookie name varies by deployment scheme — HTTPS production uses the
* `__Host-` prefix; HTTP dev uses the bare name. NextAuth stores the value
* as `<token>|<hash>` URL-encoded; we only need the token part (the hash
* is for NextAuth's own CSRF validation, separate from our SIWE-nonce use).
*
* Returns `null` when the cookie is missing/malformed so callers can reject
* the sign-in attempt — failing closed is the whole point of this fix.
*/
function readNextAuthCsrfToken(cookieHeader: string | undefined): string | null {
if (!cookieHeader) return null
const variants = [
'__Host-next-auth.csrf-token',
'__Secure-next-auth.csrf-token',
'next-auth.csrf-token',
]
for (const name of variants) {
const pattern = new RegExp(`(?:^|;\\s*)${name.replace(/\./g, '\\.')}=([^;]+)`)
const m = cookieHeader.match(pattern)
if (m) {
const value = decodeURIComponent(m[1])
const token = value.split('|')[0]
return token || null
}
}
return null
}

declare module 'next-auth' {
interface Session {
id: string
Expand Down Expand Up @@ -52,7 +81,7 @@ export const authOptions: AuthOptions = {
placeholder: '0x0'
}
},
async authorize(credentials) {
async authorize(credentials, req) {
try {
if (!credentials?.message || !credentials?.signature) {
console.error('Missing Siwe credentials', credentials)
Expand All @@ -61,10 +90,36 @@ export const authOptions: AuthOptions = {

const siwe = new SiweMessage(JSON.parse(credentials.message))
const nextAuthUrl = new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000')

// M13: read the server-issued nonce (NextAuth CSRF token from the
// cookie) and pass it to `siwe.verify` as the authoritative nonce.
// Pre-fix code passed `siwe.nonce` — the value embedded in the
// client-supplied message — which made the check `siwe.nonce ===
// siwe.nonce` (always true). The FE builds the SIWE message with
// `nonce: await getCsrfToken()` so the message-side and server-side
// values match for a legitimate sign-in. Captured signature replay
// fails: the attacker doesn't have the original cookie's CSRF
// value (HttpOnly + same-origin), so even if they replay the
// signature, the server-side nonce won't match what's in the
// signed message.
const cookieHeader =
(req?.headers?.cookie as string | undefined) ?? undefined
const expectedNonce = readNextAuthCsrfToken(cookieHeader)
if (!expectedNonce) {
console.error('SIWE: missing or malformed NextAuth CSRF cookie')
return null
}
if (siwe.nonce !== expectedNonce) {
console.error(
'SIWE: nonce mismatch — message nonce does not match server-issued CSRF token',
)
return null
}

const result = await siwe.verify({
signature: credentials?.signature || "",
domain: nextAuthUrl.host,
nonce: siwe.nonce,
nonce: expectedNonce,
})

if (result.success) {
Expand Down
Loading
Loading