• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / mender-server / 10423

11 Nov 2025 04:53PM UTC coverage: 74.435% (-0.1%) from 74.562%
10423

push

gitlab-ci

web-flow
Merge pull request #1071 from mendersoftware/dependabot/npm_and_yarn/frontend/main/development-dependencies-92732187be

3868 of 5393 branches covered (71.72%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 2 files covered. (100.0%)

176 existing lines in 95 files now uncovered.

64605 of 86597 relevant lines covered (74.6%)

7.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.98
/frontend/src/js/components/subscription/SubscriptionPage.tsx
1
// Copyright 2025 Northern.tech AS
2✔
2
//
2✔
3
//    Licensed under the Apache License, Version 2.0 (the "License");
2✔
4
//    you may not use this file except in compliance with the License.
2✔
5
//    You may obtain a copy of the License at
2✔
6
//
2✔
7
//        http://www.apache.org/licenses/LICENSE-2.0
2✔
8
//
2✔
9
//    Unless required by applicable law or agreed to in writing, software
2✔
10
//    distributed under the License is distributed on an "AS IS" BASIS,
2✔
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2✔
12
//    See the License for the specific language governing permissions and
2✔
13
//    limitations under the License.
2✔
14
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
2✔
15
import { Controller, useFormContext } from 'react-hook-form';
2✔
16
import { useSelector } from 'react-redux';
2✔
17

2✔
18
import { Alert, Button, FormControl, FormControlLabel, FormHelperText, Radio, RadioGroup, Typography, outlinedInputClasses } from '@mui/material';
2✔
19
import { makeStyles } from 'tss-react/mui';
2✔
20

2✔
21
import { SupportLink } from '@northern.tech/common-ui/SupportLink';
2✔
22
import { AddonSelect } from '@northern.tech/common-ui/forms/AddonSelect';
2✔
23
import Form from '@northern.tech/common-ui/forms/Form';
2✔
24
import TextInput from '@northern.tech/common-ui/forms/TextInput';
2✔
25
import { ADDONS, Addon, AddonId, AvailableAddon, AvailablePlans, PLANS, TIMEOUTS } from '@northern.tech/store/constants';
2✔
26
import { getDeviceLimit, getOrganization, getStripeKey } from '@northern.tech/store/selectors';
2✔
27
import { useAppDispatch } from '@northern.tech/store/store';
2✔
28
import { getBillingPreview, getCurrentCard, getUserBilling, getUserSubscription, requestPlanChange } from '@northern.tech/store/thunks';
2✔
29
import { useDebounce } from '@northern.tech/utils/debouncehook';
2✔
30
import { Elements } from '@stripe/react-stripe-js';
2✔
31

2✔
32
import { SubscriptionAddon } from './SubscriptionAddon';
2✔
33
import { SubscriptionDrawer } from './SubscriptionDrawer';
2✔
34
import { SubscriptionSummary } from './SubscriptionSummary';
2✔
35

2✔
36
let stripePromise = null;
7✔
37
export type PreviewPrice = { addons: { [key in AvailableAddon]: number }; plan: number; total: number };
2✔
38

2✔
39
const useStyles = makeStyles()(() => ({
7✔
40
  messageInput: {
2✔
41
    [`.${outlinedInputClasses.notchedOutline} > legend`]: {
2✔
42
      maxWidth: '100%'
2✔
43
    }
2✔
44
  }
2✔
45
}));
2✔
46

2✔
47
const DIVISIBILITY_STEP = 50;
7✔
48
const enterpriseDeviceCount = PLANS.enterprise.minimalDeviceCount;
7✔
49
const planOrder = Object.keys(PLANS);
7✔
50
const enterpriseRequestPlaceholder = 'Tell us a little about your requirements and device fleet size, so we can provide you with an accurate quote';
7✔
51
export type SelectedAddons = { [key in AvailableAddon]: boolean };
2✔
52

2✔
53
const contactReasons = {
7✔
54
  reduceLimit: {
2✔
55
    id: 'reduceLimit',
2✔
56
    alert: (
2✔
57
      <div>
2✔
58
        If you want to reduce your device limit, please contact <SupportLink variant="email" />.
2✔
59
      </div>
2✔
60
    )
2✔
61
  },
2✔
62
  overLimit: {
2✔
63
    id: 'overLimit',
2✔
64
    alert: (
2✔
65
      <div>
2✔
66
        For over {enterpriseDeviceCount} devices, please contact <SupportLink variant="email" /> for pricing.
2✔
67
      </div>
2✔
68
    )
2✔
69
  }
2✔
70
} as const;
2✔
71

2✔
72
const addOnsToString = (addons: Addon[] = []) =>
7✔
73
  addons
3✔
74
    .reduce((accu: string[], item) => {
2✔
75
      if (item.enabled) {
2!
76
        accu.push(item.name);
2✔
77
      }
2✔
78
      return accu;
2✔
79
    }, [])
2✔
80
    .join(', ');
2✔
81

2✔
82
interface ContactReasonProps {
2✔
83
  reason: keyof typeof contactReasons;
2✔
84
}
2✔
85
const ContactReasonAlert = ({ reason }: ContactReasonProps) => (
7✔
86
  <Alert severity="info" className="margin-bottom-x-small margin-top-x-small">
4✔
87
    {contactReasons[reason].alert}
2✔
88
  </Alert>
2✔
89
);
2✔
90

2✔
91
interface FormData {
2✔
92
  enterpriseMessage: string;
2✔
93
  limit: number;
2✔
94
  selectedAddons: AddonId[];
2✔
95
  selectedPlan: string;
2✔
96
}
2✔
97

2✔
98
interface SubscriptionFormProps {
2✔
99
  onShowUpgradeDrawer: () => void;
2✔
100
  onUpdateFormValues: (values: Partial<FormData>) => void;
2✔
101
  previewPrice: PreviewPrice;
2✔
102
  setOrder: (order: any) => void;
2✔
103
  setPreviewPrice: (price: PreviewPrice) => void;
2✔
104
  specialHandling: boolean;
2✔
105
}
2✔
106

2✔
107
const SubscriptionForm = ({ onShowUpgradeDrawer, onUpdateFormValues, previewPrice, setPreviewPrice, setOrder, specialHandling }: SubscriptionFormProps) => {
7✔
108
  const { setValue, watch } = useFormContext<FormData>();
77✔
109
  const currentDeviceLimit = useSelector(getDeviceLimit);
77✔
110
  const org = useSelector(getOrganization);
77✔
111
  const dispatch = useAppDispatch();
77✔
112

2✔
113
  const { addons: orgAddOns = [], plan: currentPlan = PLANS.os.id as AvailablePlans, trial: isTrial = true, id: orgId } = org;
77✔
114
  const isOrgLoaded = !!orgId;
77✔
115
  const plan = Object.values(PLANS).find(plan => plan.id === (isTrial ? PLANS.os.id : currentPlan)) || PLANS.os;
77!
116
  const enabledAddons = useMemo(() => orgAddOns.filter(addon => addon.enabled), [orgAddOns]);
77✔
117
  const currentPlanId = plan.id;
77✔
118

2✔
119
  const selectedPlan = PLANS[watch('selectedPlan')] || PLANS.os;
77!
120
  const selectedAddons = watch('selectedAddons');
77✔
121
  const limit = Number(watch('limit'));
77✔
122
  const enterpriseMessage = watch('enterpriseMessage');
77✔
123
  const debouncedLimit = useDebounce(limit, TIMEOUTS.debounceDefault);
77✔
124

2✔
125
  const [contactReason, setContactReason] = useState<ContactReasonProps['reason'] | ''>('');
77✔
126
  const [inputHelperText, setInputHelperText] = useState<string>('');
77✔
127
  const [isPreviewLoading, setIsPreviewLoading] = useState(false);
77✔
128

2✔
129
  const selectedAddonsLength = selectedAddons.length;
77✔
130
  const isNew =
2✔
131
    debouncedLimit >= currentDeviceLimit &&
77!
132
    (currentPlanId !== selectedPlan.id || enabledAddons.length < selectedAddonsLength || debouncedLimit > currentDeviceLimit || isTrial);
2✔
133
  const couldGetPreview = isOrgLoaded && !specialHandling && selectedPlan.id !== PLANS.enterprise.id;
77✔
134

2✔
135
  useEffect(() => {
77✔
136
    onUpdateFormValues({
36✔
137
      selectedPlan: selectedPlan.id,
2✔
138
      selectedAddons,
2✔
139
      limit,
2✔
140
      enterpriseMessage
2✔
141
    });
2✔
142
  }, [selectedPlan.id, selectedAddons, limit, enterpriseMessage, onUpdateFormValues]);
2✔
143

2✔
144
  useEffect(() => {
77✔
145
    const eligibleAddons = selectedAddons.filter(addonId => ADDONS[addonId].eligible.includes(selectedPlan.id));
6✔
146
    setValue('selectedAddons', eligibleAddons);
6✔
147
    // Only depend on selectedPlan.id, not selectedAddons - we accept the risk of stale addons here to not run this repeatedly while still aligning addons w/ the selected plan
2✔
148
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
149
  }, [selectedPlan.id, setValue]);
2✔
150

2✔
151
  useEffect(() => {
77✔
152
    if (specialHandling) return;
8!
153

2✔
154
    if (debouncedLimit >= enterpriseDeviceCount) {
8✔
155
      setContactReason(contactReasons.overLimit.id);
3✔
156
      setInputHelperText(`The maximum you can set is ${enterpriseDeviceCount} devices.`);
3✔
157
    } else if (debouncedLimit < currentDeviceLimit) {
7✔
158
      setContactReason(contactReasons.reduceLimit.id);
3✔
159
      setInputHelperText(`Your current device limit is ${currentDeviceLimit}.`);
3✔
160
    } else {
2✔
161
      setContactReason('');
6✔
162
      setInputHelperText(`The minimum limit for ${selectedPlan.name} is ${selectedPlan.minimalDeviceCount}`);
6✔
163
    }
2✔
164
    if (debouncedLimit < selectedPlan.minimalDeviceCount) {
8✔
165
      setValue('limit', selectedPlan.minimalDeviceCount);
4✔
166
    }
2✔
167
  }, [currentDeviceLimit, debouncedLimit, selectedPlan.minimalDeviceCount, selectedPlan.name, setValue, specialHandling]);
2✔
168

2✔
169
  useEffect(() => {
77✔
170
    if (!couldGetPreview) {
13✔
171
      return;
5✔
172
    }
2✔
173
    const effectiveLimit = Math.min(Math.max(debouncedLimit, selectedPlan.minimalDeviceCount), enterpriseDeviceCount);
10✔
174
    if (!effectiveLimit || effectiveLimit % DIVISIBILITY_STEP !== 0) {
10!
175
      return;
2✔
176
    }
2✔
177

2✔
178
    const addons = selectedAddons.filter(addonId => ADDONS[addonId]?.eligible.includes(selectedPlan.id)).map(key => ({ name: key }));
10✔
179
    setIsPreviewLoading(true);
10✔
180
    const order = {
10✔
181
      preview_mode: 'recurring',
2✔
182
      plan: selectedPlan.id,
2✔
183
      products: [{ name: 'mender_standard', quantity: effectiveLimit, addons }]
2✔
184
    };
2✔
185
    setOrder({ plan: order.plan, products: order.products });
10✔
186

2✔
187
    dispatch(getBillingPreview(order))
10✔
188
      .unwrap()
2✔
189
      .then(setPreviewPrice)
2✔
190
      .finally(() => setIsPreviewLoading(false));
10✔
191
  }, [couldGetPreview, debouncedLimit, dispatch, selectedPlan.id, selectedPlan.minimalDeviceCount, selectedAddons, setOrder, setPreviewPrice]);
2✔
192

2✔
193
  const handleDeviceLimitBlur = (event: ChangeEvent<HTMLInputElement>) => {
77✔
194
    const value = Number(event.target.value);
3✔
195
    const snappedValue = Math.ceil(value / DIVISIBILITY_STEP) * DIVISIBILITY_STEP;
3✔
196
    const effectiveLimit = Math.min(Math.max(snappedValue, selectedPlan.minimalDeviceCount), enterpriseDeviceCount);
3✔
197
    if (value !== effectiveLimit) {
3!
198
      setValue('limit', effectiveLimit);
3✔
199
    }
2✔
200
  };
2✔
201

2✔
202
  const onEnterpriseRequest = ({ message }: { message: string }) =>
77✔
203
    dispatch(
3✔
204
      requestPlanChange({
2✔
205
        tenantId: org.id,
2✔
206
        content: {
2✔
207
          current_plan: PLANS[currentPlan || PLANS.os.id].name,
2!
208
          requested_plan: selectedPlan.name,
2✔
209
          current_addons: addOnsToString(org.addons) || '-',
2✔
210
          requested_addons: selectedAddons.join(', ') || addOnsToString(org.addons) || '-',
2!
211
          user_message: message
2✔
212
        }
2✔
213
      })
2✔
214
    )
2✔
215
      .unwrap()
2✔
216
      .then(() => setValue('enterpriseMessage', defaultValues.enterpriseMessage));
3✔
217

2✔
218
  const isAddonDisabled = (addon: Addon) =>
77✔
219
    (!isTrial && !!enabledAddons.find(enabled => enabled.name === addon.id)) || !addon.eligible.includes(selectedPlan.id);
86✔
220

2✔
221
  const { classes } = useStyles();
77✔
222

2✔
223
  return (
77✔
224
    <div className="flexbox">
2✔
225
      <div style={{ maxWidth: '550px' }}>
2✔
226
        <Typography className="margin-top" variant="subtitle1">
2✔
227
          1. Choose a plan
2✔
228
        </Typography>
2✔
229
        <Controller
2✔
230
          name="selectedPlan"
2✔
231
          render={({ field: { value, onChange } }) => (
2✔
232
            <FormControl component="fieldset">
77✔
233
              <RadioGroup
2✔
234
                row
2✔
235
                aria-labelledby="plan-selection"
2✔
236
                name="plan-selection-radio-group"
2✔
237
                value={value || ''}
2!
238
                onChange={(_, newValue) => onChange(newValue)}
4✔
239
              >
2✔
240
                {Object.values(PLANS).map((plan, index) => (
2✔
241
                  <FormControlLabel
227✔
242
                    key={plan.id}
2✔
243
                    disabled={!isTrial && planOrder.indexOf(currentPlan) > index && !specialHandling}
2!
244
                    value={plan.id}
2✔
245
                    control={<Radio />}
2✔
246
                    label={plan.name}
2✔
247
                  />
2✔
248
                ))}
2✔
249
              </RadioGroup>
2✔
250
            </FormControl>
2✔
251
          )}
2✔
252
        />
2✔
253
        <Typography variant="body2" style={{ minHeight: '56px' }}>
2✔
254
          {selectedPlan.description}
2✔
255
        </Typography>
2✔
256
        {selectedPlan.id !== PLANS.enterprise.id && !specialHandling && (
2✔
257
          <>
2✔
258
            <Typography variant="subtitle1" className="margin-top margin-bottom-x-small">
2✔
259
              2. Set a device limit
2✔
260
            </Typography>
2✔
261
            <TextInput
2✔
262
              id="limit"
2✔
263
              label="Number of devices"
2✔
264
              type="number"
2✔
265
              InputProps={{
2✔
266
                inputProps: { min: Math.max(currentDeviceLimit, selectedPlan.minimalDeviceCount), step: DIVISIBILITY_STEP },
2✔
267
                size: 'small',
2✔
268
                onBlur: handleDeviceLimitBlur
2✔
269
              }}
2✔
270
              width="100%"
2✔
271
            />
2✔
272
            <FormHelperText className="margin-left-small">{inputHelperText}</FormHelperText>
2✔
273
          </>
2✔
274
        )}
2✔
275
        {contactReason && selectedPlan.id !== PLANS.enterprise.id && <ContactReasonAlert reason={contactReason} />}
2✔
276
        <Typography variant="subtitle1" className="margin-top">
2✔
277
          {selectedPlan.id === PLANS.enterprise.id || specialHandling ? 2 : 3}. Choose Add-ons
2✔
278
        </Typography>
2✔
279
        <div className="margin-top-x-small">
2✔
280
          {selectedPlan.id === PLANS.enterprise.id || specialHandling ? (
2✔
281
            <AddonSelect name="selectedAddons" />
2✔
282
          ) : (
2✔
283
            <Controller
2✔
284
              name="selectedAddons"
2✔
285
              render={({ field: { value = [], onChange } }) =>
2✔
286
                Object.values(ADDONS).map(addon => (
30✔
287
                  <SubscriptionAddon
86✔
288
                    selectedPlan={selectedPlan}
2✔
289
                    key={addon.id}
2✔
290
                    addon={addon}
2✔
291
                    disabled={isAddonDisabled(addon) && !specialHandling}
2✔
292
                    checked={value.includes(addon.id)}
2✔
293
                    onChange={(addonId, selected) => {
2✔
294
                      if (selected) {
3!
295
                        return onChange([...value, addonId]);
3✔
296
                      }
2✔
297
                      onChange(value.filter(id => id !== addonId));
2✔
298
                    }}
2✔
299
                  />
2✔
300
                ))
2✔
301
              }
2✔
302
            />
2✔
303
          )}
2✔
304
        </div>
2✔
305
        {enabledAddons.length > 0 && !isTrial && !specialHandling && selectedPlan.id !== PLANS.enterprise.id && (
2!
306
          <Typography variant="body2" className="margin-bottom">
2✔
307
            To remove active Add-ons from your plan, please contact <SupportLink variant="email" />
2✔
308
          </Typography>
2✔
309
        )}
2✔
310
        {(selectedPlan.id === PLANS.enterprise.id || specialHandling) && (
2✔
311
          <>
2✔
312
            <Typography variant="subtitle1" className="margin-top">
2✔
313
              3. Request a quote
2✔
314
            </Typography>
2✔
315
            <div className="margin-top-small">
2✔
316
              <TextInput
2✔
317
                id="enterpriseMessage"
2✔
318
                label="Your message"
2✔
319
                InputLabelProps={{ shrink: true }}
2✔
320
                InputProps={{ className: classes.messageInput, multiline: true, placeholder: enterpriseRequestPlaceholder }}
2✔
321
                width="100%"
2✔
322
              />
2✔
323
            </div>
2✔
324
            <Button
2✔
325
              className="margin-top"
2✔
326
              color="secondary"
2✔
327
              disabled={!enterpriseMessage}
2✔
328
              onClick={() => onEnterpriseRequest({ message: enterpriseMessage })}
3✔
329
              variant="contained"
2✔
330
            >
2✔
331
              Submit request
2✔
332
            </Button>
2✔
333
          </>
2✔
334
        )}
2✔
335
      </div>
2✔
336
      <div>
2✔
337
        {selectedPlan.id !== PLANS.enterprise.id && previewPrice && !specialHandling && (
2✔
338
          <div className="margin-top margin-left-x-large">
2✔
339
            <SubscriptionSummary
2✔
340
              isPreviewLoading={isPreviewLoading}
2✔
341
              plan={selectedPlan}
2✔
342
              addons={selectedAddons}
2✔
343
              deviceLimit={specialHandling ? limit : Math.min(Math.max(debouncedLimit, selectedPlan.minimalDeviceCount), enterpriseDeviceCount)}
2!
344
              title="Your subscription:"
2✔
345
              isNew={isNew}
2✔
346
              previewPrice={previewPrice}
2✔
347
              onAction={onShowUpgradeDrawer}
2✔
348
            />
2✔
349
          </div>
2✔
350
        )}
2✔
351
      </div>
2✔
352
    </div>
2✔
353
  );
2✔
354
};
2✔
355

2✔
356
const defaultValues = {
7✔
357
  selectedPlan: PLANS.os.id,
2✔
358
  selectedAddons: [],
2✔
359
  limit: 50,
2✔
360
  enterpriseMessage: ''
2✔
361
};
2✔
362

2✔
363
export const SubscriptionPage = () => {
7✔
364
  const currentDeviceLimit = useSelector(getDeviceLimit);
43✔
365
  const stripeAPIKey = useSelector(getStripeKey);
43✔
366
  const org = useSelector(getOrganization);
43✔
367
  const dispatch = useAppDispatch();
43✔
368

2✔
369
  const { addons: orgAddOns = [], plan: currentPlan = PLANS.os.id as AvailablePlans, trial: isTrial = true } = org;
43✔
370
  const plan = Object.values(PLANS).find(plan => plan.id === (isTrial ? PLANS.os.id : currentPlan)) || PLANS.os;
43!
371
  const enabledAddons = useMemo(() => orgAddOns.filter(addon => addon.enabled), [orgAddOns]);
43✔
372

2✔
373
  const [showUpgradeDrawer, setShowUpgradeDrawer] = useState(false);
43✔
374
  const [loadingFinished, setLoadingFinished] = useState(!stripeAPIKey);
43✔
375
  const [currentFormValues, setCurrentFormValues] = useState<Partial<FormData>>({});
43✔
376
  const [previewPrice, setPreviewPrice] = useState<PreviewPrice>();
43✔
377
  const [order, setOrder] = useState();
43✔
378
  const [specialHandling, setSpecialHandling] = useState(false);
43✔
379

2✔
380
  const initialValues: FormData = {
43✔
381
    selectedPlan: plan.id,
2✔
UNCOV
382
    selectedAddons: enabledAddons.filter(({ enabled }) => enabled && !isTrial).map(({ name }) => name),
2!
383
    limit: Math.max(currentDeviceLimit || 0, plan.minimalDeviceCount),
2!
384
    enterpriseMessage: ''
2✔
385
  };
2✔
386

2✔
387
  //Loading stripe Component
2✔
388
  useEffect(() => {
43✔
389
    // Make sure to call `loadStripe` outside of a component's render to avoid recreating
2✔
390
    // the `Stripe` object on every render - but don't initialize twice.
2✔
391
    if (!stripePromise) {
4!
392
      import(/* webpackChunkName: "stripe" */ '@stripe/stripe-js').then(({ loadStripe }) => {
4✔
393
        if (stripeAPIKey) {
4!
394
          stripePromise = loadStripe(stripeAPIKey).finally(() => setLoadingFinished(true));
2✔
395
        }
2✔
396
      });
2✔
397
    } else {
2✔
398
      const notStripePromise = new Promise(resolve => setTimeout(resolve, TIMEOUTS.debounceDefault));
2✔
399
      Promise.race([stripePromise, notStripePromise]).then(result => setLoadingFinished(result !== notStripePromise));
2✔
400
    }
2✔
401
  }, [stripeAPIKey]);
2✔
402

2✔
403
  //Fetch Billing profile & subscription
2✔
404
  useEffect(() => {
43✔
405
    if (isTrial) {
4✔
406
      return;
3✔
407
    }
2✔
408
    dispatch(getUserBilling());
3✔
409
    dispatch(getCurrentCard());
3✔
410
    //We need to handle special enterprise-like agreements
2✔
411
    dispatch(getUserSubscription())
3✔
412
      .unwrap()
2✔
413
      .catch(error => {
2✔
414
        if (!isTrial && error.message && error.message.includes('404')) {
2!
415
          setSpecialHandling(true);
2✔
416
        }
2✔
417
      });
2✔
418
  }, [isTrial, dispatch]);
2✔
419

2✔
420
  // Form submission is handled by individual components within the form
2✔
421
  const onSubmit = (data: FormData) => console.log(JSON.stringify(data));
43✔
422

2✔
423
  return (
43✔
424
    <div className="padding-bottom-x-large">
2✔
425
      <Typography variant="h4" className="margin-bottom-large">
2✔
426
        Upgrade your subscription
2✔
427
      </Typography>
2✔
428
      <Typography className="margin-bottom-small" variant="body2">
2✔
429
        Current plan: {isTrial ? ' Free trial' : PLANS[currentPlan].name}
2✔
430
      </Typography>
2✔
431
      <Typography variant="body1">
2✔
432
        Upgrade your plan or purchase an Add-on package to connect more devices, access more features and advanced support. <br />
2✔
433
        See the full details of plans and features at{' '}
2✔
434
        <a href="https://mender.io/plans/pricing" target="_blank" rel="noopener noreferrer">
2✔
435
          mender.io/plans/pricing
2✔
436
        </a>
2✔
437
      </Typography>
2✔
438

2✔
439
      <Form initialValues={initialValues} defaultValues={defaultValues} onSubmit={onSubmit} autocomplete="off">
2✔
440
        <SubscriptionForm
2✔
441
          onShowUpgradeDrawer={setShowUpgradeDrawer}
2✔
442
          onUpdateFormValues={setCurrentFormValues}
2✔
443
          setOrder={setOrder}
2✔
444
          setPreviewPrice={setPreviewPrice}
2✔
445
          previewPrice={previewPrice}
2✔
446
          specialHandling={specialHandling}
2✔
447
        />
2✔
448
      </Form>
2✔
449

2✔
450
      {loadingFinished && showUpgradeDrawer && (
2!
451
        <Elements stripe={stripePromise}>
2✔
452
          <SubscriptionDrawer
2✔
453
            order={order}
2✔
454
            isTrial={isTrial}
2✔
455
            previewPrice={previewPrice}
2✔
456
            organization={org}
2✔
457
            plan={PLANS[currentFormValues.selectedPlan || plan.id]}
2!
458
            addons={currentFormValues.selectedAddons || initialValues.selectedAddons}
2!
459
            onClose={() => setShowUpgradeDrawer(false)}
2✔
460
            currentPlanId={plan.id}
2✔
461
          />
2✔
462
        </Elements>
2✔
463
      )}
2✔
464
    </div>
2✔
465
  );
2✔
466
};
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc