1 import type { ChangeEvent } from 'react';
2 import { useRef } from 'react';
4 import { c, msgid } from 'ttag';
6 import { Checkbox, Info } from '@proton/components';
7 import Field from '@proton/components/components/container/Field';
8 import Row from '@proton/components/components/container/Row';
9 import Icon from '@proton/components/components/icon/Icon';
10 import Label from '@proton/components/components/label/Label';
11 import Option from '@proton/components/components/option/Option';
12 import SelectTwo from '@proton/components/components/selectTwo/SelectTwo';
13 import type { SelectChangeEvent } from '@proton/components/components/selectTwo/select';
14 import { useNow } from '@proton/components/hooks/useNow';
15 import { SECOND } from '@proton/shared/lib/constants';
17 import { type CountryOptions, getLocalizedCountryByAbbr } from '../../../helpers/countries';
18 import { ButtonNumberInput } from './ButtonNumberInput';
19 import { CountryFlagAndName } from './CountryFlagAndName';
20 import type { DeletedDedicatedIp } from './DeletedDedicatedIp';
21 import type { GatewayDto } from './GatewayDto';
22 import type { GatewayLocation } from './GatewayLocation';
23 import { getLocationDisplayName, getLocationFromId, getLocationId } from './helpers';
26 singleServer: boolean;
27 locations: readonly GatewayLocation[];
31 deletedDedicatedIPs?: DeletedDedicatedIp[];
32 countryOptions: CountryOptions;
35 onUpdateCheckedLocations: (checkedLocations: GatewayLocation[]) => void;
36 changeModel: (diff: Partial<GatewayDto>) => void;
39 export const GatewayCountrySelection = ({
49 onUpdateCheckedLocations,
52 const recentlyUsedServersRef = useRef<GatewayLocation[]>([]);
53 const remainingCount = ownedCount - usedCount;
54 const availableCount = Math.max(0, remainingCount - addedCount - (deletedDedicatedIPs?.length || 0));
55 const unassignedAvailableCount = deletedDedicatedIPs?.length || 0;
56 const totalCountExceeded = addedCount >= remainingCount - (deletedDedicatedIPs?.length || 0);
57 const now = useNow(10 * SECOND);
59 const handleUnassigningLocationChange = ({ value }: SelectChangeEvent<string>) =>
60 changeModel({ location: getLocationFromId(value) });
62 const handleUnassigningLocationChecked = (event: ChangeEvent<HTMLInputElement>, location: GatewayLocation) => {
63 const unassignedIpQuantities = {
64 ...model.unassignedIpQuantities,
67 const locationId = getLocationId(location);
69 if (!unassignedIpQuantities[locationId]) {
70 unassignedIpQuantities[locationId] = 0;
73 unassignedIpQuantities[locationId] += event.target.checked ? 1 : -1;
75 const mainLocation = getLocationFromId(
76 Object.keys(unassignedIpQuantities).reduce((previous, locationId) =>
77 (unassignedIpQuantities[locationId] || 0) > (unassignedIpQuantities[previous] || 0)
83 if (mainLocation !== model.location) {
84 changeModel({ location: mainLocation });
87 const locationString = JSON.stringify(location);
88 if (event.target.checked) {
89 // Add/store location if it's not already present
90 if (!recentlyUsedServersRef.current.some((ip: GatewayLocation) => JSON.stringify(ip) === locationString)) {
91 recentlyUsedServersRef.current = [...recentlyUsedServersRef.current, location];
94 // Remove location if unchecked
95 recentlyUsedServersRef.current = recentlyUsedServersRef.current.filter(
96 (ip: GatewayLocation) => JSON.stringify(ip) !== locationString
100 onUpdateCheckedLocations(recentlyUsedServersRef.current);
102 return changeModel({ unassignedIpQuantities });
105 const handleQuantityChange = (newQuantity: number, locationId: string) => {
108 [locationId]: newQuantity,
111 const mainLocation = getLocationFromId(
112 Object.keys(quantities).reduce((previous, locationId) =>
113 (quantities[locationId] || 0) > (quantities[previous] || 0) ? locationId : previous
117 if (mainLocation !== model.location) {
118 changeModel({ location: mainLocation });
121 if (newQuantity === 0) {
122 delete quantities[locationId];
125 changeModel({ quantities });
128 // Helper function to check if a location from recently used servers is already checked
129 const isLocationChecked = (location: GatewayLocation) => {
130 return model?.checkedLocations?.some((ip) => JSON.stringify(ip) === JSON.stringify(location));
133 return singleServer ? (
135 <Label htmlFor="domain">{c('Label').t`Country`}</Label>
137 <SelectTwo value={getLocationId(model.location)} onChange={handleUnassigningLocationChange}>
138 {locations.map((location) => {
139 const country = getLocalizedCountryByAbbr(location.Country, countryOptions) || location.Country;
140 const title = getLocationDisplayName(location, countryOptions);
142 <Option key={getLocationId(location)} value={title} title={title}>
143 <CountryFlagAndName countryCode={location.Country} countryName={country} />
152 {unassignedAvailableCount !== 0 && (
154 <h4 className="text-bold mb-1" style={{ marginTop: 0 }}>
155 {c('Info').t`Select recently used servers`}{' '}
160 <b>{c('Info').t`Recently used servers:`}</b>{' '}
162 .t`When you remove a server from a Gateway, it enters a 10-day deactivation period. This server can be added to a new Gateway, but its country cannot be changed.`}
167 <p className="mb-5 color-weak" style={{ marginTop: 0 }}>
169 msgid`You have ${unassignedAvailableCount} recently used servers available. To allocate these servers to another country, you'll have to wait until deactivation is complete.`,
170 `You have ${unassignedAvailableCount} recently used servers available. To allocate these servers to another country, you'll have to wait until deactivation is complete.`,
171 unassignedAvailableCount
174 {deletedDedicatedIPs?.map((deletedDedicatedIp) => {
175 const availableAgainAfterSeconds =
176 deletedDedicatedIp.AvailableAgainAfter - now.getTime() / 1000;
177 const availableAgainAfterHours = Math.ceil(availableAgainAfterSeconds / 3600);
178 const availableAgainAfterDays = Math.ceil(availableAgainAfterSeconds / 3600 / 24);
183 checked={isLocationChecked(deletedDedicatedIp.Location)}
185 handleUnassigningLocationChecked(e, deletedDedicatedIp.Location)
189 countryCode={deletedDedicatedIp.Location.Country}
190 countryName={getLocationDisplayName(
191 deletedDedicatedIp.Location,
196 className="color-weak"
197 style={{ marginLeft: '60px', marginTop: 0, marginBottom: 0, fontSize: '0.9em' }}
199 {availableAgainAfterDays > 1
200 ? c('Info').ngettext(
201 msgid`or assign to any country in ${availableAgainAfterDays} day`,
202 `or assign to any country in ${availableAgainAfterDays} days`,
203 availableAgainAfterDays
205 : c('Info').ngettext(
206 msgid`or assign to any country in ${availableAgainAfterHours} hour`,
207 `or assign to any country in ${availableAgainAfterHours} hours`,
208 availableAgainAfterHours
218 <h4 className="text-bold mb-1">Add new servers</h4>
220 className="mb-5 color-weak"
221 style={{ marginTop: 0, color: totalCountExceeded ? 'var(--signal-danger)' : '' }}
224 msgid`You have ${availableCount} new server available.`,
225 `You have ${availableCount} new servers available.`,
230 {locations.map((location) => {
231 const locationId = getLocationId(location);
233 <div key={locationId} className="flex *:min-size-auto md:flex-nowrap items-center mb-1">
236 value={model.quantities?.[locationId]}
241 (totalCountExceeded &&
242 model.quantities !== undefined &&
243 !(locationId in model.quantities))
245 onChange={(newQuantity: number) => handleQuantityChange(newQuantity, locationId)}
248 countryOptions={countryOptions}
249 ownedCount={ownedCount}
250 usedCount={usedCount}
256 <div className="flex flex-nowrap mb-4 rounded p-2 bg-weak">
257 <Icon name="info-circle" className="shrink-0" />
258 <div className="ml-2">
259 {c('Info').t`We recommend having multiple servers in different locations to provide redundancy.`}
266 export default GatewayCountrySelection;