diff --git a/.github/workflows/IOSDeploy.yaml b/.github/workflows/IOSDeploy.yaml index 2e97a37a4..c85106021 100644 --- a/.github/workflows/IOSDeploy.yaml +++ b/.github/workflows/IOSDeploy.yaml @@ -3,7 +3,7 @@ name: Deploy EAS Build on: push: branches: - - prod + - mobile-prod jobs: build: diff --git a/.github/workflows/MobileCI.yaml b/.github/workflows/MobileCI.yaml index fce05dff9..3e9a2d90d 100644 --- a/.github/workflows/MobileCI.yaml +++ b/.github/workflows/MobileCI.yaml @@ -97,7 +97,12 @@ jobs: run: cd ../shared && npm ci - name: Generate API types run: cd ../shared && npm run generate:api + - name: Install Doppler CLI + uses: dopplerhq/cli-action@v3 - name: Run tests with coverage + # Not needed currently, just for builds and deployment using doppler run command + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN_MOBILE }} run: npm run test:coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/WebCI.yaml b/.github/workflows/WebCI.yaml index c8669506f..59c2517de 100644 --- a/.github/workflows/WebCI.yaml +++ b/.github/workflows/WebCI.yaml @@ -128,7 +128,12 @@ jobs: run: | npm install --package-lock-only git diff --exit-code package-lock.json + - name: Install Doppler CLI + uses: dopplerhq/cli-action@v3 - name: Build application + # Not needed currently, just for builds and deployment using doppler run command + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN_WEB }} run: npm run build - name: Upload build artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..d300267f1 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..5c05c489b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,400 @@ +# SelfServe + +## Project Structure + +``` +clients/ + web/src/ + routes/ # File-based routing (TanStack Router) + components/ # Feature-organized React components (ui/, home/, requests/, rooms/, profile/) + hooks/ # Custom React hooks + lib/ # Utilities (cn helper, etc.) + tests/ # Test files + shared/src/ + api/ # HTTP client and config + types/ # Shared API types + mobile/ # React Native app (mirrors web structure) +backend/ + cmd/ # Entry points + config/ # Config loading + internal/ + handler/ # HTTP handlers (Fiber) + repository/ # Database access (pgx) + models/ # Domain models with validation tags + service/ # Business logic and app initialization +``` + +## Frontend Patterns + +### File Naming + +- Components: PascalCase (`Button.tsx`, `HomeHeader.tsx`) +- Hooks: kebab-case with `use-` prefix (`use-dropdown.ts`) +- Utilities: camelCase (`utils.ts`) +- Routes: kebab-case with `.` segments (`_protected/rooms.index.tsx`) +- Layout routes: underscore prefix (`__root.tsx`, `_protected.tsx`) + +### Component Structure + +Functional components only. Props typed with `type` or `interface`. Single named export per file. + +```tsx +type ButtonVariant = "primary" | "secondary"; +type ButtonProps = ButtonHTMLAttributes & { + variant?: ButtonVariant; +}; + +const variantStyles: Record = { + primary: "bg-primary text-white hover:bg-primary-hover", + secondary: "bg-bg-container text-text-default hover:bg-bg-selected", +}; + +export function Button({ variant = "secondary", className = "", ...props }: ButtonProps) { + return ( + {open && ( -
+
+ ); +} + +type SidebarProps = { + notifOpen: boolean; + onNotifToggle: () => void; + onHoverChange: (hovered: boolean) => void; +}; + +export function Sidebar({ + notifOpen, + onNotifToggle, + onHoverChange, +}: SidebarProps) { + const { signOut } = useClerk(); const { user } = useUser(); + const [logoutOpen, setLogoutOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); - const displayName = - user?.fullName ?? - [user?.firstName, user?.lastName].filter(Boolean).join(" "); + const avatarUrl = user?.imageUrl; + const initials = [user?.firstName?.[0], user?.lastName?.[0]] + .filter(Boolean) + .join("") + .toUpperCase(); return ( - - {/* Main nav */} - + setSettingsOpen(false)} + /> - {/* Bottom section */} -
- - Settings - - -
- -
-

- {displayName || "User"} + {logoutOpen && ( +

+
+

+ Are you sure you want to log out? +

+

+ You'll be signed out and can log back in anytime.

-

Hotel Chain

+
+ + +
-
- + )} + ); } diff --git a/clients/web/src/components/guests/ActiveBookingCard.tsx b/clients/web/src/components/guests/ActiveBookingCard.tsx new file mode 100644 index 000000000..c881f7bd3 --- /dev/null +++ b/clients/web/src/components/guests/ActiveBookingCard.tsx @@ -0,0 +1,67 @@ +import { CalendarDays, UsersRound } from "lucide-react"; +import type { Stay } from "@shared"; +import { cn } from "@/lib/utils"; +import { formatDate } from "@/utils/dates"; + +type ActiveBookingCardProps = { + stay: Stay; + compact?: boolean; +}; + +export function ActiveBookingCard({ stay, compact }: ActiveBookingCardProps) { + return ( +
+
+ + Suite {stay.room_number} + + {stay.group_size != null && ( +
+ + {stay.group_size} +
+ )} +
+ {compact ? ( +
+
+ Arrival: +
+ + {formatDate(stay.arrival_date)} +
+
+
+ Departure: +
+ + {formatDate(stay.departure_date)} +
+
+
+ ) : ( +
+
+ Arrival: +
+ + {formatDate(stay.arrival_date)} +
+
+
+ Departure: +
+ + {formatDate(stay.departure_date)} +
+
+
+ )} +
+ ); +} diff --git a/clients/web/src/components/guests/AssistanceChip.tsx b/clients/web/src/components/guests/AssistanceChip.tsx new file mode 100644 index 000000000..39fcaf991 --- /dev/null +++ b/clients/web/src/components/guests/AssistanceChip.tsx @@ -0,0 +1,11 @@ +type AssistanceChipProps = { + label: string; +}; + +export function AssistanceChip({ label }: AssistanceChipProps) { + return ( + + {label} + + ); +} diff --git a/clients/web/src/components/guests/GuestBookingHistoryView.tsx b/clients/web/src/components/guests/GuestBookingHistoryView.tsx new file mode 100644 index 000000000..5be271fb0 --- /dev/null +++ b/clients/web/src/components/guests/GuestBookingHistoryView.tsx @@ -0,0 +1,74 @@ +import { ChevronRight } from "lucide-react"; +import { ActiveBookingCard } from "./ActiveBookingCard"; +import { PastBookingCard } from "./PastBookingCard"; +import type { Stay } from "@shared"; + +type GuestBookingHistoryViewProps = { + currentStays: Array; + pastStays: Array; + onBack: () => void; +}; + +export function GuestBookingHistoryView({ + currentStays, + pastStays, + onBack, +}: GuestBookingHistoryViewProps) { + const byYear = pastStays.reduce>>((acc, stay) => { + const year = stay.arrival_date.slice(0, 4); + (acc[year] ??= []).push(stay); + return acc; + }, {}); + const years = Object.keys(byYear).sort((a, b) => Number(b) - Number(a)); + + return ( +
+ {/* Breadcrumb */} +
+ + + Booking History +
+ + {/* Active stays */} + {currentStays.length > 0 && ( +
+

+ Active Bookings ({currentStays.length}) +

+
+ {currentStays.map((stay) => ( + + ))} +
+
+ )} + + {/* Past stays by year */} + {years.length > 0 ? ( + years.map((year) => ( +
+

{year}

+
+ {byYear[year].map((stay, i) => ( + + ))} +
+
+ )) + ) : ( +

No booking history.

+ )} +
+ ); +} diff --git a/clients/web/src/components/guests/GuestDetailsDrawer.tsx b/clients/web/src/components/guests/GuestDetailsDrawer.tsx new file mode 100644 index 000000000..0a278e0ca --- /dev/null +++ b/clients/web/src/components/guests/GuestDetailsDrawer.tsx @@ -0,0 +1,135 @@ +import { X } from "lucide-react"; +import { + useGetGuestsStaysId, + useGetRequestGuestId, + usePutGuestsId, +} from "@shared"; +import { GuestProfileTab } from "./GuestProfileTab"; +import { GuestVisitActivityTab } from "./GuestVisitActivityTab"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; + +export enum GuestDrawerTab { + Profile = "profile", + Activity = "activity", +} + +type GuestDetailsDrawerProps = { + guestId: string; + activeTab: GuestDrawerTab; + onTabChange: (tab: GuestDrawerTab) => void; + onClose: () => void; +}; + +const TABS: Array<{ key: GuestDrawerTab; label: string }> = [ + { key: GuestDrawerTab.Profile, label: "Profile" }, + { key: GuestDrawerTab.Activity, label: "Visit Activity" }, +]; + +export function GuestDetailsDrawer({ + guestId, + activeTab, + onTabChange, + onClose, +}: GuestDetailsDrawerProps) { + const { + data: guest, + isLoading, + isError, + refetch, + } = useGetGuestsStaysId(guestId); + const { data: requestsData } = useGetRequestGuestId(guestId); + const requests = (requestsData as any)?.items ?? requestsData ?? []; + const updateGuest = usePutGuestsId(); + + const handleSaveNotes = async (notes: string) => { + await updateGuest.mutateAsync({ id: guestId, data: { notes } }); + await refetch(); + }; + + return ( +
+ {/* Header */} +
+
+ {isLoading ? ( + + ) : ( +

+ {guest ? `${guest.first_name} ${guest.last_name}` : "Guest"} +

+ )} +
+ +
+ + {/* Tabs */} +
+ {TABS.map(({ key, label }) => ( + + ))} +
+ + {/* Content */} +
+ {isLoading && ( +
+ + + + +
+ )} + {isError && ( +
+ Failed to load guest details. +
+ )} + {!isLoading && !isError && guest && ( + <> + {activeTab === GuestDrawerTab.Profile && ( + + )} + {activeTab === GuestDrawerTab.Activity && ( + + )} + + )} +
+
+ ); +} diff --git a/clients/web/src/components/guests/GuestFilterPopover.tsx b/clients/web/src/components/guests/GuestFilterPopover.tsx new file mode 100644 index 000000000..3042fab86 --- /dev/null +++ b/clients/web/src/components/guests/GuestFilterPopover.tsx @@ -0,0 +1,186 @@ +import { Filter } from "lucide-react"; +import { useCallback, useState } from "react"; +import { Button } from "@/components/ui/Button"; +import { cn } from "@/lib/utils"; + +type GuestFilterPopoverProps = { + availableFloors: Array; + availableGroupSizes: Array; + selectedFloors: Array; + selectedGroupSizes: Array; + onApply: (floors: Array, groupSizes: Array) => void; +}; + +function FilterChip({ + label, + selected, + onClick, +}: { + label: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function toggleItem(arr: Array, item: number): Array { + return arr.includes(item) ? arr.filter((v) => v !== item) : [...arr, item]; +} + +export function GuestFilterPopover({ + availableFloors, + availableGroupSizes, + selectedFloors, + selectedGroupSizes, + onApply, +}: GuestFilterPopoverProps) { + const [open, setOpen] = useState(false); + const [pendingFloors, setPendingFloors] = + useState>(selectedFloors); + const [pendingGroupSizes, setPendingGroupSizes] = + useState>(selectedGroupSizes); + + const handleOpen = () => { + setPendingFloors(selectedFloors); + setPendingGroupSizes(selectedGroupSizes); + setOpen(true); + }; + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + const handleApply = () => { + onApply(pendingFloors, pendingGroupSizes); + setOpen(false); + }; + + const handleReset = () => { + onApply([], []); + setOpen(false); + }; + + const activeFilterCount = selectedFloors.length + selectedGroupSizes.length; + + return ( +
+ + + {open && ( + <> + {/* Backdrop — captures click-outside */} + -
- - -
-
- - - - + + + + {hasCurrentStay ? ( + <> + + + + + + ) : ( +

No active stay.

+ )}
); diff --git a/clients/web/src/components/guests/GuestProfilePageSkeleton.tsx b/clients/web/src/components/guests/GuestProfilePageSkeleton.tsx new file mode 100644 index 000000000..a7436c496 --- /dev/null +++ b/clients/web/src/components/guests/GuestProfilePageSkeleton.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function GuestProfilePageSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/clients/web/src/components/guests/GuestProfileTab.tsx b/clients/web/src/components/guests/GuestProfileTab.tsx new file mode 100644 index 000000000..1ac919f5a --- /dev/null +++ b/clients/web/src/components/guests/GuestProfileTab.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { AssistanceChip } from "./AssistanceChip"; +import type { GuestWithStays } from "@shared"; +import { Button } from "@/components/ui/Button"; +import { cn } from "@/lib/utils"; + +type GuestProfileTabProps = { + guest: GuestWithStays; + onSaveNotes: (notes: string) => Promise; + isSavingNotes: boolean; +}; + +function AssistanceCategory({ + title, + items, +}: { + title: string; + items: Array; +}) { + if (items.length === 0) return null; + return ( +
+

{title}

+
+ {items.map((item) => ( + + ))} +
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +export function GuestProfileTab({ + guest, + onSaveNotes, + isSavingNotes, +}: GuestProfileTabProps) { + const [isEditing, setIsEditing] = useState(false); + const [draftNotes, setDraftNotes] = useState(guest.notes ?? ""); + const [saveError, setSaveError] = useState(false); + + const dndWindow = + guest.do_not_disturb_start && guest.do_not_disturb_end + ? `${guest.do_not_disturb_start} - ${guest.do_not_disturb_end}` + : "\u2014"; + + const accessibility = guest.assistance?.accessibility ?? []; + const dietary = guest.assistance?.dietary ?? []; + const medical = guest.assistance?.medical ?? []; + const hasAssistance = + accessibility.length > 0 || dietary.length > 0 || medical.length > 0; + + const handleEdit = () => { + setDraftNotes(guest.notes ?? ""); + setIsEditing(true); + }; + + const handleCancel = () => { + setIsEditing(false); + setDraftNotes(guest.notes ?? ""); + }; + + const handleSave = async () => { + try { + setSaveError(false); + await onSaveNotes(draftNotes); + setIsEditing(false); + } catch { + setSaveError(true); + } + }; + + return ( +
+ {/* Vital Information */} +
+
+ + + + +
+
+ + {/* Specific Assistance */} +
+

+ Specific Assistance +

+ {hasAssistance ? ( +
+ + + +
+ ) : ( +

+ No assistance needs recorded. +

+ )} +
+ + {/* Notes */} +
+
+

Notes

+ {!isEditing && ( + + )} +
+ + {isEditing ? ( +
+