Events
로그인 정책 한계를 명시하고 일정, 무료 RSVP, guest 조회와 취소를 완료합니다.
콘솔에서 시간대는 지역을 검색해 선택하세요. 워크스페이스와 함께 생성되는 캘린더는 워크스페이스 시간대를 사용합니다. 새 이벤트는 캘린더 시간대를 기본으로 사용합니다. 이벤트를 복제하면 원본의 시간대를 유지합니다. 캘린더 설정을 읽을 수 없으면 워크스페이스 시간대, 그다음 서울 시간대를 사용합니다. 이벤트를 저장하면 시간대도 함께 저장됩니다. 상위 설정을 바꿔도 저장된 이벤트와 회차는 그대로 유지됩니다.
시간 일정의 이벤트·회차 시작과 종료 시각은 picker 옆에 표시된 시간대 기준으로 입력하세요. 새 이벤트를 저장하기 전에 시간대를 바꾸면 입력한 시각(예: 10:00)을 새 시간대에서도 유지합니다. 일광 절약 시간 전환으로 존재하지 않는 시각은 수정해야 하며, 두 번 나타나는 시각을 새로 입력하면 먼저 오는 시각을 사용합니다. 달력에서 날짜를 바꿔도 입력한 시각은 유지됩니다. 시각 메뉴는 표시된 시간대에서 존재하지 않는 시각을 제외합니다. 이벤트·회차 목록의 시작과 종료 시각은 브라우저 시간대가 아니라 각 레코드의 시간대로 표시합니다. 종일 일정은 별도의 날짜 필드로 입력합니다.
시간과 장소 변경 알림
회차의 시작, 종료 시각이나 참석자에게 보이는 장소를 바꾸면 확정된(going) 등록자에게 최신 정보를 이메일로 알립니다. 변경 알림에는 값이 있는 장소명·주소·온라인 URL을 모두 표시합니다. 장소 안내 문구만 바꾸면 알림을 보내지 않습니다. 이벤트 기본 장소를 바꾸거나 이전 버전으로 복원하면 그 장소를 사용하는 미래 회차에도 적용됩니다. 연속 편집은 마지막 변경 후 5분 동안 묶고, 첫 변경으로부터 최대 30분 뒤 발송 대기 상태가 끝납니다. 실제 발송 시각은 알림 처리 주기와 대기량에 따라 달라집니다.
워크스페이스 설정의 사이트 & 브랜딩에서 알림 페이지를 열고 이벤트 시간·장소 변경을 끄면 변경 알림을 중지할 수 있습니다. 기본값은 켜짐이며, 이벤트 기능이 켜진 워크스페이스에서 표시됩니다. 이벤트 기능만 켠 경우에도 설정할 수 있습니다. 숨김 장소는 공개 조회에서만 숨겨지며 참석자 메일에는 포함됩니다. Sandbox에서는 변경 알림을 내부 기록으로 남깁니다.
준비
events를 켜고, 공개된 캘린더, registrationPolicy: rsvp(또는 approval)인 게시된(published) 공개 또는 unlisted 이벤트, 정원이 남은 열린 회차를 준비하세요. 이 가이드는 무료 RSVP만 다루며 Cart, Checkout, 유료 입장권, 운영자 등록은 다루지 않습니다. 성공했다고 모두 "확정"으로 표시하지 말고 서버가 돌려준 상태를 보여 주세요.
| 정책 | 상태 |
|---|---|
rsvp | going |
approval | pending |
rsvp이고 자동 대기자 명단이 켜져 있으며 회차 정원이 찬 경우 | waitlisted |
공개 일정 조회와 비회원 등록에는 로그인한 고객이 필요하지 않습니다. 로그인을 추가하려면 먼저 Customers의 파일을 모두 복사하고, 같은 워크스페이스의 호스티드 client, origin, redirect, HttpOnly 세션을 설정하세요. 브라우저에서 호출하려면 브라우저 origin도 허용하세요.
예제는 Next.js App Router 앱에서 요청마다 실행됩니다. 확인한 버전과 구성은 빌드 옵션에 있습니다.
일정과 무료 RSVP 구현
Customers 세션 helper 다음에 아래 파일을 복사하세요. 아래의 signupRequired는 앱이 정하는 상수이며 플랫폼 이벤트 필드가 아닙니다. true이면 앱의 신뢰할 수 있는 Server Action이 등록 전에 me()로 호스티드 세션을 확인하고, false이면 비회원 등록을 허용합니다. 세션이 유효하지 않으면 비회원 등록으로 바꾸지 않고 다시 로그인하게 합니다. 앱은 호출자가 넘긴 customer나 워크스페이스를 SDK에 보내지 않습니다.
플랫폼에는 RSVP를 로그인 사용자로 제한하는 서버 규칙이 없습니다. 공개 등록은 등록 정책, 공개 상태와 노출 여부, 캘린더와 회차, 중복, 정원을 확인하며 고객 JWT는 선택입니다. 이 앱에서 로그인을 요구해도 다른 클라이언트가 같은 Publishable Key로 비회원 등록하는 것은 막지 못하므로, 워크스페이스 전체의 로그인 규칙이 아닙니다.
// app/events/page.tsx
import { createClient } from '@01.software/sdk'
import { registerRsvp } from './actions'
export const dynamic = 'force-dynamic'
export default async function EventsPage() {
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL,
})
const range = await client.events.getRange({ limit: 10 })
const occurrences = range.docs.filter(
(row) =>
row.status === 'scheduled' &&
['rsvp', 'approval'].includes(row.event.registrationPolicy ?? ''),
)
if (occurrences.length === 0)
return <p>No open RSVP occurrences in this range.</p>
return (
<ul>
{occurrences.map((occurrence) => (
<li key={occurrence.id}>
<h2>{occurrence.event.title ?? 'Untitled event'}</h2>
<p>
{occurrence.isAllDay
? `${occurrence.allDayStartDate} / ${occurrence.allDayEndDateExclusive} (exclusive)`
: occurrence.startsAt}
</p>
<form action={registerRsvp}>
<input
type="hidden"
name="event"
value={String(occurrence.event.id)}
/>
<input
type="hidden"
name="occurrence"
value={String(occurrence.id)}
/>
<input
name="email"
type="email"
required
aria-label="Attendee email"
/>
<button>RSVP</button>
</form>
</li>
))}
</ul>
)
}// app/events/actions.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { currentCustomer, cookieOptions } from '@/lib/customer'
// App-owned policy. false permits guests; true gates this app's action only.
const signupRequired = true
export async function registerRsvp(form: FormData) {
const { client, customer, hadToken } = await currentCustomer()
if ((signupRequired || hadToken) && !customer) redirect('/api/account/login')
const event = String(form.get('event') ?? '')
const occurrence = String(form.get('occurrence') ?? '')
const email = String(form.get('email') ?? '').trim()
if (!event || !occurrence || !email)
throw new Error('Select an event and enter an email')
const result = await client.events.register({
event,
occurrence,
quantity: 1,
attendee: { email },
})
// Token is a capability: never put it in a URL, log, or public response.
const jar = await cookies()
jar.set('__Host-rsvp-token', result.guestToken, {
...cookieOptions,
expires: new Date(result.guestTokenExpiresAt),
})
redirect('/events/manage')
}// app/events/manage/page.tsx
import { cookies } from 'next/headers'
import { customerClient, cookieOptions } from '@/lib/customer'
import { CancelRsvp } from './cancel-form'
export const dynamic = 'force-dynamic'
export default async function ManageRsvp() {
const token = (await cookies()).get('__Host-rsvp-token')?.value
const registration = token
? (await customerClient().events.getGuestRegistration(token)).registration
: null
async function cancel(): Promise<'canceled'> {
'use server'
const jar = await cookies()
const current = jar.get('__Host-rsvp-token')?.value
if (!current) throw new Error('RSVP token missing')
const result =
await customerClient().events.cancelGuestRegistration(current)
if (result.registration.registrationStatus !== 'canceled')
throw new Error('Cancellation unconfirmed')
// Successful cancellation revokes the guest token; never look it up again.
jar.set('__Host-rsvp-token', '', { ...cookieOptions, maxAge: 0 })
return 'canceled'
}
return (
<main>
<h1>Manage RSVP</h1>
<CancelRsvp
registrationStatus={registration?.registrationStatus ?? null}
cancel={cancel}
/>
</main>
)
}// app/events/manage/cancel-form.tsx
'use client'
import { useActionState } from 'react'
type CancelState = 'ready' | 'canceled' | 'failed'
export function CancelRsvp({
registrationStatus,
cancel,
}: {
registrationStatus: string | null
cancel: () => Promise<'canceled'>
}) {
const [state, submit, pending] = useActionState<CancelState, FormData>(
async () => {
try {
return await cancel()
} catch {
return 'failed'
}
},
'ready',
)
if (state === 'canceled') return <p role="status">RSVP canceled.</p>
if (!registrationStatus)
return (
<p>
No RSVP token in this browser. Make a new reservation or contact
support.
</p>
)
if (!['pending', 'going', 'waitlisted'].includes(registrationStatus))
return <p>RSVP: {registrationStatus}. Contact support for changes.</p>
return (
<form action={submit}>
<p>RSVP: {registrationStatus}</p>
{state === 'failed' && (
<p role="alert">
Cancellation unconfirmed. Contact support before retrying.
</p>
)}
<button disabled={pending}>Cancel RSVP</button>
</form>
)
}정상·빈 결과·실패
/events를 여세요. 등록 정책이 rsvp 또는 approval인 예정 회차에만 RSVP 양식이 나옵니다.
- 걸러낸 결과가 비어 있으면 성공한 빈 상태입니다.
none,external이벤트와 끝난 회차에는 RSVP 양식이 없습니다. - 이 필터는 표시용일 뿐 자리를 잡아 두지 않습니다. 서버는 등록마다 다시 확인합니다.
- 앱은
getRange({ limit: 10 })뒤에 거르므로, 첫 페이지가 비어도 다음 페이지에 RSVP 회차가 있을 수 있습니다. 없다고 판단하기 전에 페이지를 넘겨 보세요. - 거절된 등록은 취소 버튼 없이 상태만 보여 줍니다.
- 종일 회차는 시작일과 종료일(종료일 미포함)을 날짜로 보여 주세요. 보는 사람의 시간대로 다시 계산하지 마세요.
그다음 비회원 흐름을 확인합니다. 회차를 골라 RSVP를 한 번 제출하고, /events/manage에서 따로 조회한 뒤 그곳에서 취소합니다.
registrationStatus: canceled인 취소 성공 응답일 때만 완료 메시지를 보여 주고 비회원 토큰 쿠키를 지웁니다.- 취소하면 토큰이 폐기되므로 이후 조회와 취소는 401을 반환합니다. 그 토큰으로 리디렉션하거나 다시 조회하지 마세요. 새로고침하면 토큰이 없는 상태가 보입니다.
- 취소 전 상태는 조회 결과에서 가져옵니다. 취소 후 결과를 확인하려면 폐기된 토큰이 아니라 권한 있는 워크스페이스 운영자가 필요합니다.
- 비회원 토큰은 한 번만 반환되며 서버에는 해시만 남습니다. 이 예제는 RSVP 토큰 하나를 Secure HttpOnly 쿠키에 보관하므로, 나중의 RSVP가 이전 토큰을 덮어씁니다. 토큰을 잃으면 비공개 등록 목록이 아니라 운영자의 지원 절차가 필요합니다.
- 비회원 조회 응답에는 참석자 개인정보가 있을 수 있습니다. 상태만 보여 주고 응답 전체를 출력하지 마세요.
| 상황 | 표시 |
|---|---|
| 쿠키 없음 | 빈 관리 화면 |
| 운영자가 등록이나 회차를 취소한 뒤 조회가 거부됨 | "RSVP 정보를 확인할 수 없습니다. 행사 주최자에게 상태 확인과 이 브라우저의 RSVP 쿠키 초기화를 요청하세요." 같은 조회를 반복해도 다시 열리지 않으며, 쿠키를 지웠다고 취소된 것은 아닙니다. |
| 유효하지 않거나 만료된 비회원 토큰, 닫힌 회차, 대기자 명단 없이 마감, 중복 등록, 기능 거부, 네트워크 실패 | 오류 |
다른 워크스페이스의 것이거나 유효하지 않은 고객 JWT여서 me()가 null을 반환 | 앱의 동작을 로그인으로 리디렉션합니다. 비회원 등록으로 처리하지 마세요. |
| 401을 포함한 취소 실패 | 오류. 취소가 확인된 것처럼 성공을 표시하거나 쿠키를 지우지 마세요. |
페이지에는 읽기 재시도 경계를, 동작에는 등록이나 취소를 저절로 다시 보내지 않는 오류 화면을 두세요. 응답이 유실돼도 등록은 저장됐을 수 있으며, 그 경우 비회원에게는 조회할 토큰이 없습니다. 응답을 잃은 등록을 재시도하기 전이나 취소 응답이 유실됐을 때는 권한 있는 워크스페이스 운영자에게 상태 확인을 요청하세요. 로그인한 고객은 listMyRegistrations로도 확인할 수 있습니다. 토큰이 거부됐다는 것만으로는 취소의 근거가 되지 않습니다.
스타터 호환과 로컬 확인
빠른 시작의 create 0.2.10 → init 0.19.4 구성에는 SDK 0.74.0을 쓰는 Events 0.1.30 스타터가 들어 있습니다. 이 스타터는 비회원 중심이며 무료 RSVP·승인제 신청과 유료 입장권을 지원합니다. 선택 기능인 고객 계정은 이 가이드의 호스티드 흐름이 아니라 direct legacy 로그인을 씁니다. 무료 RSVP를 위해 Checkout이나 결제 키를 추가하지 마세요. mock 데모나 통제된 로컬 전송 계층은 앱만 시험합니다. 실제 등록과 호스티드 로그인은 배포 뒤에 확인하세요.
비회원 흐름(등록 → 조회 → 취소)을 확인한 뒤, signupRequired: true로 이 앱의 로그인·비로그인 경로를 확인하세요. 공개 등록이 열려 있는 동안 비회원 직접 호출이 여전히 가능한지도 확인합니다.
다음 액션
캘린더 소유와 회차 취소는 콘텐츠 운영, 유료 입장권은 Events SDK: 이벤트 입장권, 출시 전 확인은 배포를 참고하세요. 구매 가능 수량은 힌트일 뿐 좌석 예약이 아닙니다. 유료 입장권 구매는 Ecommerce 흐름을 따르며 이 무료 RSVP 작업의 범위 밖입니다.
init 0.17.1 이상(빠른 시작의 고정 버전 포함)에서는 별도의 events-rsvp 스타터가 사라졌습니다. 새 무료 RSVP, 승인제, 유료 이벤트 앱에는 --template events를 쓰세요. 사라진 이름을 쓰면 종료 코드 1과 대체 이름 안내를 출력하며 실패합니다. Events에는 기본적으로 Events와 Ecommerce가 모두 필요합니다. 이미 만든 앱은 바뀌지 않습니다. 현재 스타터 목록은 빌드 옵션을 참고하세요.