feat: add timezone selection field and related utilities to Circles collection

This commit is contained in:
jinhojang6
2026-05-11 04:03:01 +09:00
parent f7d91b6117
commit d701a2d08e
9 changed files with 151 additions and 9 deletions
+1
View File
@@ -57,3 +57,4 @@ apps/*/public/fonts/**/*.otf
!apps/*/public/fonts/rhymes-display/*.woff2
mcp.json
.env*.local
+2
View File
@@ -61,6 +61,8 @@ pnpm generate-types # cms only — regenerates packages/types/src/payload.ts
- **Every clickable element gets `cursor-pointer`** in its Tailwind className: buttons, `onClick` handlers, anchors, clickable cards.
- **Visuals match Figma 1:1.** Pull the spec (font sizes, fills, gaps, padding) from Figma before implementing. See `docs/components.md` for canonical node IDs and `docs/web-pages.md` for per-page references.
- **Types on public APIs.** Exported functions, shared utilities, component props. Use `interface` for object shapes, `type` for unions/intersections. Avoid `any`; use `unknown` + narrowing for external input.
- **Keep reusable code out of generated or oversized files.** If a type, constant list, validator, or UI helper is used in more than one place, move it into a focused module and import it. Do not let generated files or collection configs absorb long literal unions, large option arrays, or repeated validation logic.
- **Split files by feature when they grow.** Prefer small folders with focused files (types, constants, validators, components) over large catch-all modules. Create a feature folder once a file mixes multiple responsibilities or becomes hard to scan.
- **Immutability.** Spread/copy, never mutate. `Readonly<T>` on inputs where it clarifies intent.
- **No `console.log` in committed code.**
- **Follow existing file organization.** Many small focused files over large ones. Routes live under `apps/web/app/[locale]/<route>/`; section components under `apps/web/components/sections/<section>/`.
+4
View File
@@ -30,6 +30,10 @@ DATABASE_URL=postgresql://user:password@host:6543/postgres
# the same Postgres database hosts multiple environments (e.g. "payload_dev").
# PAYLOAD_DB_SCHEMA=payload
# Max Postgres clients opened by the CMS process. Keep local dev small when
# using Supabase's session pooler, where pool_size is often limited.
# PAYLOAD_DB_POOL_MAX=3
# Schema auto-sync. Defaults to true (Phase 1 bootstrap). Flip to "false"
# once SQL migrations are wired up so production no longer mutates schema on
# every boot. See payload.config.ts for the full rationale.
+9
View File
@@ -69,6 +69,14 @@ if (isProduction && !payloadSecret) {
)
}
const databasePoolMax = Number.parseInt(
process.env.PAYLOAD_DB_POOL_MAX || (isProduction ? '10' : '3'),
10
)
if (!Number.isInteger(databasePoolMax) || databasePoolMax < 1) {
throw new Error('PAYLOAD_DB_POOL_MAX must be a positive integer')
}
export default buildConfig({
admin: {
components: {
@@ -97,6 +105,7 @@ export default buildConfig({
db: postgresAdapter({
pool: {
connectionString: databaseUrl,
max: databasePoolMax,
},
// Isolate Payload tables from any other app sharing the database.
schemaName: process.env.PAYLOAD_DB_SCHEMA || 'payload',
@@ -25,6 +25,7 @@ import { RfpLockBanner as RfpLockBanner_24e027e9ff0225667bcf33133ddb2c82 } from
import { SaveRfpPrButton as SaveRfpPrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx'
import { IdeaLockBanner as IdeaLockBanner_24e027e9ff0225667bcf33133ddb2c82 } from '@/components/admin/lock-banner.tsx'
import { SaveIdeaPrButton as SaveIdeaPrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx'
import { TimezoneField as TimezoneField_675c144de479f7a98214bb815213c778 } from '@/components/admin/timezone-field.tsx'
import { SaveCirclePrButton as SaveCirclePrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx'
import { SaveCircleEventPrButton as SaveCircleEventPrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx'
import { SaveCircleInitiativePrButton as SaveCircleInitiativePrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx'
@@ -60,6 +61,7 @@ export const importMap = {
"@/components/admin/save-pr-button.tsx#SaveRfpPrButton": SaveRfpPrButton_4ee2b20d9808739a2dc76b5b7d69dc52,
"@/components/admin/lock-banner.tsx#IdeaLockBanner": IdeaLockBanner_24e027e9ff0225667bcf33133ddb2c82,
"@/components/admin/save-pr-button.tsx#SaveIdeaPrButton": SaveIdeaPrButton_4ee2b20d9808739a2dc76b5b7d69dc52,
"@/components/admin/timezone-field.tsx#TimezoneField": TimezoneField_675c144de479f7a98214bb815213c778,
"@/components/admin/save-pr-button.tsx#SaveCirclePrButton": SaveCirclePrButton_4ee2b20d9808739a2dc76b5b7d69dc52,
"@/components/admin/save-pr-button.tsx#SaveCircleEventPrButton": SaveCircleEventPrButton_4ee2b20d9808739a2dc76b5b7d69dc52,
"@/components/admin/save-pr-button.tsx#SaveCircleInitiativePrButton": SaveCircleInitiativePrButton_4ee2b20d9808739a2dc76b5b7d69dc52,
+18 -9
View File
@@ -1,11 +1,6 @@
import type { CollectionConfig, Field } from 'payload'
const timeZoneOptions = ['UTC', ...Intl.supportedValuesOf('timeZone')].map(
(timeZone) => ({
label: timeZone,
value: timeZone,
})
)
import { isValidIanaTimeZone } from '@/lib/timezones'
const statusField: Field = {
name: 'status',
@@ -37,10 +32,24 @@ const slugField: Field = {
const createTimeZoneField = (width: string): Field => ({
name: 'timezone',
type: 'select',
type: 'text',
required: true,
options: timeZoneOptions,
admin: { width },
admin: {
components: {
Field: '@/components/admin/timezone-field.tsx#TimezoneField',
},
width,
},
validate: (value) => {
if (typeof value !== 'string' || value.length === 0) {
return 'A timezone is required.'
}
return (
isValidIanaTimeZone(value) ||
'Must be a valid IANA timezone, e.g. "America/Los_Angeles".'
)
},
})
export const Circles: CollectionConfig = {
@@ -0,0 +1,91 @@
'use client'
import { SelectInput, useField } from '@payloadcms/ui'
import { useMemo } from 'react'
import { IANA_TIME_ZONE_OPTIONS } from '@/lib/timezones'
import type { IanaTimeZone } from '@/types/timezones'
interface TimezoneFieldProps {
field: {
admin?: {
description?: string
isClearable?: boolean
placeholder?: string
}
label?: string
name: string
required?: boolean
}
path: string
readOnly?: boolean
}
const getSelectedValue = (selectedOption: unknown): IanaTimeZone | null => {
if (
typeof selectedOption === 'object' &&
selectedOption !== null &&
'value' in selectedOption &&
typeof selectedOption.value === 'string'
) {
return selectedOption.value
}
return null
}
export function TimezoneField({
field,
path: pathFromProps,
readOnly,
}: TimezoneFieldProps) {
const {
customComponents: {
AfterInput,
BeforeInput,
Description,
Error,
Label,
} = {},
disabled,
path,
setValue,
showError,
value,
} = useField<string>({
potentiallyStalePath: pathFromProps,
})
const optionByValue = useMemo(
() => new Set(IANA_TIME_ZONE_OPTIONS.map((option) => option.value)),
[]
)
return (
<SelectInput
AfterInput={AfterInput}
BeforeInput={BeforeInput}
Description={Description}
description={field.admin?.description}
Error={Error}
filterOption={({ label, value: optionValue }, search) =>
optionByValue.has(optionValue) &&
label.toLowerCase().includes(search.toLowerCase())
}
isClearable={field.admin?.isClearable ?? false}
Label={Label}
label={field.label}
name={field.name}
onChange={(selectedOption) => {
setValue(getSelectedValue(selectedOption))
}}
options={IANA_TIME_ZONE_OPTIONS}
path={path}
placeholder={field.admin?.placeholder ?? 'Select a timezone'}
readOnly={readOnly || disabled}
required={field.required}
showError={showError}
value={typeof value === 'string' ? value : undefined}
/>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { IanaTimeZone, TimeZoneOption } from '@/types/timezones'
export const IANA_TIME_ZONE_OPTIONS: TimeZoneOption[] = [
'UTC',
...Intl.supportedValuesOf('timeZone'),
].map((timeZone) => ({
label: timeZone,
value: timeZone,
}))
export const isValidIanaTimeZone = (value: string): value is IanaTimeZone => {
try {
new Intl.DateTimeFormat('en-US', { timeZone: value }).format(new Date())
return true
} catch {
return false
}
}
+6
View File
@@ -0,0 +1,6 @@
export type IanaTimeZone = string
export interface TimeZoneOption {
label: IanaTimeZone
value: IanaTimeZone
}