Skip to content
Draft

draft #1031

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7532495
Donate form properly crashes when the server returns a url that will …
uraniumanchor Jun 18, 2026
ab52a57
Adjust Donation Volunteer default permissions
uraniumanchor Mar 8, 2026
f2e2958
simple Twitch donation support
uraniumanchor Jul 22, 2025
cbe518a
accept twitch payloads
uraniumanchor Oct 14, 2025
4e8a148
wip - merge migration
uraniumanchor Jul 6, 2025
967035b
wip - cache schedule endpoints
uraniumanchor Jul 6, 2025
4500ec4
wip - only cache public requests
uraniumanchor Jul 7, 2025
00d4dfa
wip - merge migration
uraniumanchor Jul 24, 2025
a914253
wip - merge migration again
uraniumanchor Sep 7, 2025
5bb9928
wip - tweak to prize submission form
uraniumanchor Nov 21, 2025
41484ae
wip - fix prize permission checks
uraniumanchor Dec 2, 2025
117f1b9
wip - pending bids page listens to socket
uraniumanchor Jan 4, 2026
09eb3fc
wip - merge migration again
uraniumanchor Jun 18, 2026
81d53be
wip - fix prize time crash
uraniumanchor Jul 3, 2026
ca25baa
wip - Django 5.0 removed logout GET requests
uraniumanchor Jul 3, 2026
2a6054b
FIXME - horrible hack for the MHW weapon incentive
uraniumanchor Jul 4, 2026
52dca0f
wip - remove prize window caching for now
uraniumanchor Jul 5, 2026
a94b0f1
wip - fix broken link from prize detail to donate page
uraniumanchor Jul 8, 2026
f6ea2db
wip - bcause
uraniumanchor Aug 14, 2026
ffd191e
wip - partial bcause support
uraniumanchor Aug 16, 2026
40d199d
wip - tweaks
uraniumanchor Aug 16, 2026
eb1019d
wip - no bid
uraniumanchor Aug 18, 2026
c70d6bc
wip - capture donor name as alias
uraniumanchor Aug 27, 2026
95b7282
wip - bcause improvements - better incentive search, fee is added, se…
uraniumanchor Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions bundles/admin/donationProcessing/processPendingBids.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ export default React.memo(function ProcessPendingBids() {
error: bidError,
refetch: refetchBids,
isFetching: bidFetching,
} = useBidTreeQuery({
urlParams: { ...eventFilter, feed: 'pending' },
});
} = useBidTreeQuery(
{
urlParams: { ...eventFilter, feed: 'pending' },
listen: true,
},
{},
);
const {
data: event,
error: eventError,
Expand Down
12 changes: 10 additions & 2 deletions bundles/admin/totalWatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,10 +403,18 @@ export default React.memo(function TotalWatch() {
</div>
)}
{bid.options
?.toSorted((a, b) => b.total - a.total)
?.toSorted((a, b) => {
if (a.state === 'OPENED' && b.state !== 'OPENED') {
return -1;
}
if (b.state === 'OPENED' && a.state !== 'OPENED') {
return 1;
}
return b.total - a.total;
})
.map(o => (
<h4 key={o.id}>
{o.name} ${format.format(o.total)} {bid.allowuseroptions && `(${o.state})`}
{o.name} ${format.format(o.total)} {`(${o.state})`}
</h4>
))}
</React.Fragment>
Expand Down
2 changes: 2 additions & 0 deletions bundles/processing/modules/donations/DonationRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export default function DonationRow(props: DonationRowProps) {
<HighlightKeywords>{donation.donor_name || UNKNOWN_DONOR_NAME}</HighlightKeywords>
</strong>
{donation.pinned && <Pin className={styles.pinIcon} />}
{' · '}
<Tag>{donation.domain}</Tag>
</Text>
);

Expand Down
2 changes: 1 addition & 1 deletion bundles/processing/modules/settings/PrimaryNavPopout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const NavRoutes = {
MILESTONES: (eventId: string | number) => `/tracker/milestones/${eventId}`,
PRIZES: (eventId: string | number) => `/tracker/prizes/${eventId}`,
RUNS: (eventId: string | number) => `/tracker/runs/${eventId}`,
LOGOUT: `/tracker/user/logout/`,
LOGOUT: `/tracker/user/logout_form/`,
SELF_SERVICE: `/tracker/user/index/`,

ADMIN_HOME: `/`,
Expand Down
9 changes: 9 additions & 0 deletions bundles/public/apiv2/APITypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,17 @@ export interface DonationPost {
bids: DonationPostBid[];
domain?: DonationDomain; // defaults to 'LOCAL'
// only with creation permission
domain_id?: string; // required for 'TWITCH' donations
donor_email?: string;
donor_id?: number;
donor_twitch_id?: number;
}

export interface DonationBidPost {
bid?: number;
amount: number;
parent?: number;
name?: string;
}

export interface APIRun
Expand Down
1 change: 1 addition & 0 deletions bundles/public/apiv2/Endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function appendTree(path: string, tree?: number | { tree?: boolean }) {
}

const Endpoints = {
DONATE: 'donate/',
DONATIONS: (params: number | { eventId?: number; state?: DonationState } = {}) =>
appendState(prependEvent('donations', params), params),
DONATIONS_UNPROCESS: (donationId: number) => `donations/${donationId}/unprocess/`,
Expand Down
2 changes: 1 addition & 1 deletion bundles/public/apiv2/Models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export interface Event extends ModelBase {
}

export type DonationTransactionState = 'COMPLETED' | 'PENDING' | 'CANCELLED' | 'FLAGGED';
export type DonationDomain = 'PAYPAL' | 'LOCAL' | 'CHIPIN';
export type DonationDomain = 'PAYPAL' | 'LOCAL' | 'CHIPIN' | 'TWITCH';
export type DonationReadState = 'PENDING' | 'READY' | 'IGNORED' | 'READ' | 'FLAGGED';
export type DonationCommentState = 'ABSENT' | 'PENDING' | 'DENIED' | 'APPROVED' | 'FLAGGED';

Expand Down
98 changes: 64 additions & 34 deletions bundles/tracker/donation/__tests__/Donate.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { act, fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import Constants, { DefaultConstants } from '@common/Constants';
import { DonationPost } from '@public/apiv2/APITypes';
import Endpoints from '@public/apiv2/Endpoints';
import HTTPUtils from '@public/apiv2/HTTPUtils';
import { setRoot } from '@public/apiv2/reducers/apiRoot';
Expand Down Expand Up @@ -37,36 +38,45 @@ const renderDonate = async () => {
const getSubmitButton = () => rendered.getByTestId('donation-submit') as HTMLButtonElement;
const getSubmitBidButton = () => rendered.getByTestId('incentiveBidForm-submitBid') as HTMLButtonElement;

const submitForm = async (mock: MockAdapter, payload: Partial<DonationPost>) => {
// TODO: filling in the bid amount doesn't seem to be getting picked up by ReactNumeric even with userEvent
expect(getSubmitButton().disabled).toBe(false);

expect(HTMLFormElement.prototype.submit).not.toHaveBeenCalled();

await act(() => fireEvent.click(getSubmitButton()));

expect(mock.history.post.length).toBe(1);
const post = mock.history.post[0];
expect(post?.url).toEqual('/' + Endpoints.DONATE);
expect(JSON.parse(post?.data)).toEqual(jasmine.objectContaining({ event: eventId, ...payload }));
expect(HTMLFormElement.prototype.submit).toHaveBeenCalled();
};

const fillField = async (fieldLabel: string | RegExp, value: string) => {
const input = rendered.getByLabelText(fieldLabel);
if (fieldLabel.toString().includes('amount')) {
// ReactNumeric does not like fireEvent
await act(() => userEvent.type(input, value));
} else {
await act(() => fireEvent.change(input, { target: value }));
}
await act(() => userEvent.type(input, value));
};

const addIncentive = async () => {
const addButton = getAddIncentivesButton();
await act(() => fireEvent.click(addButton));
await act(() => fireEvent.click(getAddIncentivesButton()));
};

const fillBid = async (incentiveId: string, bid: { choiceId?: string; amount?: number; custom?: string }) => {
const fillBid = async (
incentiveId: string | number | undefined,
bid: { choiceId?: string; amount?: number; custom?: string },
) => {
expect(incentiveId).toBeDefined();
await act(() => fireEvent.click(rendered.getByTestId(`incentiveform-incentive-${incentiveId}`)));
if (bid.amount != null) {
await fillField(/Amount to put towards incentive/i, bid.amount.toString());
}

if (bid.custom != null) {
await act(() => {
const customOption = rendered.getByTestId('incentiveBidNewOption');
fireEvent.click(customOption);
});
await act(() => {
const customInput = rendered.getByTestId('incentiveBidCustomOption');
fireEvent.change(customInput, { target: { value: bid.custom } });
});
await act(() => fireEvent.click(rendered.getByTestId('incentiveBidNewOption')));
await act(() =>
fireEvent.change(rendered.getByTestId('incentiveBidCustomOption'), { target: { value: bid.custom } }),
);
}
};

Expand All @@ -89,6 +99,7 @@ const renderDonate = async () => {
return {
...rendered,
getSubmitButton,
submitForm,
getAddIncentivesButton,
fillField,
addIncentive,
Expand All @@ -100,19 +111,23 @@ const renderDonate = async () => {

describe('Donate', () => {
let mock: MockAdapter;
let bids: ReturnType<typeof getFixtureMixedBidsTree>;

beforeAll(() => {
mock = new MockAdapter(HTTPUtils.getInstance(), { onNoMatch: 'throwException' });
});

beforeEach(() => {
bids = getFixtureMixedBidsTree();
store.dispatch(setRoot({ root: '//testserver/', limit: 500, csrfToken: 'deadbeef' }));
store.dispatch(trackerApi.util.resetApiState());
mock.reset();
mock.onGet('//testserver/' + Endpoints.EVENTS).reply(() => [200, getFixturePagedEvent({ id: eventId })]);
mock.onGet('//testserver/' + Endpoints.BIDS({ eventId: 2, feed: 'open', tree: true })).reply(() => [200, bids]);
mock
.onGet('//testserver/' + Endpoints.BIDS({ eventId: 2, feed: 'open', tree: true }))
.reply(() => [200, getFixtureMixedBidsTree({})]);
.onPost('//testserver/' + Endpoints.DONATE)
.reply(() => [200, { confirm_url: `//${window.location.host}/paypal_confirm` }]);
spyOn(HTMLFormElement.prototype, 'submit');
});

afterAll(() => {
Expand All @@ -126,30 +141,39 @@ describe('Donate', () => {
});

it('is submittable with just an amount set', async () => {
const { getSubmitButton, fillField } = await renderDonate();
const { submitForm, fillField } = await renderDonate();
await fillField(/amount/i, '10');

expect(getSubmitButton().disabled).toBe(false);
await submitForm(mock, { amount: 10, requested_email: '', comment: '', requested_alias: '' });
});

it('is submittable with no alias set', async () => {
const { getSubmitButton, fillField } = await renderDonate();
const { submitForm, fillField } = await renderDonate();
await fillField(/email/i, 'someone@example.com');
await fillField(/amount/i, '10');

expect(getSubmitButton().disabled).toBe(false);
await submitForm(mock, {
amount: 10,
requested_email: 'someone@example.com',
comment: '',
requested_alias: '',
});
});

it('is submittable with all donation fields filled out', async () => {
const { getSubmitButton, fillField } = await renderDonate();
const { submitForm, fillField } = await renderDonate();
await fillField(/alias/i, 'my name');
await fillField(/email/i, 'someone@example.com');
await fillField(/amount/i, '10');
await fillField(/comment/i, 'got a comment here');

expect(getSubmitButton().disabled).toBe(false);
await submitForm(mock, {
amount: 10,
requested_email: 'someone@example.com',
comment: 'got a comment here',
requested_alias: 'my name',
});
});

it('redirects to confirmation url when request is successful', async () => {});

describe('adding incentives', () => {
it('is disabled with no amount set', async () => {
const { getAddIncentivesButton } = await renderDonate();
Expand All @@ -165,25 +189,31 @@ describe('Donate', () => {
});

it('works with a valid bid', async () => {
const { addIncentive, fillField, fillBid, getSubmitButton, submitBid } = await renderDonate();
const { addIncentive, fillField, fillBid, submitBid, submitForm } = await renderDonate();
await fillField(/amount/i, '10');

const challengeId = bids.results.find(b => b.bid_type === 'challenge')?.id;

await addIncentive();
await fillBid('121', { amount: 4.2 });
await fillBid(challengeId, { amount: 4.2 });
await submitBid();

expect(getSubmitButton().disabled).toBe(false);
// FIXME: amount change not getting picked up by ReactNumeric in test environment
await submitForm(mock, { bids: [{ id: challengeId!, amount: 10 }] });
});

it('works with a custom bid option', async () => {
const { addIncentive, fillField, fillBid, getSubmitButton, submitBid } = await renderDonate();
const { addIncentive, fillField, fillBid, submitBid, submitForm } = await renderDonate();
await fillField(/amount/i, '10');

const choiceId = bids.results.find(b => b.bid_type === 'choice')?.id;

await addIncentive();
await fillBid('122', { choiceId: '3', amount: 3.7, custom: 'idk' });
await fillBid(choiceId, { choiceId: '3', amount: 3.7, custom: 'idk' });
await submitBid();

expect(getSubmitButton().disabled).toBe(false);
// FIXME: amount change not getting picked up by ReactNumeric in test environment
await submitForm(mock, { bids: [{ parent: choiceId!, amount: 10, name: 'idk' }] });
});

it('can remove added bids', async () => {
Expand Down
11 changes: 9 additions & 2 deletions bundles/tracker/donation/components/Donate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ function Internal({ event }: { event: Event }) {
}
}, [confirmUrl]);

const [error, setError] = React.useState<string | null>(null);

if (error) {
// thrown here so that it tears down the entire tree to make it obvious something is very wrong
throw new Error(error);
}

const handleSubmit = React.useCallback(async () => {
if (errors == null && donation.amount) {
const { data } = await donate({ ...donation, amount: donation.amount, event: event.id });
Expand All @@ -107,8 +114,8 @@ function Internal({ event }: { event: Event }) {
setConfirmUrl(url.toString());
} else {
// this is a serious misconfiguration issue
throw new Error(
`confirmation url and window url origin did not match: ${url.origin} !== ${window.location.origin}`,
setError(
`confirmation url and window url origin did not match, server configuration is incorrect\nExpected: ${window.location.origin}\nActual: ${url.origin}`,
);
}
} else {
Expand Down
25 changes: 14 additions & 11 deletions bundles/tracker/donation/components/DonationBidForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,20 @@ const DonationBidForm = (props: DonationBidFormProps) => {
max={remainingDonationTotal}
/>

{incentive.options?.toSorted(compareBidChild).map(option => (
<Checkbox
key={option.id}
checked={selectedChoiceId === option.id}
contentClassName={styles.choiceLabel}
look={Checkbox.Looks.DENSE}
onChange={handleNewChoice(option.id)}>
<Checkbox.Header>{option.name}</Checkbox.Header>
<span className={styles.choiceAmount}>{eventCurrency(option.total)}</span>
</Checkbox>
))}
{incentive.options?.toSorted(compareBidChild).map(
option =>
option.state === 'OPENED' && (
<Checkbox
key={option.id}
checked={selectedChoiceId === option.id}
contentClassName={styles.choiceLabel}
look={Checkbox.Looks.DENSE}
onChange={handleNewChoice(option.id)}>
<Checkbox.Header>{option.name}</Checkbox.Header>
<span className={styles.choiceAmount}>{eventCurrency(option.total)}</span>
</Checkbox>
),
)}

{incentive.allowuseroptions && (
<>
Expand Down
2 changes: 1 addition & 1 deletion bundles/tracker/prizes/components/PrizeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ const PrizeDetail = (props: PrizeProps) => {
{event != null ? (
<React.Fragment>
<strong className={styles.summaryItem}>
<Anchor href={`/tracker/event/${event.id}`}>{event.name}</Anchor>
<Anchor href={TrackerRoutes.EVENT_DONATE(eventPath)}>{event.name}</Anchor>
</strong>
&nbsp;&middot;&nbsp;
</React.Fragment>
Expand Down
6 changes: 3 additions & 3 deletions bundles/tracker/router/RouterUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ export const Routes = {
// TODO: This URL is currently inverted as other parts of the tracker have
// expect it to be in this format. Once those dependencies can be updated,
// this can change to match normal REST structure.
EVENT_DONATE: (eventId: string | number) => `/donate/${eventId}`,
EVENT_PRIZES: (eventId: string | number) => Routes.EVENT_BASE(eventId) + '/prizes',
EVENT_PRIZE: (eventId: string | number, prizeId: number) => Routes.EVENT_BASE(eventId) + `/prizes/${prizeId}`,
EVENT_DONATE: (eventId: string | number) => `${Routes.EVENT_BASE(eventId)}/donate`,
EVENT_PRIZES: (eventId: string | number) => `${Routes.EVENT_BASE(eventId)}/prizes`,
EVENT_PRIZE: (eventId: string | number, prizeId: number) => `${Routes.EVENT_BASE(eventId)}/prizes/${prizeId}`,
};

type NavigateOptions = {
Expand Down
Loading
Loading