indico-plugin-group-registration 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. indico_plugin_group_registration-0.1.0/.gitignore +20 -0
  2. indico_plugin_group_registration-0.1.0/PKG-INFO +169 -0
  3. indico_plugin_group_registration-0.1.0/README.md +150 -0
  4. indico_plugin_group_registration-0.1.0/indico_group_registration/__init__.py +15 -0
  5. indico_plugin_group_registration-0.1.0/indico_group_registration/blueprint.py +34 -0
  6. indico_plugin_group_registration-0.1.0/indico_group_registration/client/js/GroupPlanInput.jsx +298 -0
  7. indico_plugin_group_registration-0.1.0/indico_group_registration/client/js/GroupPlanInput.module.scss +71 -0
  8. indico_plugin_group_registration-0.1.0/indico_group_registration/client/js/groupPanel.js +41 -0
  9. indico_plugin_group_registration-0.1.0/indico_group_registration/client/js/index.jsx +24 -0
  10. indico_plugin_group_registration-0.1.0/indico_group_registration/client/styles/main.scss +53 -0
  11. indico_plugin_group_registration-0.1.0/indico_group_registration/constants.py +20 -0
  12. indico_plugin_group_registration-0.1.0/indico_group_registration/controllers/__init__.py +0 -0
  13. indico_plugin_group_registration-0.1.0/indico_group_registration/controllers/display.py +162 -0
  14. indico_plugin_group_registration-0.1.0/indico_group_registration/controllers/management.py +206 -0
  15. indico_plugin_group_registration-0.1.0/indico_group_registration/fields.py +247 -0
  16. indico_plugin_group_registration-0.1.0/indico_group_registration/forms.py +94 -0
  17. indico_plugin_group_registration-0.1.0/indico_group_registration/handlers.py +79 -0
  18. indico_plugin_group_registration-0.1.0/indico_group_registration/migrations/20260831_0000_a1b2c3d4e5f6_create_group_registration_tables.py +92 -0
  19. indico_plugin_group_registration-0.1.0/indico_group_registration/models/__init__.py +5 -0
  20. indico_plugin_group_registration-0.1.0/indico_group_registration/models/groups.py +228 -0
  21. indico_plugin_group_registration-0.1.0/indico_group_registration/models/members.py +122 -0
  22. indico_plugin_group_registration-0.1.0/indico_group_registration/models/settings.py +126 -0
  23. indico_plugin_group_registration-0.1.0/indico_group_registration/notifications.py +57 -0
  24. indico_plugin_group_registration-0.1.0/indico_group_registration/operations.py +245 -0
  25. indico_plugin_group_registration-0.1.0/indico_group_registration/plans.py +206 -0
  26. indico_plugin_group_registration-0.1.0/indico_group_registration/plugin.py +131 -0
  27. indico_plugin_group_registration-0.1.0/indico_group_registration/pricing.py +126 -0
  28. indico_plugin_group_registration-0.1.0/indico_group_registration/reconcile.py +105 -0
  29. indico_plugin_group_registration-0.1.0/indico_group_registration/reglist.py +71 -0
  30. indico_plugin_group_registration-0.1.0/indico_group_registration/tasks.py +21 -0
  31. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/_group_panel.html +139 -0
  32. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/_regform_settings.html +21 -0
  33. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/balances.html +78 -0
  34. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/customization/core/events/payment/event_checkout.html +34 -0
  35. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/emails/group_confirmed.txt +29 -0
  36. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/emails/group_dissolved.txt +31 -0
  37. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/emails/group_short.txt +44 -0
  38. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/groups.html +94 -0
  39. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/overview.html +53 -0
  40. indico_plugin_group_registration-0.1.0/indico_group_registration/templates/settings.html +41 -0
  41. indico_plugin_group_registration-0.1.0/indico_group_registration/util.py +187 -0
  42. indico_plugin_group_registration-0.1.0/pyproject.toml +45 -0
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ coverage.xml
13
+ htmlcov/
14
+ *.mo
15
+
16
+ # Plugin asset build artifacts
17
+ node_modules/
18
+ indico_group_registration/static/dist/
19
+ webpack-build-config.json
20
+ url_map.json
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: indico-plugin-group-registration
3
+ Version: 0.1.0
4
+ Summary: Participant-run group registration with plan-based discounts for Indico
5
+ Project-URL: Homepage, https://github.com/indico/indico
6
+ Author: RobotHanzo
7
+ License-Expression: MIT
8
+ Classifier: Environment :: Plugins
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Python: <3.13,>=3.12.2
13
+ Requires-Dist: indico>=3.3
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Requires-Dist: ruff>=0.8; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Indico Group Registration
21
+
22
+ Participant-run group registration with plan-based discounts for
23
+ [Indico](https://github.com/indico/indico) 3.3.
24
+
25
+ A participant picks a **group plan** while registering, gets a code and a join
26
+ link, and shares them however they like. Everyone who joins pays that plan's
27
+ rate straight away. The group **confirms itself** the moment the plan's seat
28
+ count is filled — there is no button to press. If it never fills, the plugin
29
+ reprices it at the reconciliation deadline and reports what each member still
30
+ owes.
31
+
32
+ Organizers set the plans and the disclaimer. They never have to create a group.
33
+
34
+ ## How it works
35
+
36
+ ### Plans
37
+
38
+ An organizer defines plans per registration form. A plan is a seat count and a
39
+ rate:
40
+
41
+ ```json
42
+ [
43
+ {"id": "p3", "label": "Group of 3", "size": 3, "type": "percent", "value": 10},
44
+ {"id": "p10", "label": "Group of 10", "size": 10, "type": "percent", "value": 15},
45
+ {"id": "p25", "label": "Group of 25", "size": 25, "type": "amount", "value": 50}
46
+ ]
47
+ ```
48
+
49
+ The seat count is both the **target and the cap**: filling it confirms the
50
+ group, and the group is then full.
51
+
52
+ ### Lifecycle
53
+
54
+ | State | What it means |
55
+ | --- | --- |
56
+ | `forming` | Seats filling. Members pay the chosen plan's rate, whenever they like. |
57
+ | `confirmed` | The seat count was reached. Automatic. The rate is final and never gets worse. |
58
+ | `short` | The deadline passed with seats empty. Repriced to whatever the group does qualify for; balances may be due. |
59
+ | `dissolved` | A manager took it apart. Everyone is back on the standard rate. |
60
+
61
+ The only transition a human triggers is dissolution.
62
+
63
+ ### Reconciliation, and the one thing to know before enabling this
64
+
65
+ Members may pay before their group fills. If the group then falls short, the
66
+ plugin reprices everyone — including people who have already paid.
67
+
68
+ **Indico has no concept of a partial payment or a balance.** A transaction is
69
+ one amount against one registration, and its checkout would charge the whole
70
+ new price again rather than the difference. So an already-paid member whose
71
+ price rises stays `complete` as far as Indico is concerned, and only this
72
+ plugin knows they owe anything.
73
+
74
+ The plugin gives organizers a **Balances due** list (paid amount, new price,
75
+ delta) and e-mails every affected member, but a person collects the money — at
76
+ the desk or by transfer, recorded as a manual payment. Two settings soften it:
77
+
78
+ - set the **reconciliation deadline** well before the event, so balances
79
+ surface while there is still time to chase them;
80
+ - turn off **allow early payment** if you would rather not chase anyone.
81
+
82
+ ### Sharing a group
83
+
84
+ The group panel shows the group code and the join link, each with a copy
85
+ button. **The plugin sends no invitations.** There is no invite box and no way
86
+ for a participant to make your server e-mail a stranger. Leaders share the link
87
+ themselves.
88
+
89
+ The plugin does send e-mails that nobody can trigger on demand: group
90
+ confirmed, group short with the new rate, and group dissolved.
91
+
92
+ ## Installation
93
+
94
+ ```bash
95
+ pip install indico-plugin-group-registration
96
+ ```
97
+
98
+ Add it to `indico.conf`:
99
+
100
+ ```python
101
+ PLUGINS = {'group_registration'}
102
+ ```
103
+
104
+ Then create the tables and build the assets:
105
+
106
+ ```bash
107
+ indico db --all-plugins upgrade
108
+ indico setup create-symlinks --help # if you serve plugin static files
109
+ python bin/maintenance/build-assets.py plugin /path/to/IndicoGroupRegistration
110
+ ```
111
+
112
+ Restart Indico and its Celery workers — the reconciliation task runs every
113
+ fifteen minutes from the Celery beat schedule.
114
+
115
+ ## Configuration
116
+
117
+ Event management → **Group registration**, then pick a registration form.
118
+ Nothing changes on a form until group registration is enabled for it.
119
+
120
+ | Setting | Default | Notes |
121
+ | --- | --- | --- |
122
+ | Plans | – | JSON, validated on save |
123
+ | Discount applies to | Registration fee | Or the whole price, including paid options |
124
+ | Reconciliation deadline | Registration end | Set it well before the event |
125
+ | Allow paying before the group fills | on | |
126
+ | Groups per person | 1 | 0 for no limit |
127
+ | Count registrations awaiting approval | on | Withdrawn and rejected never count |
128
+ | Reprice a confirmed group that loses a member | off | Nobody should be rebilled over somebody else's moderation |
129
+ | Disclaimer | a sensible default | Versioned; the version and time of acceptance are stored per membership |
130
+
131
+ Enabling group registration provisions two fields on the form:
132
+
133
+ - **`ext__group_plan`** — the plan picker the participant fills in. Put it
134
+ wherever you like in the form.
135
+ - **`ext__group_discount`** — a manager-only field carrying the discount. It is
136
+ locked, written only by the plugin, and appears as a named line on the
137
+ invoice. Leave it alone; the plugin puts it back if it is removed.
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ pip install -e '.[dev]'
143
+ pytest
144
+ ruff check .
145
+ ```
146
+
147
+ The tests here are unit tests over the money arithmetic and the code handling;
148
+ they do not need a database or Indico's pytest plugin.
149
+
150
+ ## How it hooks into Indico
151
+
152
+ Nothing in core is patched. See `docs/EXTENSION-POINTS.md` for the full map
153
+ with file-and-line references, and `docs/PLAN.md` for the design. The short
154
+ version:
155
+
156
+ - registration field types via `signals.core.get_fields`
157
+ - the registration lifecycle via `registration_created`, `registration_deleted`
158
+ and `registration_state_updated`
159
+ - `is_field_data_locked` to keep core out of the plugin's own field
160
+ - `registrant_list_items` for the Group column
161
+ - `before-render-registration-info` for the group panel
162
+ - the React field registry via `regformCustomFields`
163
+ - `get_template_customization_paths` to name the discount on the checkout page,
164
+ using `{% extends '~...' %}` so it inherits the core template rather than
165
+ forking it
166
+
167
+ ## Licence
168
+
169
+ MIT.
@@ -0,0 +1,150 @@
1
+ # Indico Group Registration
2
+
3
+ Participant-run group registration with plan-based discounts for
4
+ [Indico](https://github.com/indico/indico) 3.3.
5
+
6
+ A participant picks a **group plan** while registering, gets a code and a join
7
+ link, and shares them however they like. Everyone who joins pays that plan's
8
+ rate straight away. The group **confirms itself** the moment the plan's seat
9
+ count is filled — there is no button to press. If it never fills, the plugin
10
+ reprices it at the reconciliation deadline and reports what each member still
11
+ owes.
12
+
13
+ Organizers set the plans and the disclaimer. They never have to create a group.
14
+
15
+ ## How it works
16
+
17
+ ### Plans
18
+
19
+ An organizer defines plans per registration form. A plan is a seat count and a
20
+ rate:
21
+
22
+ ```json
23
+ [
24
+ {"id": "p3", "label": "Group of 3", "size": 3, "type": "percent", "value": 10},
25
+ {"id": "p10", "label": "Group of 10", "size": 10, "type": "percent", "value": 15},
26
+ {"id": "p25", "label": "Group of 25", "size": 25, "type": "amount", "value": 50}
27
+ ]
28
+ ```
29
+
30
+ The seat count is both the **target and the cap**: filling it confirms the
31
+ group, and the group is then full.
32
+
33
+ ### Lifecycle
34
+
35
+ | State | What it means |
36
+ | --- | --- |
37
+ | `forming` | Seats filling. Members pay the chosen plan's rate, whenever they like. |
38
+ | `confirmed` | The seat count was reached. Automatic. The rate is final and never gets worse. |
39
+ | `short` | The deadline passed with seats empty. Repriced to whatever the group does qualify for; balances may be due. |
40
+ | `dissolved` | A manager took it apart. Everyone is back on the standard rate. |
41
+
42
+ The only transition a human triggers is dissolution.
43
+
44
+ ### Reconciliation, and the one thing to know before enabling this
45
+
46
+ Members may pay before their group fills. If the group then falls short, the
47
+ plugin reprices everyone — including people who have already paid.
48
+
49
+ **Indico has no concept of a partial payment or a balance.** A transaction is
50
+ one amount against one registration, and its checkout would charge the whole
51
+ new price again rather than the difference. So an already-paid member whose
52
+ price rises stays `complete` as far as Indico is concerned, and only this
53
+ plugin knows they owe anything.
54
+
55
+ The plugin gives organizers a **Balances due** list (paid amount, new price,
56
+ delta) and e-mails every affected member, but a person collects the money — at
57
+ the desk or by transfer, recorded as a manual payment. Two settings soften it:
58
+
59
+ - set the **reconciliation deadline** well before the event, so balances
60
+ surface while there is still time to chase them;
61
+ - turn off **allow early payment** if you would rather not chase anyone.
62
+
63
+ ### Sharing a group
64
+
65
+ The group panel shows the group code and the join link, each with a copy
66
+ button. **The plugin sends no invitations.** There is no invite box and no way
67
+ for a participant to make your server e-mail a stranger. Leaders share the link
68
+ themselves.
69
+
70
+ The plugin does send e-mails that nobody can trigger on demand: group
71
+ confirmed, group short with the new rate, and group dissolved.
72
+
73
+ ## Installation
74
+
75
+ ```bash
76
+ pip install indico-plugin-group-registration
77
+ ```
78
+
79
+ Add it to `indico.conf`:
80
+
81
+ ```python
82
+ PLUGINS = {'group_registration'}
83
+ ```
84
+
85
+ Then create the tables and build the assets:
86
+
87
+ ```bash
88
+ indico db --all-plugins upgrade
89
+ indico setup create-symlinks --help # if you serve plugin static files
90
+ python bin/maintenance/build-assets.py plugin /path/to/IndicoGroupRegistration
91
+ ```
92
+
93
+ Restart Indico and its Celery workers — the reconciliation task runs every
94
+ fifteen minutes from the Celery beat schedule.
95
+
96
+ ## Configuration
97
+
98
+ Event management → **Group registration**, then pick a registration form.
99
+ Nothing changes on a form until group registration is enabled for it.
100
+
101
+ | Setting | Default | Notes |
102
+ | --- | --- | --- |
103
+ | Plans | – | JSON, validated on save |
104
+ | Discount applies to | Registration fee | Or the whole price, including paid options |
105
+ | Reconciliation deadline | Registration end | Set it well before the event |
106
+ | Allow paying before the group fills | on | |
107
+ | Groups per person | 1 | 0 for no limit |
108
+ | Count registrations awaiting approval | on | Withdrawn and rejected never count |
109
+ | Reprice a confirmed group that loses a member | off | Nobody should be rebilled over somebody else's moderation |
110
+ | Disclaimer | a sensible default | Versioned; the version and time of acceptance are stored per membership |
111
+
112
+ Enabling group registration provisions two fields on the form:
113
+
114
+ - **`ext__group_plan`** — the plan picker the participant fills in. Put it
115
+ wherever you like in the form.
116
+ - **`ext__group_discount`** — a manager-only field carrying the discount. It is
117
+ locked, written only by the plugin, and appears as a named line on the
118
+ invoice. Leave it alone; the plugin puts it back if it is removed.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ pip install -e '.[dev]'
124
+ pytest
125
+ ruff check .
126
+ ```
127
+
128
+ The tests here are unit tests over the money arithmetic and the code handling;
129
+ they do not need a database or Indico's pytest plugin.
130
+
131
+ ## How it hooks into Indico
132
+
133
+ Nothing in core is patched. See `docs/EXTENSION-POINTS.md` for the full map
134
+ with file-and-line references, and `docs/PLAN.md` for the design. The short
135
+ version:
136
+
137
+ - registration field types via `signals.core.get_fields`
138
+ - the registration lifecycle via `registration_created`, `registration_deleted`
139
+ and `registration_state_updated`
140
+ - `is_field_data_locked` to keep core out of the plugin's own field
141
+ - `registrant_list_items` for the Group column
142
+ - `before-render-registration-info` for the group panel
143
+ - the React field registry via `regformCustomFields`
144
+ - `get_template_customization_paths` to name the discount on the checkout page,
145
+ using `{% extends '~...' %}` so it inherits the core template rather than
146
+ forking it
147
+
148
+ ## Licence
149
+
150
+ MIT.
@@ -0,0 +1,15 @@
1
+ """Participant-run group registration for Indico.
2
+
3
+ A participant picks a group plan while registering, gets a code and a join
4
+ link, and shares them however they like. Everyone who joins pays that plan's
5
+ rate straight away. The group locks itself the moment the plan's seat count is
6
+ filled; if it never fills, the plugin reprices it at the reconciliation
7
+ deadline and reports what each member still owes.
8
+ """
9
+
10
+ from indico.util.i18n import make_bound_gettext
11
+
12
+
13
+ __version__ = '0.1.0'
14
+
15
+ _ = make_bound_gettext('group_registration')
@@ -0,0 +1,34 @@
1
+ from indico.core.plugins import IndicoPluginBlueprint
2
+
3
+ from indico_group_registration.controllers import display, management
4
+
5
+
6
+ blueprint = IndicoPluginBlueprint('group_registration', __name__, url_prefix='/event/<int:event_id>')
7
+
8
+ # -- participant -------------------------------------------------------------
9
+
10
+ blueprint.add_url_rule('/registrations/<int:reg_form_id>/group/join/<join_uuid>', 'join_link',
11
+ display.RHGroupJoinLink)
12
+ blueprint.add_url_rule('/registrations/<int:reg_form_id>/group/check-code', 'check_code',
13
+ display.RHCheckGroupCode)
14
+ blueprint.add_url_rule('/registrations/<int:reg_form_id>/group/leave', 'leave_group',
15
+ display.RHLeaveGroup, methods=('POST',))
16
+ blueprint.add_url_rule('/registrations/<int:reg_form_id>/group/new-link', 'regenerate_link',
17
+ display.RHRegenerateJoinLink, methods=('POST',))
18
+ blueprint.add_url_rule('/registrations/<int:reg_form_id>/group/plan', 'switch_plan',
19
+ display.RHSwitchPlan, methods=('POST',))
20
+
21
+ # -- management --------------------------------------------------------------
22
+
23
+ blueprint.add_url_rule('/manage/group-registration/', 'manage_overview',
24
+ management.RHGroupOverview)
25
+ blueprint.add_url_rule('/manage/registration/<int:reg_form_id>/groups/', 'manage_groups',
26
+ management.RHManageGroups)
27
+ blueprint.add_url_rule('/manage/registration/<int:reg_form_id>/groups/settings', 'manage_settings',
28
+ management.RHGroupSettings, methods=('GET', 'POST'))
29
+ blueprint.add_url_rule('/manage/registration/<int:reg_form_id>/groups/balances', 'manage_balances',
30
+ management.RHGroupBalances)
31
+ blueprint.add_url_rule('/manage/registration/<int:reg_form_id>/groups/<int:group_id>/dissolve', 'dissolve_group',
32
+ management.RHDissolveGroup, methods=('POST',))
33
+ blueprint.add_url_rule('/manage/registration/<int:reg_form_id>/groups/<int:group_id>/reconcile', 'reconcile_group',
34
+ management.RHReconcileGroup, methods=('POST',))
@@ -0,0 +1,298 @@
1
+ // The participant-facing plan picker.
2
+ //
3
+ // It writes a single object into the registration form:
4
+ // {mode: 'none' | 'create' | 'join', plan, name, code, accepted}
5
+ // The backend validates the same shape again -- this component exists to make
6
+ // the choice, and its consequences, legible before anyone submits.
7
+
8
+ import checkCodeURL from 'indico-url:plugin_group_registration.check_code';
9
+
10
+ import _ from 'lodash';
11
+ import PropTypes from 'prop-types';
12
+ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
13
+ import {useField} from 'react-final-form';
14
+ import {Checkbox, Form, Input, Loader, Message, Radio} from 'semantic-ui-react';
15
+
16
+ import {Param, Translate} from 'indico/react/i18n';
17
+ import {indicoAxios} from 'indico/utils/axios';
18
+
19
+ import './GroupPlanInput.module.scss';
20
+
21
+ const MODE_NONE = 'none';
22
+ const MODE_CREATE = 'create';
23
+ const MODE_JOIN = 'join';
24
+
25
+ const EMPTY = {mode: MODE_NONE, plan: null, name: '', code: '', accepted: false};
26
+
27
+ function formatMoney(amount, currency) {
28
+ try {
29
+ return new Intl.NumberFormat(document.documentElement.lang || 'en', {
30
+ style: 'currency',
31
+ currency,
32
+ }).format(amount);
33
+ } catch {
34
+ return `${amount} ${currency}`;
35
+ }
36
+ }
37
+
38
+ /** What one member pays under a plan, given the standard fee. */
39
+ function planPrice(plan, basePrice) {
40
+ if (!plan || !plan.type || !plan.value) {
41
+ return basePrice;
42
+ }
43
+ const discount = plan.type === 'percent' ? (basePrice * plan.value) / 100 : plan.value;
44
+ return Math.max(basePrice - Math.min(discount, basePrice), 0);
45
+ }
46
+
47
+ /** The code as it is shown: two halves, because people retype it. */
48
+ function normalizeCode(value) {
49
+ return (value || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
50
+ }
51
+
52
+ export default function GroupPlanInput({
53
+ htmlName,
54
+ disabled,
55
+ eventId,
56
+ regformId,
57
+ plans,
58
+ currency,
59
+ basePrice,
60
+ disclaimer,
61
+ allowEarlyPayment,
62
+ enabled,
63
+ }) {
64
+ const {input} = useField(htmlName, {allowNull: true});
65
+ const value = input.value || EMPTY;
66
+ const groupPlans = useMemo(() => plans.filter(p => p.size > 1), [plans]);
67
+
68
+ const [lookup, setLookup] = useState({state: 'idle'});
69
+ const lookupSeq = useRef(0);
70
+
71
+ const update = useCallback(
72
+ patch => {
73
+ input.onChange({...EMPTY, ...value, ...patch});
74
+ },
75
+ [input, value]
76
+ );
77
+
78
+ // -- code lookup ---------------------------------------------------------
79
+
80
+ const runLookup = useMemo(
81
+ () =>
82
+ _.debounce(async code => {
83
+ const seq = ++lookupSeq.current;
84
+ if (code.length < 4) {
85
+ setLookup({state: 'idle'});
86
+ return;
87
+ }
88
+ setLookup({state: 'loading'});
89
+ let response;
90
+ try {
91
+ response = await indicoAxios.get(checkCodeURL({event_id: eventId, reg_form_id: regformId}), {
92
+ params: {code},
93
+ });
94
+ } catch {
95
+ if (seq === lookupSeq.current) {
96
+ setLookup({state: 'error', error: Translate.string('Could not check that code.')});
97
+ }
98
+ return;
99
+ }
100
+ if (seq !== lookupSeq.current) {
101
+ // A newer keystroke already won.
102
+ return;
103
+ }
104
+ setLookup(
105
+ response.data.valid
106
+ ? {state: 'found', group: response.data}
107
+ : {state: 'error', error: response.data.error}
108
+ );
109
+ }, 400),
110
+ [eventId, regformId]
111
+ );
112
+
113
+ useEffect(() => {
114
+ if (value.mode === MODE_JOIN) {
115
+ runLookup(normalizeCode(value.code));
116
+ } else {
117
+ setLookup({state: 'idle'});
118
+ }
119
+ return () => runLookup.cancel();
120
+ }, [value.mode, value.code, runLookup]);
121
+
122
+ // A join link drops the code in the query string; pick it up once.
123
+ const prefilled = useRef(false);
124
+ useEffect(() => {
125
+ if (prefilled.current || disabled) {
126
+ return;
127
+ }
128
+ prefilled.current = true;
129
+ const code = new URLSearchParams(window.location.search).get('group_code');
130
+ if (code) {
131
+ input.onChange({...EMPTY, mode: MODE_JOIN, code: normalizeCode(code)});
132
+ }
133
+ // Only ever runs once, deliberately.
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, [disabled]);
136
+
137
+ if (!enabled || !groupPlans.length) {
138
+ return null;
139
+ }
140
+
141
+ const chosenPlan = groupPlans.find(p => p.id === value.plan) || null;
142
+ const needsAcceptance = value.mode === MODE_CREATE || value.mode === MODE_JOIN;
143
+
144
+ return (
145
+ <div styleName="group-plan" data-mode={value.mode}>
146
+ <Form.Group grouped>
147
+ <Radio
148
+ label={Translate.string('Register individually')}
149
+ checked={value.mode === MODE_NONE}
150
+ disabled={disabled}
151
+ onChange={() => input.onChange({...EMPTY})}
152
+ />
153
+ <Radio
154
+ label={Translate.string('Create a group and invite people')}
155
+ checked={value.mode === MODE_CREATE}
156
+ disabled={disabled}
157
+ onChange={() => update({mode: MODE_CREATE, code: '', accepted: false})}
158
+ />
159
+ <Radio
160
+ label={Translate.string('Join a group with a code')}
161
+ checked={value.mode === MODE_JOIN}
162
+ disabled={disabled}
163
+ onChange={() => update({mode: MODE_JOIN, plan: null, name: '', accepted: false})}
164
+ />
165
+ </Form.Group>
166
+
167
+ {value.mode === MODE_CREATE && (
168
+ <div styleName="panel">
169
+ <div styleName="plans">
170
+ {groupPlans.map(plan => (
171
+ <label key={plan.id} styleName="plan" data-selected={plan.id === value.plan}>
172
+ <Radio
173
+ checked={plan.id === value.plan}
174
+ disabled={disabled}
175
+ onChange={() => update({plan: plan.id, accepted: false})}
176
+ />
177
+ <span styleName="plan-label">{plan.label}</span>
178
+ <span styleName="plan-seats">
179
+ <Translate>
180
+ <Param name="size" value={plan.size} /> members
181
+ </Translate>
182
+ </span>
183
+ <span styleName="plan-price">
184
+ {formatMoney(planPrice(plan, basePrice), currency)}
185
+ <small>
186
+ <Translate>each</Translate>
187
+ </small>
188
+ </span>
189
+ </label>
190
+ ))}
191
+ </div>
192
+
193
+ <Form.Field required>
194
+ <label htmlFor="group-name-input">
195
+ <Translate>Group name</Translate>
196
+ </label>
197
+ <Input
198
+ id="group-name-input"
199
+ value={value.name}
200
+ disabled={disabled}
201
+ maxLength={80}
202
+ placeholder={Translate.string('e.g. your department or company')}
203
+ onChange={(evt, {value: name}) => update({name})}
204
+ />
205
+ </Form.Field>
206
+
207
+ {chosenPlan && (
208
+ <Message info>
209
+ <Translate>
210
+ You will get a code and a link to share. Your group confirms itself as soon as{' '}
211
+ <Param name="size" value={chosenPlan.size} /> people have joined, and the rate is
212
+ then final.
213
+ </Translate>
214
+ </Message>
215
+ )}
216
+ </div>
217
+ )}
218
+
219
+ {value.mode === MODE_JOIN && (
220
+ <div styleName="panel">
221
+ <Form.Field required>
222
+ <label htmlFor="group-code-input">
223
+ <Translate>Group code</Translate>
224
+ </label>
225
+ <Input
226
+ id="group-code-input"
227
+ value={value.code}
228
+ disabled={disabled}
229
+ maxLength={9}
230
+ placeholder="ABCD-2345"
231
+ onChange={(evt, {value: code}) => update({code: normalizeCode(code), accepted: false})}
232
+ icon={lookup.state === 'loading' ? <Loader active inline size="tiny" /> : undefined}
233
+ />
234
+ </Form.Field>
235
+
236
+ {lookup.state === 'error' && <Message negative>{lookup.error}</Message>}
237
+ {lookup.state === 'found' && (
238
+ <Message positive>
239
+ <Message.Header>{lookup.group.name}</Message.Header>
240
+ <p>
241
+ <Translate>
242
+ <Param name="members" value={lookup.group.members} /> of{' '}
243
+ <Param name="target" value={lookup.group.target} /> members ·{' '}
244
+ <Param
245
+ name="price"
246
+ value={formatMoney(planPrice(lookup.group.plan, basePrice), currency)}
247
+ />{' '}
248
+ for you
249
+ </Translate>
250
+ </p>
251
+ </Message>
252
+ )}
253
+ </div>
254
+ )}
255
+
256
+ {needsAcceptance && (
257
+ <div styleName="disclaimer">
258
+ {disclaimer && <p>{disclaimer}</p>}
259
+ {!allowEarlyPayment && (
260
+ <p>
261
+ <Translate>
262
+ You will not be able to pay until your group is complete.
263
+ </Translate>
264
+ </p>
265
+ )}
266
+ <Checkbox
267
+ label={Translate.string('I understand and accept these conditions')}
268
+ checked={!!value.accepted}
269
+ disabled={disabled}
270
+ onChange={(evt, {checked}) => update({accepted: checked})}
271
+ />
272
+ </div>
273
+ )}
274
+ </div>
275
+ );
276
+ }
277
+
278
+ GroupPlanInput.propTypes = {
279
+ htmlName: PropTypes.string.isRequired,
280
+ disabled: PropTypes.bool,
281
+ eventId: PropTypes.number.isRequired,
282
+ regformId: PropTypes.number.isRequired,
283
+ plans: PropTypes.array,
284
+ currency: PropTypes.string.isRequired,
285
+ basePrice: PropTypes.number,
286
+ disclaimer: PropTypes.string,
287
+ allowEarlyPayment: PropTypes.bool,
288
+ enabled: PropTypes.bool,
289
+ };
290
+
291
+ GroupPlanInput.defaultProps = {
292
+ disabled: false,
293
+ plans: [],
294
+ basePrice: 0,
295
+ disclaimer: '',
296
+ allowEarlyPayment: true,
297
+ enabled: false,
298
+ };