diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 000000000..425d507f4 --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,40 @@ +# Static Metadata Caching Strategy + +To improve application loading speeds and reduce bandwidth, we employ an aggressive edge caching strategy for highly static metadata (majors, semesters, terms, departments). + +## The Cache Header + +All static edge function responses include the following header: + +``` +Cache-Control: public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400 +``` + +### Breakdown: + +- **`public`**: The response can be cached by any cache (browser, CDN). +- **`max-age=86400`**: The browser will cache the data locally for 1 day (86,400 seconds). +- **`s-maxage=604800`**: The CDN (Cloudflare/Vercel) will cache the data for 7 days. +- **`stale-while-revalidate=86400`**: The CDN will serve stale data while fetching the updated data in the background if the cache is older than 7 days, up to an additional 1 day. + +## Frontend Fetch Wrapper + +The frontend `customFetch` utility in `src/utils/fetch.ts` is designed to support this strategy. +For static metadata requests, it ensures: + +1. `cache: "default"` is explicitly passed to the underlying `fetch` API. +2. Cache-busting query strings (like `?timestamp=12345`) are NOT appended, allowing the browser to serve from disk/memory cache. + +## Database Triggers and Cache Invalidation + +Since the data is cached at the CDN for 7 days, any modifications made in the database (e.g., adding a new major) would not reflect immediately. + +To solve this, we implemented a Postgres trigger on the static metadata tables. +When a row is inserted, updated, or deleted, it invokes a PL/pgSQL function `notify_cdn_purge()`. +This function makes an HTTP POST request via `pg_net` to the Cloudflare/Vercel CDN Purge API, invalidating the specific endpoint url (e.g. `/api/majors`). + +### Relevant Files + +- `supabase/migrations/*_cache_invalidation.sql` +- `supabase/migrations/*_triggers.sql` +- `supabase/functions/shared/cache.ts` diff --git a/docs/event-state-machine.md b/docs/event-state-machine.md new file mode 100644 index 000000000..bcefea092 --- /dev/null +++ b/docs/event-state-machine.md @@ -0,0 +1,59 @@ +# Event State Machine Documentation + +The `EventWizard` component uses an XState finite state machine (`eventCreationMachine`) to manage its complex multi-step UI flow. + +## State Chart + +```mermaid +stateDiagram-v2 + [*] --> basics + basics --> ticketing : NEXT (isPaid = true) + basics --> location : NEXT (isPaid = false) + ticketing --> location : NEXT + ticketing --> basics : BACK + location --> review : NEXT + location --> ticketing : BACK (isPaid = true) + location --> basics : BACK (isPaid = false) + review --> submitting : SUBMIT + review --> location : BACK + submitting --> success : onDone + submitting --> error : onError + error --> submitting : RETRY + error --> review : BACK + success --> [*] +``` + +## Context Schema + +```ts +export interface EventContext { + formData: { + title: string; + description: string; + category: string; + isPaid: boolean; + price?: number; + currency?: string; + location?: string; + startDate: string; + endDate: string; + tags: string[]; + image?: string; + }; + validationErrors: Record; + currentStep: number; +} +``` + +## Guards + +- `isBasicsValid`: Verifies all required fields in the basics step are filled. +- `isTicketingValid`: Ensures price > 0 and currency is selected for paid events. +- `isLocationValid`: Ensures a location string exists. +- `isPaidEvent` / `isFreeEvent`: Checks `context.formData.isPaid`. +- `canSubmit`: Runs full validation across all fields before allowing submission. + +## Persistence + +The `useEventWizard` hook automatically saves the `snapshot.value` and `snapshot.context` to `sessionStorage` after every transition (except `success`). +Upon mount, it attempts to load from `sessionStorage` and restores context using the `RESTORE` event. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 000000000..962b7265c --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,92 @@ +# Outbound Webhooks + +Clubs can configure outbound webhooks to receive real-time HTTP POST notifications when events occur in their club on CampusConnect. + +## Supported Events + +Currently supported events: + +- `event.created` +- `event.updated` +- `event.deleted` +- `post.created` +- `club.updated` +- `member.joined` +- `member.left` + +## Payload Schema + +The webhook payload is a JSON object with the following structure: + +```json +{ + "event": "event.created", + "timestamp": "2026-07-30T11:30:00Z", + "club": { + "id": "uuid-of-club" + }, + "data": { + "id": "uuid-of-event", + "title": "Hackathon", + "location": "Auditorium", + "startsAt": "2026-08-15T09:00:00Z" + } +} +``` + +## Security & Signatures + +To verify that a webhook request was genuinely sent by CampusConnect, we include an HMAC-SHA256 signature in the `X-CampusConnect-Signature` header. + +The signature is generated using your Webhook Secret (available in the Club Settings UI). + +### Verifying Signatures in Node.js + +```javascript +const crypto = require("crypto"); + +function verifySignature(payloadString, secret, signatureHeader) { + const hash = crypto.createHmac("sha256", secret).update(payloadString).digest("hex"); + + const expectedSignature = `sha256=${hash}`; + + // Use crypto.timingSafeEqual to prevent timing attacks + return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expectedSignature)); +} +``` + +### Verifying Signatures in Python + +```python +import hmac +import hashlib + +def verify_signature(payload_string, secret, signature_header): + hash_obj = hmac.new( + secret.encode('utf-8'), + payload_string.encode('utf-8'), + hashlib.sha256 + ) + expected_signature = f"sha256={hash_obj.hexdigest()}" + return hmac.compare_digest(signature_header, expected_signature) +``` + +## Retry Policy + +If your server responds with a 5xx error or times out, CampusConnect will automatically retry the delivery with exponential backoff: + +- **Attempt 1:** Immediate +- **Attempt 2:** +1 minute +- **Attempt 3:** +5 minutes +- **Attempt 4:** +15 minutes +- **Attempt 5:** +1 hour + +After 5 failed attempts, the delivery is marked as permanently failed. Client errors (4xx responses) are generally not retried, except for `429 Too Many Requests`. + +## Server-Side Request Forgery (SSRF) Protection + +For security reasons, webhook URLs must be publicly accessible over HTTPS. We reject URLs pointing to: + +- `localhost` or `127.0.0.1` +- Private network ranges (e.g., `10.x.x.x`, `192.168.x.x`) +- AWS Metadata endpoints (`169.254.169.254`) diff --git a/package-lock.json b/package-lock.json index dc1ab1b95..931976a0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,11 @@ "@types/canvas-confetti": "^1.9.0", "@types/react-easy-crop": "^1.16.0", "@uiw/react-md-editor": "^4.1.1", +<<<<<<< HEAD + "@xstate/react": "^6.1.0", +======= "canvas-confetti": "^1.9.4", +>>>>>>> upstream/main "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -104,7 +108,11 @@ "workbox-precaching": "^7.4.1", "workbox-routing": "^7.4.1", "workbox-strategies": "^7.4.1", +<<<<<<< HEAD + "xstate": "^5.32.5", +======= "y-prosemirror": "^1.3.7", +>>>>>>> upstream/main "y-protocols": "^1.0.7", "yjs": "^13.6.31", "zod": "^3.24.2", @@ -7479,6 +7487,25 @@ "node": ">=18.0.0" } }, + "node_modules/@xstate/react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@xstate/react/-/react-6.1.0.tgz", + "integrity": "sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.2", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "xstate": "^5.28.0" + }, + "peerDependenciesMeta": { + "xstate": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -18040,6 +18067,20 @@ } } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", @@ -19007,6 +19048,16 @@ "dev": true, "license": "MIT" }, +<<<<<<< HEAD + "node_modules/xstate": { + "version": "5.32.5", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.32.5.tgz", + "integrity": "sha512-ULazi1oe6wGrXl0Frb6otSlkm5HLifbbVTkMk5kkSKqz4TkxJaVpnl6jOJwKeid3ORPxYyZQgNLUSYX9q65SIA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" +======= "node_modules/y-prosemirror": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", @@ -19029,6 +19080,7 @@ "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" +>>>>>>> upstream/main } }, "node_modules/y-protocols": { diff --git a/package.json b/package.json index faa65074e..ce1735eed 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@types/canvas-confetti": "^1.9.0", "@types/react-easy-crop": "^1.16.0", "@uiw/react-md-editor": "^4.1.1", + "@xstate/react": "^6.1.0", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -130,6 +131,7 @@ "workbox-precaching": "^7.4.1", "workbox-routing": "^7.4.1", "workbox-strategies": "^7.4.1", + "xstate": "^5.32.5", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.7", "yjs": "^13.6.31", diff --git a/src/App.tsx b/src/App.tsx index 5108e9cce..6a11241ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -99,8 +99,10 @@ const Leaderboard = lazy(() => import("./components/Leaderboard").then((m) => ({ default: m.Leaderboard })), ); -const LazyEventsIndex = lazy(() => import("./routes/events")); -const LazyEventDetails = lazy(() => import("./routes/events.$eventId")); +const EventsLayout = lazy(() => import("./pages/Events/EventsLayout")); +const LazyEventsIndex = lazy(() => import("./pages/Events/EventsList")); +const LazyEventDetails = lazy(() => import("./pages/Events/EventDetail")); +const EmptyState = lazy(() => import("./pages/Events/EmptyState")); function PageFallback() { return ( @@ -155,21 +157,29 @@ const router = createBrowserRouter( }> - + } - /> - - }> - - - } - /> - + > + }> + + + } + /> + }> + + + } + /> + } /> {/* Events Map View with clustering */} } /> diff --git a/src/components/DeliveryHistory.tsx b/src/components/DeliveryHistory.tsx new file mode 100644 index 000000000..be8a3c43b --- /dev/null +++ b/src/components/DeliveryHistory.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Typography, + Chip, + CircularProgress, + Box, + IconButton, + Tooltip, +} from "@mui/material"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { webhookService, WebhookDelivery, Webhook } from "../../services/webhookService"; + +interface DeliveryHistoryProps { + webhook: Webhook; +} + +export const DeliveryHistory: React.FC = ({ webhook }) => { + const [deliveries, setDeliveries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchHistory = async () => { + setLoading(true); + try { + const data = await webhookService.getDeliveries(webhook.id); + setDeliveries(data); + setError(""); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError(String(err)); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchHistory(); + }, [webhook.id]); + + if (loading) { + return ; + } + + if (error) { + return {error}; + } + + if (deliveries.length === 0) { + return No delivery history found for this webhook.; + } + + const getStatusColor = (status: string) => { + switch (status) { + case "success": + return "success"; + case "failed": + return "warning"; + case "permanent_failure": + return "error"; + default: + return "default"; + } + }; + + return ( + + + Delivery History + + + + + + + + + Date + Event + Status + Status Code + Attempt + Error + + + + {deliveries.map((delivery) => ( + + {new Date(delivery.created_at).toLocaleString()} + {delivery.event_name} + + + + {delivery.status_code || "-"} + {delivery.attempt} + + {delivery.last_error ? ( + + + {delivery.last_error} + + + ) : ( + "-" + )} + + + ))} + +
+
+
+ ); +}; diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index ee1fe8029..48fb7927d 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -45,6 +45,7 @@ interface EventCardProps { isRsvpPending: boolean; onBookmarkToggle: (eventId: string, isSaved: boolean) => void; isBookmarkPending: boolean; + active?: boolean; } // Assumed lead time (in days) used when an event has no `created_at` available @@ -163,6 +164,7 @@ export function EventCard({ isRsvpPending, onBookmarkToggle, isBookmarkPending, + active, }: EventCardProps) { const club = Array.isArray(event.clubs) ? event.clubs[0] : event.clubs; const rsvps = Array.isArray(event.event_rsvps) ? event.event_rsvps : []; @@ -254,7 +256,9 @@ export function EventCard({ id={`event-${event.id}`} onMouseEnter={preloadEvent.onMouseEnter} onMouseLeave={preloadEvent.onMouseLeave} - className={`neu-border p-5 relative ${colors[index % colors.length]} transition-transform duration-300 ease-out group-hover:scale-[1.02]`} + className={`neu-border p-5 relative ${ + active ? "bg-blue-100 border-4 border-blue-600 ring-2 ring-blue-600" : colors[index % colors.length] + } transition-all duration-300 ease-out group-hover:scale-[1.02]`} > {" "}
diff --git a/src/components/EventWizard/EventWizard.tsx b/src/components/EventWizard/EventWizard.tsx new file mode 100644 index 000000000..a29209292 --- /dev/null +++ b/src/components/EventWizard/EventWizard.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { useEventWizard } from "../../hooks/useEventWizard"; +import { WizardLayout } from "./WizardLayout"; +import { StepIndicator } from "./StepIndicator"; +import { Navigation } from "./Navigation"; + +import { BasicsStep } from "../steps/BasicsStep"; +import { TicketingStep } from "../steps/TicketingStep"; +import { LocationStep } from "../steps/LocationStep"; +import { ReviewStep } from "../steps/ReviewStep"; +import { SubmittingStep } from "../steps/SubmittingStep"; +import { SuccessStep } from "../steps/SuccessStep"; +import { ErrorStep } from "../steps/ErrorStep"; + +export function EventWizard() { + const wizard = useEventWizard(); + const { stateValue } = wizard; + + let currentStepComponent = null; + + switch (stateValue) { + case "basics": + currentStepComponent = ; + break; + case "ticketing": + currentStepComponent = ; + break; + case "location": + currentStepComponent = ; + break; + case "review": + currentStepComponent = ; + break; + case "submitting": + currentStepComponent = ; + break; + case "success": + currentStepComponent = ; + break; + case "error": + currentStepComponent = ; + break; + default: + currentStepComponent =
Unknown state
; + } + + return ( + + {stateValue !== "success" && stateValue !== "submitting" && stateValue !== "error" && ( + + )} + +
{currentStepComponent}
+ + {stateValue !== "success" && stateValue !== "submitting" && stateValue !== "error" && ( + + )} +
+ ); +} diff --git a/src/components/EventWizard/Navigation.tsx b/src/components/EventWizard/Navigation.tsx new file mode 100644 index 000000000..c46de0151 --- /dev/null +++ b/src/components/EventWizard/Navigation.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { useEventWizard } from "../../hooks/useEventWizard"; + +export function Navigation({ wizard }: { wizard: ReturnType }) { + const { stateValue, canNext, canBack, next, back, submit, canSubmit } = wizard; + + return ( +
+ + + {stateValue === "review" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/components/EventWizard/StepIndicator.tsx b/src/components/EventWizard/StepIndicator.tsx new file mode 100644 index 000000000..bd43857f9 --- /dev/null +++ b/src/components/EventWizard/StepIndicator.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +const STEPS = ["basics", "ticketing", "location", "review"]; + +export function StepIndicator({ stateValue }: { stateValue: string }) { + const currentIndex = STEPS.indexOf(stateValue); + + return ( +
+ {STEPS.map((step, index) => { + const isCompleted = index < currentIndex; + const isCurrent = index === currentIndex; + return ( +
+
+ {index + 1} +
+ + {step} + + {index < STEPS.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/components/EventWizard/WizardLayout.tsx b/src/components/EventWizard/WizardLayout.tsx new file mode 100644 index 000000000..5c55b30f7 --- /dev/null +++ b/src/components/EventWizard/WizardLayout.tsx @@ -0,0 +1,10 @@ +import React from "react"; + +export function WizardLayout({ children }: { children: React.ReactNode }) { + return ( +
+

Create New Event

+ {children} +
+ ); +} diff --git a/src/components/MultiSelect/EmptyState.tsx b/src/components/MultiSelect/EmptyState.tsx new file mode 100644 index 000000000..39a17d657 --- /dev/null +++ b/src/components/MultiSelect/EmptyState.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { Command } from "cmdk"; +import { useMultiSelectContext } from "./hooks"; + +export function EmptyState() { + const { emptyText } = useMultiSelectContext(); + + return ( + + {emptyText} + + ); +} diff --git a/src/components/MultiSelect/LoadingState.tsx b/src/components/MultiSelect/LoadingState.tsx new file mode 100644 index 000000000..02328537e --- /dev/null +++ b/src/components/MultiSelect/LoadingState.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { Command } from "cmdk"; + +export function LoadingState() { + return ( + + Loading... + + ); +} diff --git a/src/components/MultiSelect/MultiSelect.tsx b/src/components/MultiSelect/MultiSelect.tsx new file mode 100644 index 000000000..e146a1381 --- /dev/null +++ b/src/components/MultiSelect/MultiSelect.tsx @@ -0,0 +1,61 @@ +import React, { useState, useMemo } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { MultiSelectProps, Tag } from "./types"; +import { MultiSelectContext } from "./hooks"; +import { MultiSelectTrigger } from "./MultiSelectTrigger"; +import { MultiSelectPopover } from "./MultiSelectPopover"; + +export function MultiSelect({ + options, + value, + onChange, + placeholder = "Select options...", + emptyText = "No results found.", + disabled = false, +}: MultiSelectProps) { + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + + const selected = value || []; + + // Options that are not currently selected + const availableOptions = useMemo(() => { + return options.filter((option) => !selected.some((s) => s.value === option.value)); + }, [options, selected]); + + const addTag = (tag: Tag) => { + if (!selected.some((s) => s.value === tag.value)) { + onChange([...selected, tag]); + } + }; + + const removeTag = (tag: Tag) => { + onChange(selected.filter((s) => s.value !== tag.value)); + }; + + const contextValue = { + options, + selected, + availableOptions, + addTag, + removeTag, + open, + setOpen, + inputValue, + setInputValue, + disabled, + placeholder, + emptyText, + }; + + return ( + + +
+ + +
+
+
+ ); +} diff --git a/src/components/MultiSelect/MultiSelectItem.tsx b/src/components/MultiSelect/MultiSelectItem.tsx new file mode 100644 index 000000000..7ff35df34 --- /dev/null +++ b/src/components/MultiSelect/MultiSelectItem.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Command } from "cmdk"; +import { Tag } from "./types"; +import { cn } from "../../lib/utils"; +import { useMultiSelectContext } from "./hooks"; + +interface MultiSelectItemProps { + tag: Tag; +} + +export function MultiSelectItem({ tag }: MultiSelectItemProps) { + const { addTag, setInputValue } = useMultiSelectContext(); + + return ( + { + addTag(tag); + setInputValue(""); + }} + className={cn( + "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none", + "aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground", + )} + > + {tag.label} + + ); +} diff --git a/src/components/MultiSelect/MultiSelectList.tsx b/src/components/MultiSelect/MultiSelectList.tsx new file mode 100644 index 000000000..33d405aa3 --- /dev/null +++ b/src/components/MultiSelect/MultiSelectList.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { Command } from "cmdk"; +import { useMultiSelectContext } from "./hooks"; +import { MultiSelectItem } from "./MultiSelectItem"; +import { EmptyState } from "./EmptyState"; +import { LoadingState } from "./LoadingState"; + +export function MultiSelectList() { + const { availableOptions } = useMultiSelectContext(); + + return ( + + + {/* If we needed async loading, we would conditionally render LoadingState here */} + + {availableOptions.map((tag) => ( + + ))} + + + ); +} diff --git a/src/components/MultiSelect/MultiSelectPopover.tsx b/src/components/MultiSelect/MultiSelectPopover.tsx new file mode 100644 index 000000000..0619a4e68 --- /dev/null +++ b/src/components/MultiSelect/MultiSelectPopover.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { Command } from "cmdk"; +import { useMultiSelectContext } from "./hooks"; +import { MultiSelectList } from "./MultiSelectList"; +import { cn } from "../../lib/utils"; + +export function MultiSelectPopover() { + const { inputValue, setInputValue } = useMultiSelectContext(); + + return ( + + e.preventDefault()} + > + +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/MultiSelect/MultiSelectTrigger.tsx b/src/components/MultiSelect/MultiSelectTrigger.tsx new file mode 100644 index 000000000..fe996f98f --- /dev/null +++ b/src/components/MultiSelect/MultiSelectTrigger.tsx @@ -0,0 +1,55 @@ +import React, { useRef } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { useMultiSelectContext } from "./hooks"; +import { SelectedPill } from "./SelectedPill"; +import { cn } from "../../lib/utils"; + +export const MultiSelectTrigger = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { selected, removeTag, open, setOpen, disabled, placeholder } = useMultiSelectContext(); + + return ( + +
{ + if (!disabled) setOpen(!open); + }} + onKeyDown={(e) => { + if (disabled) return; + if (e.key === "Enter" || e.key === " ") { + setOpen(!open); + e.preventDefault(); + } + if (e.key === "Delete" || e.key === "Backspace") { + if (selected.length > 0) { + removeTag(selected[selected.length - 1]); + } + } + }} + tabIndex={disabled ? -1 : 0} + {...props} + > +
+ {selected.length === 0 && {placeholder}} + {selected.map((tag) => ( + + ))} +
+
+
+ ); +}); + +MultiSelectTrigger.displayName = "MultiSelectTrigger"; diff --git a/src/components/MultiSelect/SelectedPill.tsx b/src/components/MultiSelect/SelectedPill.tsx new file mode 100644 index 000000000..e5e4e454b --- /dev/null +++ b/src/components/MultiSelect/SelectedPill.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { X } from "lucide-react"; +import { Tag } from "./types"; +import { cn } from "../../lib/utils"; + +interface SelectedPillProps { + tag: Tag; + onRemove: (tag: Tag) => void; + disabled?: boolean; +} + +export function SelectedPill({ tag, onRemove, disabled }: SelectedPillProps) { + return ( + + {tag.label} + + + ); +} diff --git a/src/components/MultiSelect/hooks.ts b/src/components/MultiSelect/hooks.ts new file mode 100644 index 000000000..ec266ebe2 --- /dev/null +++ b/src/components/MultiSelect/hooks.ts @@ -0,0 +1,12 @@ +import { useContext, createContext } from "react"; +import { MultiSelectContextValue } from "./types"; + +export const MultiSelectContext = createContext(undefined); + +export function useMultiSelectContext() { + const context = useContext(MultiSelectContext); + if (!context) { + throw new Error("useMultiSelectContext must be used within a MultiSelectProvider"); + } + return context; +} diff --git a/src/components/MultiSelect/index.ts b/src/components/MultiSelect/index.ts new file mode 100644 index 000000000..fae6e092e --- /dev/null +++ b/src/components/MultiSelect/index.ts @@ -0,0 +1,3 @@ +export { MultiSelect } from "./MultiSelect"; +export type { MultiSelectProps, Tag } from "./types"; +export { useMultiSelect } from "../../hooks/useMultiSelect"; diff --git a/src/components/MultiSelect/types.ts b/src/components/MultiSelect/types.ts new file mode 100644 index 000000000..09406a2ba --- /dev/null +++ b/src/components/MultiSelect/types.ts @@ -0,0 +1,28 @@ +export interface Tag { + value: string; + label: string; +} + +export interface MultiSelectProps { + options: Tag[]; + value: Tag[]; + onChange: (tags: Tag[]) => void; + placeholder?: string; + emptyText?: string; + disabled?: boolean; +} + +export interface MultiSelectContextValue { + options: Tag[]; + selected: Tag[]; + availableOptions: Tag[]; + addTag: (tag: Tag) => void; + removeTag: (tag: Tag) => void; + open: boolean; + setOpen: (open: boolean) => void; + inputValue: string; + setInputValue: (value: string) => void; + disabled: boolean; + placeholder: string; + emptyText: string; +} diff --git a/src/components/WebhookForm.tsx b/src/components/WebhookForm.tsx new file mode 100644 index 000000000..5aaf47277 --- /dev/null +++ b/src/components/WebhookForm.tsx @@ -0,0 +1,118 @@ +import React, { useState } from "react"; +import { + Button, + Checkbox, + FormControlLabel, + FormGroup, + TextField, + Typography, + Box, +} from "@mui/material"; +import { Webhook } from "../../services/webhookService"; + +interface WebhookFormData { + url: string; + secret: string; + events_subscribed: string[]; + is_active: boolean; +} + +interface WebhookFormProps { + initialData?: Webhook; + onSubmit: (data: WebhookFormData) => void; + onCancel: () => void; +} + +const AVAILABLE_EVENTS = [ + "event.created", + "event.updated", + "event.deleted", + "post.created", + "club.updated", + "member.joined", + "member.left", +]; + +export const WebhookForm: React.FC = ({ initialData, onSubmit, onCancel }) => { + const [url, setUrl] = useState(initialData?.url || ""); + const [secret, setSecret] = useState(initialData?.secret || crypto.randomUUID()); + const [events, setEvents] = useState( + initialData?.events_subscribed || ["event.created"], + ); + const [isActive, setIsActive] = useState(initialData?.is_active ?? true); + + const handleEventToggle = (event: string) => { + setEvents((prev) => + prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event], + ); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ url, secret, events_subscribed: events, is_active: isActive }); + }; + + return ( + + {initialData ? "Edit Webhook" : "Add Webhook"} + + setUrl(e.target.value)} + placeholder="https://your-server.com/webhook" + type="url" + fullWidth + /> + + + + + + Events to subscribe to: + + + {AVAILABLE_EVENTS.map((evt) => ( + handleEventToggle(evt)} /> + } + label={evt} + /> + ))} + + + setIsActive(e.target.checked)} />} + label="Active" + /> + + + + + + + ); +}; diff --git a/src/components/WebhookList.tsx b/src/components/WebhookList.tsx new file mode 100644 index 000000000..6d79b5c97 --- /dev/null +++ b/src/components/WebhookList.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Card, CardContent, Typography, Switch, IconButton, Box, Chip } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import HistoryIcon from "@mui/icons-material/History"; +import { Webhook } from "../../services/webhookService"; + +interface WebhookListProps { + webhooks: Webhook[]; + onToggleActive: (id: string, active: boolean) => void; + onEdit: (webhook: Webhook) => void; + onDelete: (id: string) => void; + onViewHistory: (webhook: Webhook) => void; +} + +export const WebhookList: React.FC = ({ + webhooks, + onToggleActive, + onEdit, + onDelete, + onViewHistory, +}) => { + if (webhooks.length === 0) { + return No webhooks configured yet.; + } + + return ( + + {webhooks.map((webhook) => ( + + + + {webhook.url} + + {webhook.events_subscribed.map((evt) => ( + + ))} + + + + onToggleActive(webhook.id, e.target.checked)} + color="primary" + /> + onViewHistory(webhook)} title="View Delivery History"> + + + onEdit(webhook)} title="Edit Webhook"> + + + onDelete(webhook.id)} color="error" title="Delete Webhook"> + + + + + + ))} + + ); +}; diff --git a/src/components/steps/BasicsStep.tsx b/src/components/steps/BasicsStep.tsx new file mode 100644 index 000000000..a808d31b4 --- /dev/null +++ b/src/components/steps/BasicsStep.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import { useEventWizard } from "../../hooks/useEventWizard"; + +export function BasicsStep({ wizard }: { wizard: ReturnType }) { + const { context, updateForm } = wizard; + const { title, description, category, isPaid, startDate, endDate } = context.formData; + const { validationErrors } = context; + + return ( +
+
+ + updateForm({ title: e.target.value })} + className={`w-full p-2 rounded-md border ${validationErrors.title ? "border-red-500" : "border-input"} bg-background`} + /> + {validationErrors.title && ( +

+ {validationErrors.title} +

+ )} +
+ +
+ +