diff --git a/apps/community-mobile/app/auth/otp.tsx b/apps/community-mobile/app/auth/otp.tsx index 93d5634e..6af0c9ab 100644 --- a/apps/community-mobile/app/auth/otp.tsx +++ b/apps/community-mobile/app/auth/otp.tsx @@ -4,6 +4,7 @@ import { useRouter, useLocalSearchParams } from "expo-router" import { useSafeAreaInsets } from "react-native-safe-area-context" import Ionicons from "@expo/vector-icons/Ionicons" import { + EMAIL_OTP_CODE_LENGTH, REVIEWER_OTP_CODE_MAX_LENGTH, REVIEWER_OTP_CODE_MIN_LENGTH, REVIEWER_OTP_EMAIL, @@ -32,10 +33,9 @@ import { useResendCooldown } from "@/hooks/useResendCooldown" const { ScrollView: OtpScrollView } = makeKeyboardAwareScrollHost(PLAIN_SCROLL_HOST) const MAX_ATTEMPTS = 3 -const CODE_LENGTH = 6 function isCodeIncomplete(code: string, reviewer: boolean): boolean { - return reviewer ? code.trim().length < REVIEWER_OTP_CODE_MIN_LENGTH : code.length !== CODE_LENGTH + return reviewer ? code.trim().length < REVIEWER_OTP_CODE_MIN_LENGTH : code.length !== EMAIL_OTP_CODE_LENGTH } export default function OtpScreen() { @@ -194,7 +194,7 @@ export default function OtpScreen() { setError(null) setCode(next) }} - length={CODE_LENGTH} + length={EMAIL_OTP_CODE_LENGTH} onComplete={onVerify} editable={!locked && !verifying} /> diff --git a/apps/community-mobile/src/components/FirstRunGate.tsx b/apps/community-mobile/src/components/FirstRunGate.tsx index 27a3dd4e..11063960 100644 --- a/apps/community-mobile/src/components/FirstRunGate.tsx +++ b/apps/community-mobile/src/components/FirstRunGate.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react" import { View, StyleSheet, ActivityIndicator, Pressable } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import Ionicons from "@expo/vector-icons/Ionicons" +import { HANDLE_MAX_LENGTH } from "@civfix/shared" import { MIN_TOUCH_TARGET, makeThemedStyles, space, useTheme } from "@/theme" import { AgeConfirmation, @@ -32,9 +33,6 @@ import { friendlyError } from "@/lib/errors" const { ScrollView: FirstRunScrollView } = makeKeyboardAwareScrollHost(PLAIN_SCROLL_HOST) -// The upper bound of the shared HANDLE_REGEX, so the field stops where validation would reject. -const HANDLE_MAX_LENGTH = 20 - export function FirstRunGate() { const status = useAuthStore((s) => s.status) const incomplete = useAuthStore((s) => s.user?.profileComplete === false) diff --git a/apps/community-mobile/src/lib/authResume.ts b/apps/community-mobile/src/lib/authResume.ts index c50ab031..4c700f30 100644 --- a/apps/community-mobile/src/lib/authResume.ts +++ b/apps/community-mobile/src/lib/authResume.ts @@ -1,3 +1,5 @@ +import { stripTrailingSlashes } from "@civfix/shared" + const ROOT_ROUTE = "/" function routeQuery(params: unknown): string { @@ -27,7 +29,7 @@ export function resumePathname(href: string): string { const cut = href.search(/[?#]/) const path = cut === -1 ? href : href.slice(0, cut) if (path.length > 1 && path.endsWith("/")) { - const trimmed = path.replace(/\/+$/, "") + const trimmed = stripTrailingSlashes(path) return trimmed.length > 0 ? trimmed : "/" } return path diff --git a/apps/community-web/src/components/auth/auth-modal.tsx b/apps/community-web/src/components/auth/auth-modal.tsx index 60da180e..5eb67347 100644 --- a/apps/community-web/src/components/auth/auth-modal.tsx +++ b/apps/community-web/src/components/auth/auth-modal.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { createPortal } from "react-dom" import { X } from "lucide-react" +import { EMAIL_OTP_CODE_LENGTH } from "@civfix/shared" import { useT } from "@civfix/ui/i18n" import { useFocusTrap } from "@/components/console/overlay/use-focus-trap" @@ -18,8 +19,6 @@ import { useUiStore } from "@/store/ui-store" import { AuthStepHeading, ChoicesStep, CodeStep, EmailStep, type AuthStep } from "./auth-modal-steps" import { useOtpEntry } from "./use-otp-entry" -const OTP_LENGTH = 6 - /** * The return target is the current origin, or a same-origin console path allowlisted by oauthReturnPath * (such as an org invite waiting to be accepted). Apple is offered only when the server has a Sign in @@ -51,7 +50,7 @@ export function AuthModal({ oauthReturnPath = null }: AuthModalProps = {}) { const [step, setStep] = React.useState("choices") const [email, setEmail] = React.useState("") - const otp = useOtpEntry(OTP_LENGTH) + const otp = useOtpEntry(EMAIL_OTP_CODE_LENGTH) const [submitting, setSubmitting] = React.useState(false) const [error, setError] = React.useState(null) const [resendAfter, setResendAfter] = React.useState(0) @@ -222,7 +221,7 @@ export function AuthModal({ oauthReturnPath = null }: AuthModalProps = {}) { ({ - at: new Date(startsAt + bucket.offsetMin * MINUTE_MS).toISOString(), + at: new Date(startsAt + bucket.offsetMin * MS_PER_MINUTE).toISOString(), count: bucket.seats, })), asOf: new Date().toISOString(), @@ -407,18 +407,18 @@ function phaseRoster(id: string, phase: EventPhase): ListEventRegistrationsRespo attendeeName: seatIndex === 0 ? who.name : null, status: "active" as const, ticketToken: null, - checkedInAt: checkedIn ? new Date(startsAt + i * 6 * MINUTE_MS).toISOString() : null, + checkedInAt: checkedIn ? new Date(startsAt + i * 6 * MS_PER_MINUTE).toISOString() : null, checkinMethod: checkedIn ? ("scan" as const) : null, noShowAt: null, })), status: i === ROSTER_PEOPLE.length - 1 ? "cancelled" : "registered", source: i === 3 ? "walkup" : "self", - registeredAt: new Date(startsAt - (14 - i) * DAY_MS).toISOString(), + registeredAt: new Date(startsAt - (14 - i) * MS_PER_DAY).toISOString(), cancelledAt: i === ROSTER_PEOPLE.length - 1 - ? new Date(startsAt - 2 * DAY_MS).toISOString() + ? new Date(startsAt - 2 * MS_PER_DAY).toISOString() : null, - checkedInAt: checkedIn ? new Date(startsAt + i * 6 * MINUTE_MS).toISOString() : null, + checkedInAt: checkedIn ? new Date(startsAt + i * 6 * MS_PER_MINUTE).toISOString() : null, checkedInBy: checkedIn ? HOST : null, slot: null, waitlistPosition: null, diff --git a/apps/community-web/src/components/dev/fixtures.ts b/apps/community-web/src/components/dev/fixtures.ts index 7c00a582..d68c9d4b 100644 --- a/apps/community-web/src/components/dev/fixtures.ts +++ b/apps/community-web/src/components/dev/fixtures.ts @@ -11,14 +11,11 @@ import type { UserDTO, UserProfileDTO, } from "@civfix/shared" - -export const MINUTE_MS = 60_000 -export const HOUR_MS = 60 * MINUTE_MS -export const DAY_MS = 24 * HOUR_MS +import { MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE } from "@civfix/shared" export const isoFromNow = (ms: number): string => new Date(Date.now() + ms).toISOString() -export const hoursAgo = (h: number): string => isoFromNow(-h * HOUR_MS) -export const daysAgo = (d: number): string => isoFromNow(-d * DAY_MS) +export const hoursAgo = (h: number): string => isoFromNow(-h * MS_PER_HOUR) +export const daysAgo = (d: number): string => isoFromNow(-d * MS_PER_DAY) export const GALLERY_VIEWER = { id: "me", @@ -139,7 +136,7 @@ function chatMessage( }, body, kind: "text", - createdAt: isoFromNow(-minsAgo * MINUTE_MS), + createdAt: isoFromNow(-minsAgo * MS_PER_MINUTE), } as ChatMessageDTO } diff --git a/apps/community-web/src/components/dev/landscape-fake-api.ts b/apps/community-web/src/components/dev/landscape-fake-api.ts index bc2b82e3..f8240c67 100644 --- a/apps/community-web/src/components/dev/landscape-fake-api.ts +++ b/apps/community-web/src/components/dev/landscape-fake-api.ts @@ -15,10 +15,10 @@ import type { ReportDTO, UserProfileDTO, } from "@civfix/shared" +import { MS_PER_DAY } from "@civfix/shared" import { makeFakeApiClient } from "@civfix/ui/data" import { - DAY_MS, GALLERY_LEADERBOARD_PODIUM, GALLERY_MY_PROFILE, GALLERY_NOTIFICATIONS, @@ -77,7 +77,7 @@ function cleanup( description: "Gloves, bags and grabbers provided. Meet at the gate; we finish by noon.", lat: 37.77, lng: -122.42, - scheduledAt: isoFromNow(inDays * DAY_MS), + scheduledAt: isoFromNow(inDays * MS_PER_DAY), status: "upcoming", organizer, going: 12, diff --git a/apps/community-web/src/features/auth/first-run-gate.tsx b/apps/community-web/src/features/auth/first-run-gate.tsx index bc535cda..9677c727 100644 --- a/apps/community-web/src/features/auth/first-run-gate.tsx +++ b/apps/community-web/src/features/auth/first-run-gate.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { Loader2, Check, X } from "lucide-react" +import { HANDLE_MAX_LENGTH } from "@civfix/shared" import { space } from "@civfix/shared/tokens" import { Avatar, AgeConfirmation, TermsConfirmation } from "@civfix/ui" @@ -20,7 +21,6 @@ import { useCurrentUser, useLogout } from "@/hooks/use-auth" import { useVisualViewportShift } from "@/hooks/use-visual-viewport-shift" import { useFirstRunRequired } from "@/hooks/use-profile-registration" import { errorMessage } from "@/lib/error-messages" -import { HANDLE_MAX_LENGTH } from "@/lib/input-limits" import { SESSION_ALERT_ATTR, Z_FIRST_RUN_GATE } from "@/styles/z-layers" export function FirstRunGate() { diff --git a/apps/community-web/src/features/host/broadcasts/broadcast-composer.tsx b/apps/community-web/src/features/host/broadcasts/broadcast-composer.tsx index cae7b470..92a77170 100644 --- a/apps/community-web/src/features/host/broadcasts/broadcast-composer.tsx +++ b/apps/community-web/src/features/host/broadcasts/broadcast-composer.tsx @@ -5,6 +5,7 @@ import { Eye, Send, Clock, TestTube } from "lucide-react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import type { BroadcastDTO, BroadcastPreviewDTO, BroadcastSegment } from "@civfix/shared" import { + HTTPS_URL_MAX_LENGTH, MAX_BROADCAST_BODY, MAX_BROADCAST_CTA_LABEL, MAX_BROADCAST_SUBJECT, @@ -14,7 +15,6 @@ import { useApi, useAuthState, useEventTicketTypes } from "@civfix/ui/data" import { useT } from "@civfix/ui/i18n" import type { Translate } from "@civfix/ui/i18n" -import { HTTPS_URL_MAX_LENGTH } from "@/lib/input-limits" import { fieldErrorsFrom } from "@/components/console/query-state" import { ConsoleButton } from "@/components/console/button" import { Chip } from "@/components/console/chips/chip" diff --git a/apps/community-web/src/features/host/org/org-invites.ts b/apps/community-web/src/features/host/org/org-invites.ts index e326d086..d0c04150 100644 --- a/apps/community-web/src/features/host/org/org-invites.ts +++ b/apps/community-web/src/features/host/org/org-invites.ts @@ -1,8 +1,7 @@ -import type { OrganizationInviteDTO } from "@civfix/shared" +import { EMAIL_MAX_LENGTH, MS_PER_DAY, type OrganizationInviteDTO } from "@civfix/shared" import { notifyConsoleUrlChanged } from "@/components/console/url-state" import { safeGet, safeRemove, safeSet } from "@/lib/browser-storage" -import { EMAIL_MAX_LENGTH } from "@/lib/input-limits" import { replaceUrlInPlace } from "@/lib/replace-url" /** @@ -20,8 +19,6 @@ export const INVITE_IDENTIFIER_MAX = EMAIL_MAX_LENGTH /** The backend's `ORG_INVITE_TTL_MS` (14 days) - the copy says it before an invite exists. */ export const ORG_INVITE_TTL_DAYS = 14 -const DAY_MS = 24 * 60 * 60 * 1000 - /** * Whole days until `iso`, rounded up so "expires in 1 day" reads until the moment it expires and * never says "0 days" for a still-valid invite. Zero or negative means it has already expired. @@ -29,7 +26,7 @@ const DAY_MS = 24 * 60 * 60 * 1000 export function daysUntil(iso: string, now: number = Date.now()): number { const at = new Date(iso).getTime() if (Number.isNaN(at)) return 0 - return Math.ceil((at - now) / DAY_MS) + return Math.ceil((at - now) / MS_PER_DAY) } /** diff --git a/apps/community-web/src/features/host/org/org-profile-form.tsx b/apps/community-web/src/features/host/org/org-profile-form.tsx index 9bf51997..d75a2e9b 100644 --- a/apps/community-web/src/features/host/org/org-profile-form.tsx +++ b/apps/community-web/src/features/host/org/org-profile-form.tsx @@ -8,21 +8,24 @@ import type { OrganizationDTO, SocialLinks, SocialPlatform } from "@civfix/share import { CreateOrganizationRequestSchema, ErrorCode, + HTTPS_URL_MAX_LENGTH, MAX_ORG_DESCRIPTION, MAX_ORG_NAME, ORG_SLUG_MAX, ORG_SLUG_MIN, SafeHttpsLinkSchema, + SOCIAL_HANDLE_MAX_LENGTH, + SOCIAL_HANDLE_PREFIX, SOCIAL_PLATFORMS, SOCIAL_PLATFORM_LABELS, UpdateOrganizationRequestSchema, + WHATSAPP_NUMBER_MAX_LENGTH, toAppError, } from "@civfix/shared" import { useApi, useAuthState } from "@civfix/ui/data" import { useT } from "@civfix/ui/i18n" import { cn } from "@/lib/utils" -import { HTTPS_URL_MAX_LENGTH } from "@/lib/input-limits" import { ConsoleButton } from "@/components/console/button" import { Field } from "@/components/console/forms/field" import { CONSOLE_INPUT_CLASSES, TextInput, TextArea } from "@/components/console/forms/inputs" @@ -49,25 +52,11 @@ import type { OrgSlugProblem } from "./org-slug" import { suspendedForbiddenCopy } from "./org-copy" import type { Translate } from "@civfix/ui/i18n" -/** Mirrors the contract's social handle pattern (`{1,30}`). */ -const SOCIAL_HANDLE_MAX = 30 -/** An E.164 number is at most 15 digits. */ -const WHATSAPP_NUMBER_MAX = 15 - const SECTION_CLASSES = "rounded-md border border-console-line bg-console-surface p-token-4 shadow-console-1" const PREFIX_BOX_CLASSES = "inline-flex items-center rounded-l-xs border border-r-0 border-console-line bg-console-surface-alt" -/** What each social field is prefixed with so the host types only the handle. */ -const SOCIAL_PREFIX: Record = { - facebook: "facebook.com/", - instagram: "instagram.com/", - tiktok: "tiktok.com/@", - x: "x.com/", - whatsapp: "+", -} - export interface OrgProfileDraft { name: string slug: string @@ -730,7 +719,7 @@ function SocialLinkField({ optional error={error} > - + onChange(event.target.value)} diff --git a/apps/community-web/src/features/host/org/verification-screen.tsx b/apps/community-web/src/features/host/org/verification-screen.tsx index 8c0ce61d..ea5bc77b 100644 --- a/apps/community-web/src/features/host/org/verification-screen.tsx +++ b/apps/community-web/src/features/host/org/verification-screen.tsx @@ -12,6 +12,7 @@ import type { import { ApplyOrganizationVerificationRequestSchema, MAX_ORG_VERIFICATION_DOCUMENTS, + MAX_ORG_VERIFICATION_NOTE, } from "@civfix/shared" import { useApi } from "@civfix/ui/data" import { useT } from "@civfix/ui/i18n" @@ -40,8 +41,6 @@ import { suspendedForbiddenCopy } from "./org-copy" import { useOrgVerification } from "./use-org-verification" const KINDS: readonly OrgVerificationKind[] = ["nonprofit", "government", "community"] -/** Mirrors the contract's unexported `note` max on the apply request. */ -const MAX_NOTE = 1000 /** "XX-XXXXXXX", the shape `normalizeEin` types into the field. */ const EIN_INPUT_MAX = 10 @@ -350,13 +349,13 @@ function ApplyForm({ orgId, onSubmitted }: { orgId: string; onSubmitted: () => v label={t("verification.field_note", { defaultValue: "Note for the reviewer" })} htmlFor="verification-note" optional - counter={`${note.length}/${MAX_NOTE}`} + counter={`${note.length}/${MAX_ORG_VERIFICATION_NOTE}`} error={showError("note")} >