-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add registration closed functionality to events #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
79f88a3
78cdd1c
ccad7d9
c90f7b6
97c5f6c
79d26c6
3978709
71e797f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| -- Add registration_closed boolean to events table | ||
| -- When TRUE, the event's registration is closed (no new bookings allowed) | ||
|
|
||
| ALTER TABLE events | ||
| ADD COLUMN IF NOT EXISTS registration_closed BOOLEAN NOT NULL DEFAULT FALSE; | ||
|
|
||
| -- Index for quick filtering | ||
| CREATE INDEX IF NOT EXISTS idx_events_registration_closed ON events(registration_closed); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -18,7 +18,9 @@ function loadCart() { | |||||
| function persistCart(items) { | ||||||
| if (typeof window === 'undefined') return | ||||||
| try { | ||||||
| localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) | ||||||
| // Strip runtime-only flags before persisting — they are always refreshed from DB on validateCart | ||||||
| const stripped = items.map(({ registrationClosed, ...rest }) => rest) | ||||||
| localStorage.setItem(STORAGE_KEY, JSON.stringify(stripped)) | ||||||
| } catch { | ||||||
| /* quota exceeded — silently fail */ | ||||||
| } | ||||||
|
|
@@ -80,10 +82,10 @@ const cartSlice = createSlice({ | |||||
| * Sanitizes loaded data to discard any corrupted items. */ | ||||||
| hydrateCart(state) { | ||||||
| const raw = loadCart() | ||||||
| // Sanitize: discard items with missing eventId or quantity < 1 | ||||||
| state.items = raw.filter( | ||||||
| item => item && item.eventId && typeof item.quantity === 'number' && item.quantity >= 1 | ||||||
| ) | ||||||
| // Sanitize: discard items with missing eventId or quantity < 1; strip stale runtime flags | ||||||
| state.items = raw | ||||||
| .filter(item => item && item.eventId && typeof item.quantity === 'number' && item.quantity >= 1) | ||||||
| .map(({ registrationClosed, ...rest }) => rest) | ||||||
| state.hydrated = true | ||||||
| }, | ||||||
|
|
||||||
|
|
@@ -161,16 +163,21 @@ const cartSlice = createSlice({ | |||||
| const dbEvents = action.payload | ||||||
| const prevCount = state.items.length | ||||||
|
|
||||||
| // Update cart items with fresh DB prices and remove stale items | ||||||
| // Update cart items with fresh DB prices and mark unavailable items | ||||||
| state.items = state.items | ||||||
| .map(item => { | ||||||
| const dbEvent = dbEvents.find(e => e.id === item.eventId) | ||||||
| if (!dbEvent) return null // event no longer exists/published | ||||||
| if (!dbEvent) { | ||||||
| // Event no longer exists/published — mark as unavailable (maxAvailable = 0) instead of removing | ||||||
| return { ...item, maxAvailable: 0, registrationClosed: false } | ||||||
| } | ||||||
|
|
||||||
| const dbAvailable = Math.max(0, (dbEvent.seats || 0) - (dbEvent.registered || 0)) | ||||||
| // Registration closed — mark it but keep in cart so user sees reason | ||||||
| if (dbEvent.registration_closed) { | ||||||
| return { ...item, title: dbEvent.title, registrationClosed: true, maxAvailable: null } | ||||||
| } | ||||||
|
|
||||||
| // Sold out — remove from cart entirely | ||||||
| if (dbAvailable <= 0) return null | ||||||
| const dbAvailable = Math.max(0, (dbEvent.seats || 0) - (dbEvent.registered || 0)) | ||||||
|
|
||||||
| return { | ||||||
| ...item, | ||||||
|
|
@@ -179,9 +186,9 @@ const cartSlice = createSlice({ | |||||
| startTime: dbEvent.start_time, | ||||||
| venue: dbEvent.venue || item.venue, | ||||||
| image: dbEvent.images?.[0] || item.image, | ||||||
| maxAvailable: dbAvailable, | ||||||
| // Cap stored quantity to actual availability — no fallback to 1 | ||||||
| quantity: Math.min(item.quantity, dbAvailable) | ||||||
| maxAvailable: dbAvailable || null, | ||||||
|
||||||
| maxAvailable: dbAvailable || null, | |
| maxAvailable: dbAvailable, |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -128,6 +128,7 @@ function CartItem({ item, accent }) { | |
| const dispatch = useDispatch() | ||
| const imageUrl = getItemImage(item) | ||
| const subtotal = item.ticketPrice * item.quantity | ||
| const isUnavailable = !!item.registrationClosed | ||
|
|
||
| return ( | ||
| <Box | ||
|
|
@@ -136,7 +137,8 @@ function CartItem({ item, accent }) { | |
| gap: { xs: 2, md: 3 }, | ||
| py: { xs: 2.5, md: 3 }, | ||
| flexDirection: { xs: 'column', sm: 'row' }, | ||
| alignItems: { xs: 'stretch', sm: 'flex-start' } | ||
| alignItems: { xs: 'stretch', sm: 'flex-start' }, | ||
| opacity: isUnavailable ? 0.7 : 1 | ||
| }} | ||
| > | ||
| {/* Image */} | ||
|
|
@@ -169,18 +171,40 @@ function CartItem({ item, accent }) { | |
| {/* Details */} | ||
| <Box sx={{ flex: 1, minWidth: 0 }}> | ||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}> | ||
| <Typography | ||
| variant='h6' | ||
| sx={{ | ||
| fontWeight: 700, | ||
| fontSize: { xs: '1rem', md: '1.05rem' }, | ||
| lineHeight: 1.3, | ||
| color: c.textPrimary, | ||
| mb: 0.5 | ||
| }} | ||
| > | ||
| {item.title} | ||
| </Typography> | ||
| <Box sx={{ flex: 1, minWidth: 0 }}> | ||
| <Typography | ||
| variant='h6' | ||
| sx={{ | ||
| fontWeight: 700, | ||
| fontSize: { xs: '1rem', md: '1.05rem' }, | ||
| lineHeight: 1.3, | ||
| color: c.textPrimary, | ||
| mb: 0.5 | ||
| }} | ||
| > | ||
| {item.title} | ||
| </Typography> | ||
| {isUnavailable && ( | ||
| <Typography | ||
| variant='caption' | ||
| sx={{ | ||
| display: 'inline-block', | ||
| bgcolor: alpha(c.error || '#f44336', 0.1), | ||
| color: c.error || '#f44336', | ||
| fontWeight: 700, | ||
| px: 1, | ||
| py: 0.2, | ||
| borderRadius: '4px', | ||
| fontSize: '0.72rem', | ||
| letterSpacing: 0.5, | ||
| textTransform: 'uppercase', | ||
| mb: 0.5 | ||
| }} | ||
| > | ||
| Registration Closed | ||
| </Typography> | ||
| )} | ||
| </Box> | ||
|
|
||
| {/* Remove button */} | ||
| <IconButton | ||
|
|
@@ -232,13 +256,15 @@ function CartItem({ item, accent }) { | |
| <Typography variant='body2' sx={{ color: c.textSecondary, fontWeight: 500, fontSize: '0.85rem' }}> | ||
| {formatCurrency(item.ticketPrice)} each | ||
| </Typography> | ||
| <QuantityControl | ||
| quantity={item.quantity} | ||
| max={item.maxAvailable} | ||
| onDecrease={() => dispatch(updateQuantity({ eventId: item.eventId, quantity: item.quantity - 1 }))} | ||
| onIncrease={() => dispatch(updateQuantity({ eventId: item.eventId, quantity: item.quantity + 1 }))} | ||
| accent={accent} | ||
| /> | ||
| {!isUnavailable && ( | ||
| <QuantityControl | ||
| quantity={item.quantity} | ||
| max={item.maxAvailable} | ||
| onDecrease={() => dispatch(updateQuantity({ eventId: item.eventId, quantity: item.quantity - 1 }))} | ||
| onIncrease={() => dispatch(updateQuantity({ eventId: item.eventId, quantity: item.quantity + 1 }))} | ||
| accent={accent} | ||
| /> | ||
| )} | ||
| {/* availability label removed per UX request */} | ||
| </Box> | ||
| <Typography | ||
|
|
@@ -316,6 +342,7 @@ export default function CartView() { | |
| const itemCount = useSelector(selectCartItemCount) | ||
| const subtotal = useSelector(selectCartSubtotal) | ||
| const { validating, hydrated, validationRemovedCount } = useSelector(state => state.cart) | ||
| const hasUnavailableItems = items.some(item => item.registrationClosed) | ||
|
|
||
| // Validate on every cart page visit. | ||
| // - Initial load: fires when hydrated flips false→true (after CartHydrator dispatches hydrateCart). | ||
|
|
@@ -332,7 +359,7 @@ export default function CartView() { | |
| useEffect(() => { | ||
| if (validationRemovedCount > 0) { | ||
| toast.error( | ||
| `${validationRemovedCount} sold-out event${validationRemovedCount > 1 ? 's were' : ' was'} removed from your cart.`, | ||
| `${validationRemovedCount} event${validationRemovedCount > 1 ? 's were' : ' was'} removed from your cart (no longer available).`, | ||
| { duration: 5000, id: 'cart-removal' } | ||
| ) | ||
| } | ||
|
|
@@ -514,11 +541,26 @@ export default function CartView() { | |
| </Box> | ||
|
|
||
| {/* Checkout CTA */} | ||
| {hasUnavailableItems && ( | ||
| <Typography | ||
| variant='caption' | ||
| sx={{ | ||
| display: 'block', | ||
| mt: 2, | ||
| color: c.error || '#f44336', | ||
| fontWeight: 600, | ||
| textAlign: 'center', | ||
| fontSize: '0.8rem' | ||
| }} | ||
| > | ||
| Remove unavailable events from your cart to proceed. | ||
| </Typography> | ||
| )} | ||
| <Button | ||
| variant='contained' | ||
| size='large' | ||
| fullWidth | ||
| disabled={validating} | ||
| disabled={validating || hasUnavailableItems} | ||
| endIcon={<Icon icon='tabler:arrow-right' />} | ||
|
Comment on lines
559
to
564
|
||
| onClick={async () => { | ||
| // Re-validate prices and availability immediately before checkout | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This migration adds
events.registration_closed, but the repo keeps full schema snapshots in bothschema.sql(root) andsrc/services/database/schema.sql. Those schema files currently don’t includeregistration_closed, so fresh DB bootstraps from schema.sql will diverge from migrated DBs. Please update both schema files to include the new column.