mirror of
https://github.com/logos-co/logos-web.git
synced 2026-08-27 12:11:14 +00:00
feat: add timezone selection field and related utilities to Circles collection
This commit is contained in:
@@ -57,3 +57,4 @@ apps/*/public/fonts/**/*.otf
|
||||
!apps/*/public/fonts/rhymes-display/*.woff2
|
||||
|
||||
mcp.json
|
||||
.env*.local
|
||||
|
||||
@@ -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>/`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type IanaTimeZone = string
|
||||
|
||||
export interface TimeZoneOption {
|
||||
label: IanaTimeZone
|
||||
value: IanaTimeZone
|
||||
}
|
||||
Reference in New Issue
Block a user