diff --git a/backend/clubs/migrations/0120_ticket_code_discount_ticket_discount_code_applied.py b/backend/clubs/migrations/0120_ticket_code_discount_ticket_discount_code_applied.py new file mode 100644 index 000000000..a3ab5f0dd --- /dev/null +++ b/backend/clubs/migrations/0120_ticket_code_discount_ticket_discount_code_applied.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1 on 2025-03-23 22:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0119_registrationqueuesettings"), + ] + + operations = [ + migrations.AddField( + model_name="ticket", + name="code_discount", + field=models.DecimalField(blank=True, decimal_places=2, + default=0, max_digits=3), + ), + migrations.AddField( + model_name="ticket", + name="discount_code_applied", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 814209b5c..04ac0fe5f 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1,4 +1,5 @@ import datetime +import hashlib import os import re import uuid @@ -1896,6 +1897,14 @@ class TicketTransactionRecord(models.Model): buyer_email = models.EmailField(blank=True, null=True) +def get_discount_code(event, ticket): + # get private key as hash of event id, ticket class, and secret + private_key = hashlib.sha256( + f"{event.id}-{ticket.type}-{settings.SECRET_KEY}".encode() + ).hexdigest() + return private_key[:8].upper() + + class Ticket(models.Model): """ Represents an instance of a ticket for an event @@ -1930,6 +1939,13 @@ class Ticket(models.Model): blank=True, ) group_size = models.PositiveIntegerField(null=True, blank=True) + code_discount = models.DecimalField( + max_digits=3, + decimal_places=2, + default=0, + blank=True, + ) + discount_code_applied = models.BooleanField(default=False) transferable = models.BooleanField(default=True) attended = models.BooleanField(default=False) # TODO: change to enum between All, Club, None @@ -1993,6 +2009,10 @@ def send_confirmation_email(self): }, ) + @property + def discount_code(self): + return get_discount_code(self.event, self) + class TicketTransferRecord(models.Model): """ diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index fb5f2ebb2..867217078 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -1804,13 +1804,42 @@ class TicketSerializer(serializers.ModelSerializer): owner = serializers.SerializerMethodField("get_owner_name") event = EventSerializer() + discount_code = serializers.SerializerMethodField() def get_owner_name(self, obj): return obj.owner.get_full_name() if obj.owner else "None" + def get_discount_code(self, obj): + # only event officers should be able to see discount codes + if ( + "request" in self.context + and self.context["request"].user.is_authenticated + and ( + membership := Membership.objects.filter( + person=self.context["request"].user, club=obj.event.club + ).first() + ) + ): + if membership.role <= Membership.ROLE_OFFICER: + return obj.discount_code + return None + class Meta: model = Ticket - fields = ("id", "event", "type", "owner", "attended", "price") + fields = ( + "id", + "event", + "type", + "owner", + "attended", + "price", + "buyable", + "code_discount", + "group_discount", + "group_size", + "discount_code", + "discount_code_applied", + ) class UserUUIDSerializer(serializers.ModelSerializer): diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 2d91a5889..2913eae1d 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -46,7 +46,6 @@ DurationField, ExpressionWrapper, F, - Max, Prefetch, Q, TextField, @@ -2539,6 +2538,9 @@ def add_to_cart(self, request, *args, **kwargs): type: string count: type: integer + discount_code: + type: string + required: false responses: "200": content: @@ -2620,6 +2622,7 @@ def add_to_cart(self, request, *args, **kwargs): for item in quantities: type = item["type"] count = item["count"] + discount_code = item.get("discount_code", None) # Count unowned/unheld tickets of requested type # We don't need a lock since we aren't changing the holder or owner @@ -2643,6 +2646,18 @@ def add_to_cart(self, request, *args, **kwargs): }, status=status.HTTP_403_FORBIDDEN, ) + + if discount_code: + valid_discount_code = tickets.first().discount_code + if discount_code != valid_discount_code: + return Response( + { + "detail": f"Invalid discount code for {type}", + "success": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + tickets.update(discount_code_applied=True) cart.tickets.add(*tickets[:count]) cart.save() @@ -2783,6 +2798,13 @@ def tickets(self, request, *args, **kwargs): type: integer price: type: number + code_discount: + type: number + group_discount: + type: number + group_size: + type: number + required: false available: type: array items: @@ -2794,26 +2816,60 @@ def tickets(self, request, *args, **kwargs): type: integer price: type: number + code_discount: + type: number + group_discount: + type: number + group_size: + type: number --- """ event = self.get_object() tickets = Ticket.objects.filter(event=event) + # if for purchasing purposes, non-buyable tickets are unavailable + # otherwise, all non-owned/held tickets are available + for_purchasing = self.request.query_params.get("for_purchasing", "true") # Take price of first ticket of given type for now - totals = ( - tickets.values("type") - .annotate(price=Max("price")) - .annotate(count=Count("type")) - .order_by("type") - ) - available = ( - tickets.filter(owner__isnull=True, holder__isnull=True, buyable=True) - .values("type") - .annotate(price=Max("price")) - .annotate(count=Count("type")) - .order_by("type") + total_counts = { + t["type"]: t["count"] + for t in tickets.values("type").annotate(count=Count("type")) + } + + available_tickets = tickets.filter( + owner__isnull=True, + holder__isnull=True, ) - return Response({"totals": list(totals), "available": list(available)}) + if for_purchasing == "true": + available_tickets = available_tickets.filter(buyable=True) + available_counts = { + t["type"]: t["count"] + for t in available_tickets.values("type").annotate(count=Count("type")) + } + + # Get one representative ticket for each type and serialize it + totals_list = [] + available_list = [] + for ticket_type in tickets.values_list("type", flat=True).distinct(): + ticket = tickets.filter(type=ticket_type).first() + if ticket: + # Serialize the ticket and add counts + ticket_data = TicketSerializer( + ticket, context={"request": request} + ).data + + # Add to totals + totals_entry = ticket_data.copy() + totals_entry["count"] = total_counts.get(ticket_type, 0) + totals_list.append(totals_entry) + + # Add to available if there are any available tickets + if ticket_type in available_counts: + available_entry = ticket_data.copy() + available_entry["count"] = available_counts.get(ticket_type, 0) + available_list.append(available_entry) + + return Response({"totals": totals_list, "available": available_list}) @tickets.mapping.put @transaction.atomic @@ -2845,6 +2901,10 @@ def create_tickets(self, request, *args, **kwargs): type: number format: float required: false + code_discount: + type: number + format: float + required: false transferable: type: boolean buyable: @@ -2929,14 +2989,25 @@ def create_tickets(self, request, *args, **kwargs): ) # Group discounts must be between 0 and 1 - if item.get("group_discount", 0) < 0 or item.get("group_discount", 0) > 1: + if item.get("group_discount", 0) is not None and ( + item.get("group_discount", 0) < 0 or item.get("group_discount", 0) > 1 + ): return Response( {"detail": "Group discount must be between 0 and 1"}, status=status.HTTP_400_BAD_REQUEST, ) + # Code discounts must be between 0 and 1 + if item.get("code_discount", 0) is not None and ( + item.get("code_discount", 0) < 0 or item.get("code_discount", 0) > 1 + ): + return Response( + {"detail": "Code discount must be between 0 and 1"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Min group sizes must be greater than 1 - if item.get("group_size", 2) <= 1: + if item.get("group_size", 2) is not None and item.get("group_size", 2) <= 1: return Response( {"detail": "Min group size must be greater than 1"}, status=status.HTTP_400_BAD_REQUEST, @@ -2961,8 +3032,9 @@ def create_tickets(self, request, *args, **kwargs): event=event, type=item["type"], price=item.get("price", 0), - group_discount=item.get("group_discount", 0), + group_discount=item.get("group_discount", 0) or 0, # Field not nullable group_size=item.get("group_size", None), + code_discount=item.get("code_discount", 0) or 0, transferable=item.get("transferable", True), buyable=item.get("buyable", True), ) @@ -5548,6 +5620,14 @@ def _calculate_cart_total(cart) -> float: and ticket_type_counts[ticket.type] >= ticket.group_size else ticket.price ) + * ( + 1 + - ( + ticket.code_discount + if ticket.code_discount and ticket.discount_code_applied + else 0 + ) + ) for ticket in cart.tickets.all() ) return cart_total diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index de8acb531..02a06ccf1 100644 --- a/backend/tests/clubs/test_ticketing.py +++ b/backend/tests/clubs/test_ticketing.py @@ -22,6 +22,7 @@ Ticket, TicketTransactionRecord, TicketTransferRecord, + get_discount_code, ) @@ -81,9 +82,12 @@ def commonSetUp(self): ] self.tickets1 = [ - Ticket.objects.create(type="normal", event=self.event1, price=15.0) + Ticket.objects.create( + type="normal", event=self.event1, price=15.0, code_discount=0.5 + ) for _ in range(20) ] + self.tickets1_discount_code = get_discount_code(self.event1, self.tickets1[0]) self.tickets2 = [ Ticket.objects.create(type="premium", event=self.event1, price=30.0) for _ in range(10) @@ -134,7 +138,7 @@ def test_create_ticket_offerings(self): qts = { "quantities": [ - {"type": "_normal", "count": 20, "price": 10}, + {"type": "_normal", "count": 20, "price": 10, "code_discount": 0.5}, {"type": "_premium", "count": 10, "price": 20}, ] } @@ -147,13 +151,14 @@ def test_create_ticket_offerings(self): aggregated_tickets = list( Ticket.objects.filter(event=self.event1, type__contains="_") - .values("type", "price") + .values("type", "price", "code_discount") .annotate(count=Count("id")) ) for t1, t2 in zip(qts["quantities"], aggregated_tickets): self.assertEqual(t1["type"], t2["type"]) self.assertAlmostEqual(t1["price"], float(t2["price"]), 0.02) self.assertEqual(t1["count"], t2["count"]) + self.assertEqual(t1.get("code_discount", 0), t2["code_discount"]) self.assertIn(resp.status_code, [200, 201], resp.content) @@ -517,12 +522,27 @@ def test_get_tickets_information(self): ) self.assertIn(resp.status_code, [200, 201], resp.content) data = resp.json() - self.assertEqual(data["totals"], self.ticket_totals, data["totals"]) self.assertEqual( - data["available"], - # Only premium tickets available + [ + { + "type": t["type"], + "price": float(t["price"]), + "count": t["count"], + } + for t in data["totals"] + ], + self.ticket_totals, + ) + self.assertEqual( + [ + { + "type": t["type"], + "price": float(t["price"]), + "count": t["count"], + } + for t in data["available"] + ], [t for t in self.ticket_totals if t["type"] == "premium"], - data["available"], ) def test_get_tickets_buyers(self): @@ -576,6 +596,30 @@ def test_add_to_cart(self): self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets) self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets) + def test_add_to_cart_code_discount(self): + self.client.login(username=self.user1.username, password="test") + + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2, "discount_code": "TESTCODE"}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 400, resp.content) + self.assertIn("Invalid discount code", resp.data["detail"], resp.data) + tickets_to_add["quantities"][0]["discount_code"] = self.tickets1_discount_code + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.data["success"], True) + def test_add_to_cart_elapsed_event(self): self.client.login(username=self.user1.username, password="test") @@ -1022,6 +1066,35 @@ def test_calculate_cart_total_with_group_discount(self): total = TicketViewSet._calculate_cart_total(cart) self.assertEqual(total, 40.0) # 5 * price=10 * (1 - group_discount=0.2) = 40 + def test_calculate_cart_total_with_code_discount(self): + self.client.login(username=self.user1.username, password="test") + + tickets_to_add = { + "quantities": [ + { + "type": "normal", + "count": 3, + "discount_code": self.tickets1_discount_code, + }, + ] + } + + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 3) + + from clubs.views import TicketViewSet + + total = TicketViewSet._calculate_cart_total(cart) + + self.assertEqual(total, 22.5) # 3 * 15 * 0.5 = 22.5 + def test_get_cart_replacement_required(self): self.client.login(username=self.user1.username, password="test") diff --git a/frontend/components/ClubEditPage/EventsCard.tsx b/frontend/components/ClubEditPage/EventsCard.tsx index ce9935fd0..7280e0b92 100644 --- a/frontend/components/ClubEditPage/EventsCard.tsx +++ b/frontend/components/ClubEditPage/EventsCard.tsx @@ -513,6 +513,7 @@ export default function EventsCard({ onChange={(obj) => { setDeviceContents(obj) }} + scrollable /> diff --git a/frontend/components/ClubEditPage/TicketsModal.tsx b/frontend/components/ClubEditPage/TicketsModal.tsx index d29996804..108265d6a 100644 --- a/frontend/components/ClubEditPage/TicketsModal.tsx +++ b/frontend/components/ClubEditPage/TicketsModal.tsx @@ -71,6 +71,7 @@ const TicketItem: React.FC = ({ }) => { const [ticket, setTicket] = useState(propTicket) const [openGroupDiscount, setOpenGroupDiscount] = useState(false) + const [openCodeDiscount, setOpenCodeDiscount] = useState(false) const resetGroupDiscount = () => { setTicket({ @@ -86,6 +87,18 @@ const TicketItem: React.FC = ({ setOpenGroupDiscount(!openGroupDiscount) } + const resetCodeDiscount = () => { + setTicket({ + ...ticket, + codeDiscount: null, + }) + onChange?.({ + ...ticket, + codeDiscount: null, + }) + setOpenCodeDiscount(!openCodeDiscount) + } + return (
= ({ display: 'flex', flexDirection: 'row', justifyContent: 'end', + flexWrap: 'wrap', + gap: '0.5rem', }} > -
{ + if (ticket.buyable) { + resetGroupDiscount() + resetCodeDiscount() + setOpenGroupDiscount(false) + setOpenCodeDiscount(false) + } + setTicket({ ...ticket, buyable: !ticket.buyable }) + onChange?.({ ...ticket, buyable: !ticket.buyable }) }} > + {ticket.buyable ? 'Disable Buying' : 'Enable Buying'} + + {ticket.buyable && ( - {openGroupDiscount ? ( - <> -
- { - const groupDiscount = e.target.value - setTicket({ ...ticket, groupDiscount }) - onChange?.({ ...ticket, groupDiscount }) - }} - /> -
- % Discount for -
- { - const groupNumber = e.target.value - setTicket({ ...ticket, groupNumber }) - onChange?.({ ...ticket, groupNumber }) - }} - /> -
- - - ) : ( - <> - - - )} -
+ )} + {ticket.buyable && ( + + )}
+ {openGroupDiscount && ( +
+
+ { + const groupDiscount = e.target.value + setTicket({ ...ticket, groupDiscount }) + onChange?.({ ...ticket, groupDiscount }) + }} + /> +
+ % Group Discount for +
+ { + const groupNumber = e.target.value + setTicket({ ...ticket, groupNumber }) + onChange?.({ ...ticket, groupNumber }) + }} + /> +
+
+ )} + {openCodeDiscount && ( +
+
+ { + const codeDiscount = e.target.value + setTicket({ ...ticket, codeDiscount }) + onChange?.({ ...ticket, codeDiscount }) + }} + /> +
+ % Promo Code Discount +
+ )} ) } @@ -218,6 +268,7 @@ type Ticket = { price: string | null // Free if null groupDiscount: string | null // If null, no group discount groupNumber: string | null // If null, no group discount + codeDiscount: string | null // If null, no code discount buyable: boolean } @@ -245,6 +296,7 @@ const TicketsModal = ({ price: '0.00', groupDiscount: null, groupNumber: null, + codeDiscount: null, buyable: true, }, ]) @@ -259,6 +311,7 @@ const TicketsModal = ({ price: '0.00', groupDiscount: null, groupNumber: null, + codeDiscount: null, buyable: true, }, ]) @@ -274,12 +327,15 @@ const TicketsModal = ({ type: ticket.name, count: parseInt(ticket.count ?? '0'), price: parseFloat(ticket.price ?? '0'), - groupDiscount: usingGroupPricing - ? parseFloat(ticket.groupDiscount!) + group_discount: usingGroupPricing + ? parseFloat(ticket.groupDiscount!) / 100 : null, - groupNumber: usingGroupPricing + group_size: usingGroupPricing ? parseFloat(ticket.groupNumber!) : null, + code_discount: ticket.codeDiscount + ? parseFloat(ticket.codeDiscount!) / 100 + : null, buyable: ticket.buyable, } }) @@ -297,8 +353,10 @@ const TicketsModal = ({ router.push(`/tickets/${id}`) }, 500) } else { - notify(<>Error creating tickets, 'error') - setSubmitting(false) + res.json().then((data) => { + notify(<>Error creating tickets: {data.detail}, 'error') + setSubmitting(false) + }) } }) } @@ -326,10 +384,7 @@ const TicketsModal = ({ /> {name} - - Create new tickets for this event. For our beta, only free tickets - will be supported for now: stay tuned for payments integration! - + Create new tickets for this event.

Tickets

diff --git a/frontend/components/ModelForm.tsx b/frontend/components/ModelForm.tsx index f4c98b05a..1d0202d2c 100644 --- a/frontend/components/ModelForm.tsx +++ b/frontend/components/ModelForm.tsx @@ -86,6 +86,7 @@ type ModelFormProps = { confirmDeletion?: boolean actions?: (object: ModelObject) => ReactElement draggable?: boolean + scrollable?: boolean } /** @@ -126,6 +127,7 @@ type ModelTableProps = { actions?: (object: ModelObject) => ReactElement draggable?: boolean onDragEnd?: (result: any) => void | null | undefined + scrollable?: boolean } /** @@ -146,6 +148,7 @@ export const ModelTable = ({ actions, draggable = false, onDragEnd, + scrollable = false, }: ModelTableProps): ReactElement => { const columns = useMemo( () => @@ -225,6 +228,7 @@ export const ModelTable = ({ filterOptions={filterOptions || []} draggable={draggable} onDragEnd={onDragEnd} + scrollable={scrollable} /> ) @@ -275,6 +279,7 @@ export const ModelForm = (props: ModelFormProps): ReactElement => { onChange: parentComponentChange, onEditPressed = () => undefined, draggable = false, + scrollable = false, } = props /** @@ -483,6 +488,7 @@ export const ModelForm = (props: ModelFormProps): ReactElement => { actions={actions} draggable={draggable} onDragEnd={onDragEnd} + scrollable={scrollable} /> {(allowCreation || currentlyEditing !== null) && ( <> diff --git a/frontend/components/TabView.tsx b/frontend/components/TabView.tsx index 69d41ed35..5ed7d880f 100644 --- a/frontend/components/TabView.tsx +++ b/frontend/components/TabView.tsx @@ -41,7 +41,9 @@ const BackgroundTabs = styled.div` const Div = styled.div` padding: 1rem 0; ` -const Tabs = styled.div`` +const Tabs = styled.div` + width: 100%; +` type Tab = { name: string diff --git a/frontend/components/Tickets/CartTickets.tsx b/frontend/components/Tickets/CartTickets.tsx index 49d8f640b..f96d821f8 100644 --- a/frontend/components/Tickets/CartTickets.tsx +++ b/frontend/components/Tickets/CartTickets.tsx @@ -13,6 +13,39 @@ import { doApiRequest } from '~/utils' import { ModalContent } from '../ClubPage/Actions' const Summary: React.FC<{ tickets: CountedEventTicket[] }> = ({ tickets }) => { + const ticketsWithDiscounts = tickets.map((ticket) => { + const totalPrice = Number(ticket.price) * (ticket.count ?? 1) + let discountedPrice = totalPrice + + if ( + ticket.code_discount && + ticket.code_discount > 0 && + ticket.discount_code_applied + ) { + discountedPrice *= 1 - ticket.code_discount + } + + if ( + ticket.group_discount && + ticket.group_size && + ticket.count && + ticket.count >= ticket.group_size + ) { + discountedPrice *= 1 - ticket.group_discount + } + + return { + ...ticket, + originalTotal: totalPrice, + discountedTotal: discountedPrice, + } + }) + + // Calculate grand total using discounted prices + const grandTotal = ticketsWithDiscounts + .reduce((acc, ticket) => acc + ticket.discountedTotal, 0) + .toFixed(2) + return ( <> Summary @@ -65,17 +98,40 @@ const Summary: React.FC<{ tickets: CountedEventTicket[] }> = ({ tickets }) => { > Total - {tickets.map((ticket) => ( - <> - {ticket.count} x - {ticket.event.name} - ({ticket.type}) - ${ticket.price} - - ${(Number(ticket.price) * (ticket.count ?? 1)).toFixed(2)} - - - ))} + {ticketsWithDiscounts.map((ticket) => { + let price_string = ticket.price + if ( + ticket.code_discount && + ticket.code_discount > 0 && + ticket.discount_code_applied + ) { + price_string += ` - ${ticket.code_discount * 100}% (code)` + } + if ( + ticket.group_discount && + ticket.group_size && + ticket.count && + ticket.count >= ticket.group_size + ) { + price_string += ` - ${ticket.group_discount * 100}% (group)` + } + + if (ticket.originalTotal !== ticket.discountedTotal) { + price_string += ` = ${ticket.discountedTotal.toFixed(2)}` + } + + return ( + <> + {ticket.count} x + {ticket.event.name} + {ticket.type} + {price_string} + + ${ticket.discountedTotal.toFixed(2)} + + + ) + })}
= ({ tickets }) => { `} > Total - - $ - {tickets - .map((ticket) => Number(ticket.price) * (ticket.count ?? 1)) - .reduce((acc, curr) => acc + curr, 0) - .toFixed(2)} - + ${grandTotal} diff --git a/frontend/components/common/Modal.tsx b/frontend/components/common/Modal.tsx index 37491f0b2..5579f2db2 100644 --- a/frontend/components/common/Modal.tsx +++ b/frontend/components/common/Modal.tsx @@ -34,18 +34,12 @@ const ModalCard = styled.div<{ $width?: string }>` overflow: auto; width: ${({ $width }) => $width ?? '35%'}; - ${({ $width }) => - !$width - ? ` ${mediaMaxWidth(MD)} { - width: 50%; + width: ${({ $width }) => ($width ? 'max(60%, ' + $width + ')' : '50%')}; } - ${mediaMaxWidth(SM)} { - width: 90%; + width: ${({ $width }) => ($width ? 'max(90%, ' + $width + ')' : '90%')}; } - ` - : ''} ` export const ModalContentWrapper = styled.div<{ $marginBottom?: boolean }>` diff --git a/frontend/components/common/Table.tsx b/frontend/components/common/Table.tsx index 3879aeab5..f42527bf7 100644 --- a/frontend/components/common/Table.tsx +++ b/frontend/components/common/Table.tsx @@ -78,6 +78,7 @@ type tableProps = { initialPage?: number setInitialPage?: (page: number) => void initialPageSize?: number + scrollable?: boolean } const Styles = styled.div` @@ -116,6 +117,16 @@ const Input = styled.input` } ` +const TableWrapper = styled.div<{ $scrollable: boolean }>` + width: 100%; + ${({ $scrollable }) => + $scrollable && + ` + overflow-x: auto; + -webkit-overflow-scrolling: touch; + `} +` + const Table = ({ columns, data, @@ -129,6 +140,7 @@ const Table = ({ initialPage = 0, setInitialPage, initialPageSize = 10, + scrollable = false, }: tableProps): ReactElement => { const [searchQuery, setSearchQuery] = useState('') const [tableData, setTableData] = useState([]) @@ -292,131 +304,135 @@ const Table = ({ )} {tableData.length > 0 ? ( - - - {headerGroups.map((headerGroup) => ( - - {headerGroup.headers.map((column) => ( - + {page.map((row, i) => { + prepareRow(row) + return focusable != null && focusable ? ( + { + if (onClick != null) { + onClick(row, e) + } + }} + > + {columns.map((column, i) => { + return ( + + ) + })} + + ) : ( + + {columns.map((column, i) => { + return ( + + ) + })} + + ) + })} + + )} +
- {titleize(column.render('Header'))} - - {column.isSorted ? ( - column.isSortedDesc ? ( - + + + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((column) => ( + + ))} + + ))} + + {draggable ? ( + + + {(provided) => ( + + {page.map((row, index) => { + prepareRow(row) + return focusable != null && focusable ? ( + { + if (onClick != null) { + onClick(row, e) + } + }} + > + {columns.map((column, i) => { + return ( + + ) + })} + ) : ( - + + {(provided) => ( + + {columns.map((column, i) => { + return ( + + ) + })} + + )} + ) - ) : ( - '' - )} - - - ))} - - ))} - - {draggable ? ( - - - {(provided) => ( - - {page.map((row, index) => { - prepareRow(row) - return focusable != null && focusable ? ( - { - if (onClick != null) { - onClick(row, e) - } - }} - > - {columns.map((column, i) => { - return ( - - ) - })} - - ) : ( - - {(provided) => ( - - {columns.map((column, i) => { - return ( - - ) - })} - - )} - - ) - })} - {provided.placeholder} - - )} - - - ) : ( - - {page.map((row, i) => { - prepareRow(row) - return focusable != null && focusable ? ( - { - if (onClick != null) { - onClick(row, e) - } - }} - > - {columns.map((column, i) => { - return ( - - ) - })} - - ) : ( - - {columns.map((column, i) => { - return ( - - ) - })} - - ) - })} - - )} -
+ {titleize(column.render('Header'))} + + {column.isSorted ? ( + column.isSortedDesc ? ( + + ) : ( + + ) + ) : ( + '' + )} + +
+ {column.render + ? column.render(row.original.id, row.id) + : row.original[column.name]} +
+ {column.render + ? column.render(row.original.id, row.id) + : row.original[column.name]} +
- {column.render - ? column.render(row.original.id, row.id) - : row.original[column.name]} -
- {column.render - ? column.render(row.original.id, row.id) - : row.original[column.name]} -
- {column.render - ? column.render(row.original.id, row.id) - : row.original[column.name]} -
- {column.render - ? column.render(row.original.id, row.id) - : row.original[column.name]} -
+ })} + {provided.placeholder} + + )} + + + ) : ( +
+ {column.render + ? column.render(row.original.id, row.id) + : row.original[column.name]} +
+ {column.render + ? column.render(row.original.id, row.id) + : row.original[column.name]} +
+ ) : (

No matches were found. Please change your filters.

)} diff --git a/frontend/pages/events/[id].tsx b/frontend/pages/events/[id].tsx index 2fe811d61..cd10d4e66 100644 --- a/frontend/pages/events/[id].tsx +++ b/frontend/pages/events/[id].tsx @@ -31,8 +31,8 @@ import { PHONE, WHITE, } from '~/constants' -import { Club, ClubEvent, TicketAvailability } from '~/types' -import { doApiRequest, EMPTY_DESCRIPTION } from '~/utils' +import { Availability, Club, ClubEvent } from '~/types' +import { doApiRequest, EMPTY_DESCRIPTION, isZeroish } from '~/utils' import { APPROVAL_AUTHORITY } from '~/utils/branding' import { createBasePropFetcher } from '~/utils/getBaseProps' @@ -70,7 +70,7 @@ export const getServerSideProps = (async (ctx) => { (resp) => resp.json() as Promise, ), doApiRequest(`/clubs/${event.club}/events/${id}/tickets/`, data).then( - (resp) => resp.json() as Promise, + (resp) => resp.json() as Promise>, ), ]) return { @@ -162,6 +162,10 @@ type Ticket = { max: string available: string count: number | null + group_discount: number | null + group_size: number | null + code_discount: number | null + discount_code: string | null } type TicketItemProps = { @@ -170,6 +174,10 @@ type TicketItemProps = { price: string max: string onCountChange: (newCount: number) => void + onDiscountCodeChange: (code: string) => void + group_discount: number | null + group_size: number | null + code_discount: number | null } const GetTicketItem: React.FC = ({ @@ -177,9 +185,15 @@ const GetTicketItem: React.FC = ({ name, price, max, + group_discount, + group_size, + code_discount, onCountChange, + onDiscountCodeChange, }) => { const [count, setCount] = useState(ticket.count) + const [showDiscounts, setShowDiscounts] = useState(false) + const [discountCode, setDiscountCode] = useState('') const handleCountChange = (e: React.ChangeEvent) => { // Round to nearest integer and clamp to min/max const value = Math.max( @@ -190,6 +204,15 @@ const GetTicketItem: React.FC = ({ onCountChange(value) } + const handleDiscountCodeChange = (e: React.ChangeEvent) => { + const code = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '') + setDiscountCode(code) + onDiscountCodeChange(code) + } + + const hasGroupDiscount = group_discount !== null && !isZeroish(group_discount) + const hasCodeDiscount = code_discount !== null && !isZeroish(code_discount) + return (
= ({ padding: '0px 16px', }} > -

- {name} - ${price} -

+
+

+ {name} - ${price} +

+ {(hasGroupDiscount || hasCodeDiscount) && ( + + )} +
= ({ style={{ flex: '0 0 auto', maxWidth: '64px' }} />
+ {showDiscounts && ( +
+ {hasGroupDiscount && ( +
+

+ Group Discount: {group_discount * 100}% for groups of{' '} + {group_size} and more. +

+
+ )} + {hasCodeDiscount && ( +
+

Code Discount: {code_discount * 100}%

+ +
+ )} +
+ )} ) } @@ -247,10 +306,23 @@ const EventPage: React.FC = ({ available: tickets.available.find((t) => t.type === cur.type)?.count ?? 0, price: cur.price, + group_discount: cur.group_discount, + code_discount: cur.code_discount, + group_size: cur.group_size, }, }), {}, - ) as Record + ) as Record< + string, + { + total: number + available: number + price: number + group_discount: number | null + code_discount: number | null + group_size: number | null + } + > const [order, setOrder] = useState( Object.entries(ticketMap).map(([type, counts]) => ({ @@ -259,6 +331,10 @@ const EventPage: React.FC = ({ max: counts.total.toString(), available: counts.available.toString(), count: 0, + group_discount: counts.group_discount, + code_discount: counts.code_discount, + group_size: counts.group_size, + discount_code: null, })), ) @@ -292,11 +368,19 @@ const EventPage: React.FC = ({ max={ticket.available} name={ticket.type} price={ticket.price} + group_discount={ticket.group_discount} + group_size={ticket.group_size} + code_discount={ticket.code_discount} onCountChange={(count: number | null) => { const ticks = [...order] ticks[index].count = count setOrder(ticks) }} + onDiscountCodeChange={(code: string) => { + const ticks = [...order] + ticks[index].discount_code = code + setOrder(ticks) + }} /> ))}