@oxy.so/services 11.2.0 → 11.4.0

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 (141) hide show
  1. package/lib/commonjs/index.js +28 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js +97 -0
  4. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js.map +1 -0
  5. package/lib/commonjs/ui/components/DisplayCurrencySelector.js +49 -0
  6. package/lib/commonjs/ui/components/DisplayCurrencySelector.js.map +1 -0
  7. package/lib/commonjs/ui/components/organizations/OrganizationClaimReview.js +208 -0
  8. package/lib/commonjs/ui/components/organizations/OrganizationClaimReview.js.map +1 -0
  9. package/lib/commonjs/ui/components/organizations/OrganizationClaims.js +77 -0
  10. package/lib/commonjs/ui/components/organizations/OrganizationClaims.js.map +1 -0
  11. package/lib/commonjs/ui/components/organizations/OrganizationDirectorySearch.js +218 -0
  12. package/lib/commonjs/ui/components/organizations/OrganizationDirectorySearch.js.map +1 -0
  13. package/lib/commonjs/ui/components/organizations/OrganizationSettings.js +147 -0
  14. package/lib/commonjs/ui/components/organizations/OrganizationSettings.js.map +1 -0
  15. package/lib/commonjs/ui/components/organizations/OrganizationTypePicker.js +34 -0
  16. package/lib/commonjs/ui/components/organizations/OrganizationTypePicker.js.map +1 -0
  17. package/lib/commonjs/ui/hooks/useDisplayCurrency.js +77 -0
  18. package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +1 -0
  19. package/lib/commonjs/ui/hooks/useDisplayPrice.js +59 -0
  20. package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +1 -0
  21. package/lib/commonjs/ui/screens/AccountMembersScreen.js +63 -20
  22. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  23. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +14 -5
  24. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  25. package/lib/commonjs/ui/screens/CreateAccountScreen.js +148 -115
  26. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  27. package/lib/commonjs/ui/screens/ManageAccountScreen.js +13 -1
  28. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  29. package/lib/commonjs/ui/screens/PreferencesScreen.js +4 -3
  30. package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
  31. package/lib/module/index.js +4 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/ui/components/AccountInvitationAcceptance.js +94 -0
  34. package/lib/module/ui/components/AccountInvitationAcceptance.js.map +1 -0
  35. package/lib/module/ui/components/DisplayCurrencySelector.js +44 -0
  36. package/lib/module/ui/components/DisplayCurrencySelector.js.map +1 -0
  37. package/lib/module/ui/components/organizations/OrganizationClaimReview.js +205 -0
  38. package/lib/module/ui/components/organizations/OrganizationClaimReview.js.map +1 -0
  39. package/lib/module/ui/components/organizations/OrganizationClaims.js +73 -0
  40. package/lib/module/ui/components/organizations/OrganizationClaims.js.map +1 -0
  41. package/lib/module/ui/components/organizations/OrganizationDirectorySearch.js +215 -0
  42. package/lib/module/ui/components/organizations/OrganizationDirectorySearch.js.map +1 -0
  43. package/lib/module/ui/components/organizations/OrganizationSettings.js +143 -0
  44. package/lib/module/ui/components/organizations/OrganizationSettings.js.map +1 -0
  45. package/lib/module/ui/components/organizations/OrganizationTypePicker.js +30 -0
  46. package/lib/module/ui/components/organizations/OrganizationTypePicker.js.map +1 -0
  47. package/lib/module/ui/hooks/useDisplayCurrency.js +72 -0
  48. package/lib/module/ui/hooks/useDisplayCurrency.js.map +1 -0
  49. package/lib/module/ui/hooks/useDisplayPrice.js +56 -0
  50. package/lib/module/ui/hooks/useDisplayPrice.js.map +1 -0
  51. package/lib/module/ui/screens/AccountMembersScreen.js +63 -20
  52. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  53. package/lib/module/ui/screens/AccountSettingsScreen.js +14 -5
  54. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  55. package/lib/module/ui/screens/CreateAccountScreen.js +149 -115
  56. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  57. package/lib/module/ui/screens/ManageAccountScreen.js +13 -1
  58. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  59. package/lib/module/ui/screens/PreferencesScreen.js +4 -3
  60. package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
  61. package/lib/typescript/commonjs/index.d.ts +4 -0
  62. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  64. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +3 -0
  66. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaimReview.d.ts +3 -0
  68. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaimReview.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaims.d.ts +2 -0
  70. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaims.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/ui/components/organizations/OrganizationDirectorySearch.d.ts +6 -0
  72. package/lib/typescript/commonjs/ui/components/organizations/OrganizationDirectorySearch.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/ui/components/organizations/OrganizationSettings.d.ts +5 -0
  74. package/lib/typescript/commonjs/ui/components/organizations/OrganizationSettings.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/ui/components/organizations/OrganizationTypePicker.d.ts +7 -0
  76. package/lib/typescript/commonjs/ui/components/organizations/OrganizationTypePicker.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +10 -3
  78. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +4 -3
  80. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +11 -0
  82. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +6 -0
  84. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
  86. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  87. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
  88. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
  93. package/lib/typescript/module/index.d.ts +4 -0
  94. package/lib/typescript/module/index.d.ts.map +1 -1
  95. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  96. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  97. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +3 -0
  98. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  99. package/lib/typescript/module/ui/components/organizations/OrganizationClaimReview.d.ts +3 -0
  100. package/lib/typescript/module/ui/components/organizations/OrganizationClaimReview.d.ts.map +1 -0
  101. package/lib/typescript/module/ui/components/organizations/OrganizationClaims.d.ts +2 -0
  102. package/lib/typescript/module/ui/components/organizations/OrganizationClaims.d.ts.map +1 -0
  103. package/lib/typescript/module/ui/components/organizations/OrganizationDirectorySearch.d.ts +6 -0
  104. package/lib/typescript/module/ui/components/organizations/OrganizationDirectorySearch.d.ts.map +1 -0
  105. package/lib/typescript/module/ui/components/organizations/OrganizationSettings.d.ts +5 -0
  106. package/lib/typescript/module/ui/components/organizations/OrganizationSettings.d.ts.map +1 -0
  107. package/lib/typescript/module/ui/components/organizations/OrganizationTypePicker.d.ts +7 -0
  108. package/lib/typescript/module/ui/components/organizations/OrganizationTypePicker.d.ts.map +1 -0
  109. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +10 -3
  110. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  111. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +4 -3
  112. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  113. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +11 -0
  114. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  115. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +6 -0
  116. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  117. package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
  118. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  119. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
  120. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  121. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
  125. package/package.json +9 -8
  126. package/src/index.ts +5 -0
  127. package/src/ui/components/AccountInvitationAcceptance.tsx +90 -0
  128. package/src/ui/components/DisplayCurrencySelector.tsx +38 -0
  129. package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +61 -0
  130. package/src/ui/components/organizations/OrganizationClaimReview.tsx +218 -0
  131. package/src/ui/components/organizations/OrganizationClaims.tsx +67 -0
  132. package/src/ui/components/organizations/OrganizationDirectorySearch.tsx +228 -0
  133. package/src/ui/components/organizations/OrganizationSettings.tsx +131 -0
  134. package/src/ui/components/organizations/OrganizationTypePicker.tsx +31 -0
  135. package/src/ui/hooks/useDisplayCurrency.ts +64 -0
  136. package/src/ui/hooks/useDisplayPrice.ts +40 -0
  137. package/src/ui/screens/AccountMembersScreen.tsx +71 -27
  138. package/src/ui/screens/AccountSettingsScreen.tsx +22 -4
  139. package/src/ui/screens/CreateAccountScreen.tsx +177 -124
  140. package/src/ui/screens/ManageAccountScreen.tsx +18 -0
  141. package/src/ui/screens/PreferencesScreen.tsx +2 -0
@@ -0,0 +1,67 @@
1
+ import { View } from 'react-native';
2
+ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
3
+ import { Button } from '@oxy.so/bloom/button';
4
+ import { SettingsListGroup, SettingsListItem } from '@oxy.so/bloom/settings-list';
5
+ import { Text } from '@oxy.so/bloom/typography';
6
+ import { useOxy } from '../../context/OxyContext';
7
+ import { useI18n } from '../../hooks/useI18n';
8
+
9
+ export function OrganizationClaims() {
10
+ const { oxyServices, user, canUsePrivateApi } = useOxy();
11
+ const { t } = useI18n();
12
+ const enabled = canUsePrivateApi && user?.kind === 'personal';
13
+ const requests = useInfiniteQuery({
14
+ queryKey: ['oxy', 'organizations', 'claims', user?.id, canUsePrivateApi],
15
+ queryFn: ({ pageParam }) =>
16
+ oxyServices.organizations.myClaims({
17
+ limit: 20,
18
+ ...(pageParam ? { cursor: pageParam } : {}),
19
+ }),
20
+ initialPageParam: undefined as string | undefined,
21
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
22
+ enabled,
23
+ retry: false,
24
+ gcTime: 0,
25
+ });
26
+ const claims = requests.data?.pages.flatMap((page) => page.items) ?? [];
27
+ const ids = [...new Set(claims.map((claim) => claim.accountId))].sort();
28
+ const organizations = useQuery({
29
+ queryKey: ['oxy', 'organizations', 'claims-employers', ids],
30
+ queryFn: async () => {
31
+ const batches = [];
32
+ for (let offset = 0; offset < ids.length; offset += 100)
33
+ batches.push(oxyServices.organizations.batch({ ids: ids.slice(offset, offset + 100) }));
34
+ return (await Promise.all(batches)).flatMap((batch) => batch.items);
35
+ },
36
+ enabled: enabled && ids.length > 0,
37
+ retry: false,
38
+ });
39
+ if (!enabled || (!requests.isError && claims.length === 0)) return null;
40
+ return (
41
+ <SettingsListGroup title={t('organizationDirectory.requests')}>
42
+ {requests.isError ? (
43
+ <View>
44
+ <Text accessibilityRole="alert">{t('organizationDirectory.requestError')}</Text>
45
+ <Button onPress={() => requests.refetch()}>{t('organizationDirectory.retry')}</Button>
46
+ </View>
47
+ ) : null}
48
+ {!requests.isError &&
49
+ claims.map((claim) => (
50
+ <SettingsListItem
51
+ key={claim.id}
52
+ title={
53
+ organizations.data?.find((item) => item.requestedAccountId === claim.accountId)
54
+ ?.organization.name ?? t('organizationDirectory.view')
55
+ }
56
+ description={t(`organizationDirectory.${claim.status}`)}
57
+ showChevron={false}
58
+ />
59
+ ))}
60
+ {!requests.isError && requests.hasNextPage ? (
61
+ <Button onPress={() => requests.fetchNextPage()} disabled={requests.isFetchingNextPage}>
62
+ {t('organizationDirectory.loadMore')}
63
+ </Button>
64
+ ) : null}
65
+ </SettingsListGroup>
66
+ );
67
+ }
@@ -0,0 +1,228 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+ import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import {
5
+ createOrganizationSchema,
6
+ organizationClaimInputSchema,
7
+ type Organization,
8
+ type OrganizationType,
9
+ } from '@oxy.so/contracts';
10
+ import { Avatar } from '@oxy.so/bloom/avatar';
11
+ import { Button } from '@oxy.so/bloom/button';
12
+ import { SettingsListGroup, SettingsListItem } from '@oxy.so/bloom/settings-list';
13
+ import { TextField, TextFieldInput } from '@oxy.so/bloom/text-field';
14
+ import { Text } from '@oxy.so/bloom/typography';
15
+ import { useOxy } from '../../context/OxyContext';
16
+ import { useI18n } from '../../hooks/useI18n';
17
+ import { OrganizationTypePicker } from './OrganizationTypePicker';
18
+
19
+ /** Finding an existing identity never grants ownership or silently creates an account. */
20
+ export function OrganizationDirectorySearch({
21
+ onCreateAccount,
22
+ }: {
23
+ onCreateAccount: (name: string, type: OrganizationType) => void;
24
+ }) {
25
+ const { oxyServices, user, canUsePrivateApi } = useOxy();
26
+ const { t } = useI18n();
27
+ const queryClient = useQueryClient();
28
+ const [search, setSearch] = useState('');
29
+ const [query, setQuery] = useState('');
30
+ const [selected, setSelected] = useState<Organization | null>(null);
31
+ const [adding, setAdding] = useState(false);
32
+ const [type, setType] = useState<OrganizationType>('business');
33
+ const [evidence, setEvidence] = useState('');
34
+ const [validationError, setValidationError] = useState(false);
35
+ const personalReady = canUsePrivateApi && user?.kind === 'personal';
36
+ useEffect(() => {
37
+ const timer = setTimeout(() => setQuery(search.trim()), 300);
38
+ return () => clearTimeout(timer);
39
+ }, [search]);
40
+ const results = useInfiniteQuery({
41
+ queryKey: ['oxy', 'organizations', 'search', query],
42
+ queryFn: ({ pageParam }) =>
43
+ oxyServices.organizations.search({
44
+ query,
45
+ limit: 20,
46
+ ...(pageParam ? { cursor: pageParam } : {}),
47
+ }),
48
+ initialPageParam: undefined as string | undefined,
49
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
50
+ enabled: query.length > 0,
51
+ retry: false,
52
+ });
53
+ const claim = useMutation({
54
+ mutationFn: () => {
55
+ if (!personalReady || !selected || selected.status !== 'active')
56
+ throw new Error('An active organization and personal session are required');
57
+ return oxyServices.organizations.claim(
58
+ selected.accountId,
59
+ organizationClaimInputSchema.parse({ evidenceReference: evidence }),
60
+ );
61
+ },
62
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['oxy', 'organizations'] }),
63
+ });
64
+ const create = useMutation({
65
+ mutationFn: () => {
66
+ if (!personalReady) throw new Error('A personal session is required');
67
+ return oxyServices.organizations.create(
68
+ createOrganizationSchema.parse({ name: search.trim(), organizationType: type }),
69
+ );
70
+ },
71
+ onSuccess: (organization) => {
72
+ setSelected(organization);
73
+ setAdding(false);
74
+ void queryClient.invalidateQueries({ queryKey: ['oxy', 'organizations'] });
75
+ },
76
+ });
77
+ const reset = () => {
78
+ setSelected(null);
79
+ setAdding(false);
80
+ setEvidence('');
81
+ claim.reset();
82
+ create.reset();
83
+ setValidationError(false);
84
+ };
85
+ if (selected)
86
+ return (
87
+ <SettingsListGroup title={selected.name}>
88
+ <View className="gap-space-16 p-space-16">
89
+ <Text>{t(`organizationDirectory.${selected.claimStatus}`)}</Text>
90
+ {selected.status === 'inactive' ? (
91
+ <Text>{t('organizationDirectory.inactive')}</Text>
92
+ ) : null}
93
+ {selected.claimStatus === 'unclaimed' ? (
94
+ <>
95
+ <Text>{t('organizationDirectory.claimHint')}</Text>
96
+ <TextField>
97
+ <TextFieldInput
98
+ label={t('organizationDirectory.evidence')}
99
+ value={evidence}
100
+ onChangeText={setEvidence}
101
+ maxLength={1000}
102
+ />
103
+ </TextField>
104
+ <Text>{t('organizationDirectory.evidenceHint')}</Text>
105
+ {claim.isSuccess ? (
106
+ <Text accessibilityRole="alert">{t('organizationDirectory.claimSent')}</Text>
107
+ ) : (
108
+ <Button
109
+ onPress={() => claim.mutate()}
110
+ disabled={
111
+ !personalReady ||
112
+ selected.status !== 'active' ||
113
+ !evidence.trim() ||
114
+ claim.isPending
115
+ }
116
+ loading={claim.isPending}
117
+ >
118
+ {t('organizationDirectory.claim')}
119
+ </Button>
120
+ )}
121
+ {claim.isError ? (
122
+ <Text accessibilityRole="alert">{t('organizationDirectory.claimFailed')}</Text>
123
+ ) : null}
124
+ </>
125
+ ) : (
126
+ <Text>{t('organizationDirectory.claimedHint')}</Text>
127
+ )}
128
+ <Button appearance="outline" onPress={reset} disabled={claim.isPending}>
129
+ {t('organizationDirectory.back')}
130
+ </Button>
131
+ </View>
132
+ </SettingsListGroup>
133
+ );
134
+ return (
135
+ <View className="gap-space-16">
136
+ <TextField>
137
+ <TextFieldInput
138
+ label={t('organizationDirectory.search')}
139
+ value={search}
140
+ onChangeText={(value) => {
141
+ setSearch(value);
142
+ setValidationError(false);
143
+ }}
144
+ maxLength={200}
145
+ />
146
+ </TextField>
147
+ {query && results.isPending ? <ActivityIndicator /> : null}
148
+ {results.isError ? (
149
+ <View>
150
+ <Text accessibilityRole="alert">{t('organizationDirectory.failed')}</Text>
151
+ <Button onPress={() => results.refetch()}>{t('organizationDirectory.retry')}</Button>
152
+ </View>
153
+ ) : null}
154
+ {results.data ? (
155
+ <SettingsListGroup title={t('organizationDirectory.title')}>
156
+ {results.data.pages
157
+ .flatMap((page) => page.items)
158
+ .map((organization) => (
159
+ <SettingsListItem
160
+ key={organization.accountId}
161
+ title={organization.name}
162
+ description={[
163
+ organization.websiteUrl,
164
+ organization.countryCode,
165
+ t(`organizationDirectory.${organization.claimStatus}`),
166
+ ]
167
+ .filter(Boolean)
168
+ .join(' · ')}
169
+ icon={<Avatar source={organization.avatar} name={organization.name} size={40} />}
170
+ onPress={() => setSelected(organization)}
171
+ />
172
+ ))}
173
+ {results.data.pages[0].items.length === 0 ? (
174
+ <Text>{t('organizationDirectory.empty')}</Text>
175
+ ) : null}
176
+ {results.hasNextPage ? (
177
+ <Button onPress={() => results.fetchNextPage()} disabled={results.isFetchingNextPage}>
178
+ {t('organizationDirectory.loadMore')}
179
+ </Button>
180
+ ) : null}
181
+ </SettingsListGroup>
182
+ ) : null}
183
+ {results.isSuccess && query === search.trim() ? (
184
+ <>
185
+ <Text>{t('organizationDirectory.newAccountHint')}</Text>
186
+ <Button onPress={() => onCreateAccount(search.trim(), type)}>
187
+ {t('organizationDirectory.newAccount')}
188
+ </Button>
189
+ {personalReady && !adding ? (
190
+ <Button appearance="outline" onPress={() => setAdding(true)}>
191
+ {t('organizationDirectory.addDirectory')}
192
+ </Button>
193
+ ) : null}
194
+ </>
195
+ ) : null}
196
+ {adding ? (
197
+ <>
198
+ <Text>{t('organizationDirectory.directoryHint')}</Text>
199
+ <OrganizationTypePicker value={type} onChange={setType} disabled={create.isPending} />
200
+ {validationError ? (
201
+ <Text accessibilityRole="alert">{t('organizationDirectory.invalid')}</Text>
202
+ ) : null}
203
+ {create.isError ? (
204
+ <Text accessibilityRole="alert">{t('organizationDirectory.saveFailed')}</Text>
205
+ ) : null}
206
+ <Button
207
+ disabled={
208
+ !personalReady || create.isPending || !results.isSuccess || query !== search.trim()
209
+ }
210
+ loading={create.isPending}
211
+ onPress={() => {
212
+ if (
213
+ !createOrganizationSchema.safeParse({ name: search.trim(), organizationType: type })
214
+ .success
215
+ ) {
216
+ setValidationError(true);
217
+ return;
218
+ }
219
+ create.mutate();
220
+ }}
221
+ >
222
+ {t('organizationDirectory.addDirectory')}
223
+ </Button>
224
+ </>
225
+ ) : null}
226
+ </View>
227
+ );
228
+ }
@@ -0,0 +1,131 @@
1
+ import { useState } from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import {
5
+ updateOrganizationSchema,
6
+ type Organization,
7
+ type OrganizationType,
8
+ } from '@oxy.so/contracts';
9
+ import { Button } from '@oxy.so/bloom/button';
10
+ import { toast } from '@oxy.so/bloom/toast';
11
+ import { SettingsListGroup } from '@oxy.so/bloom/settings-list';
12
+ import { TextField, TextFieldInput } from '@oxy.so/bloom/text-field';
13
+ import { Text } from '@oxy.so/bloom/typography';
14
+ import { useOxy } from '../../context/OxyContext';
15
+ import { useI18n } from '../../hooks/useI18n';
16
+ import { OrganizationTypePicker } from './OrganizationTypePicker';
17
+
18
+ export function OrganizationSettings({
19
+ accountId,
20
+ canUpdate,
21
+ }: {
22
+ accountId: string;
23
+ canUpdate: boolean;
24
+ }) {
25
+ const { oxyServices, canUsePrivateApi } = useOxy();
26
+ const { t } = useI18n();
27
+ const organization = useQuery({
28
+ queryKey: ['oxy', 'organizations', accountId],
29
+ queryFn: () => oxyServices.organizations.get(accountId),
30
+ retry: false,
31
+ });
32
+ if (organization.isPending) return <ActivityIndicator />;
33
+ if (organization.isError)
34
+ return (
35
+ <View>
36
+ <Text accessibilityRole="alert">{t('organizationDirectory.failed')}</Text>
37
+ <Button onPress={() => organization.refetch()}>{t('organizationDirectory.retry')}</Button>
38
+ </View>
39
+ );
40
+ if (!canUpdate || !canUsePrivateApi)
41
+ return (
42
+ <SettingsListGroup title={t('organizationDirectory.title')}>
43
+ <Text>{t(`organizationDirectory.types.${organization.data.organizationType}`)}</Text>
44
+ <Text>{t('organizationDirectory.readonly')}</Text>
45
+ </SettingsListGroup>
46
+ );
47
+ return (
48
+ <OrganizationForm
49
+ key={`${accountId}:${organization.data.updatedAt}`}
50
+ organization={organization.data}
51
+ />
52
+ );
53
+ }
54
+
55
+ function OrganizationForm({ organization }: { organization: Organization }) {
56
+ const { oxyServices, canUsePrivateApi } = useOxy();
57
+ const { t } = useI18n();
58
+ const queryClient = useQueryClient();
59
+ const [legalName, setLegalName] = useState(organization.legalName ?? '');
60
+ const [website, setWebsite] = useState(organization.websiteUrl ?? '');
61
+ const [country, setCountry] = useState(organization.countryCode ?? '');
62
+ const [type, setType] = useState<OrganizationType>(organization.organizationType);
63
+ const [invalid, setInvalid] = useState(false);
64
+ const save = useMutation({
65
+ mutationFn: (input: ReturnType<typeof updateOrganizationSchema.parse>) => {
66
+ if (!canUsePrivateApi) throw new Error('An authenticated session is required');
67
+ return oxyServices.organizations.update(organization.accountId, input);
68
+ },
69
+ onSuccess: () => {
70
+ toast.success(t('organizationDirectory.saved'));
71
+ void queryClient.invalidateQueries({ queryKey: ['oxy', 'organizations'] });
72
+ void queryClient.invalidateQueries({ queryKey: ['accounts'] });
73
+ void queryClient.invalidateQueries({ queryKey: ['users'] });
74
+ },
75
+ });
76
+ return (
77
+ <SettingsListGroup title={t('organizationDirectory.title')}>
78
+ <View className="gap-space-16 p-space-16">
79
+ <TextField>
80
+ <TextFieldInput
81
+ label={t('organizationDirectory.legalName')}
82
+ value={legalName}
83
+ onChangeText={setLegalName}
84
+ maxLength={300}
85
+ />
86
+ </TextField>
87
+ <TextField>
88
+ <TextFieldInput
89
+ label={t('organizationDirectory.website')}
90
+ value={website}
91
+ onChangeText={setWebsite}
92
+ autoCapitalize="none"
93
+ maxLength={2048}
94
+ />
95
+ </TextField>
96
+ <TextField>
97
+ <TextFieldInput
98
+ label={t('organizationDirectory.country')}
99
+ value={country}
100
+ onChangeText={setCountry}
101
+ autoCapitalize="characters"
102
+ maxLength={2}
103
+ />
104
+ </TextField>
105
+ <OrganizationTypePicker value={type} onChange={setType} disabled={save.isPending} />
106
+ {invalid ? (
107
+ <Text accessibilityRole="alert">{t('organizationDirectory.invalid')}</Text>
108
+ ) : null}
109
+ {save.isError ? (
110
+ <Text accessibilityRole="alert">{t('organizationDirectory.saveFailed')}</Text>
111
+ ) : null}
112
+ <Button
113
+ disabled={save.isPending || !canUsePrivateApi}
114
+ loading={save.isPending}
115
+ onPress={() => {
116
+ const input = updateOrganizationSchema.safeParse({
117
+ legalName: legalName.trim() || null,
118
+ websiteUrl: website.trim() || null,
119
+ countryCode: country.trim().toUpperCase() || null,
120
+ organizationType: type,
121
+ });
122
+ setInvalid(!input.success);
123
+ if (input.success) save.mutate(input.data);
124
+ }}
125
+ >
126
+ {t('organizationDirectory.save')}
127
+ </Button>
128
+ </View>
129
+ </SettingsListGroup>
130
+ );
131
+ }
@@ -0,0 +1,31 @@
1
+ import { ORGANIZATION_TYPES, type OrganizationType } from '@oxy.so/contracts';
2
+ import { SettingsListGroup, SettingsListItem } from '@oxy.so/bloom/settings-list';
3
+ import { Text } from '@oxy.so/bloom/typography';
4
+ import { useI18n } from '../../hooks/useI18n';
5
+
6
+ export function OrganizationTypePicker({
7
+ value,
8
+ onChange,
9
+ disabled = false,
10
+ }: {
11
+ value: OrganizationType;
12
+ onChange: (value: OrganizationType) => void;
13
+ disabled?: boolean;
14
+ }) {
15
+ const { t } = useI18n();
16
+ return (
17
+ <SettingsListGroup title={t('organizationDirectory.type')}>
18
+ {ORGANIZATION_TYPES.map((type) => (
19
+ <SettingsListItem
20
+ key={type}
21
+ title={t(`organizationDirectory.types.${type}`)}
22
+ accessibilityLabel={t(`organizationDirectory.types.${type}`)}
23
+ disabled={disabled}
24
+ onPress={() => onChange(type)}
25
+ showChevron={false}
26
+ rightElement={value === type ? <Text>✓</Text> : undefined}
27
+ />
28
+ ))}
29
+ </SettingsListGroup>
30
+ );
31
+ }
@@ -0,0 +1,64 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import { authenticatedApiCall, type UserPreferences } from '@oxy.so/core';
4
+ import { useOxy } from '../context/OxyContext';
5
+ import { useCurrentUser } from './queries/useAccountQueries';
6
+ import { queryKeys } from './queries/queryKeys';
7
+ import { useAuthStore } from '../stores/authStore';
8
+ import { mutationKeys } from './mutations/mutationKeys';
9
+
10
+ export type DisplayCurrency = NonNullable<UserPreferences['displayCurrency']>;
11
+ export const DISPLAY_CURRENCIES: readonly DisplayCurrency[] = ['USD', 'EUR', 'FAIR'];
12
+
13
+ let anonymousCurrency: DisplayCurrency = 'USD';
14
+ const anonymousListeners = new Set<() => void>();
15
+ const subscribeAnonymous = (listener: () => void) => {
16
+ anonymousListeners.add(listener);
17
+ return () => {
18
+ anonymousListeners.delete(listener);
19
+ };
20
+ };
21
+ const anonymousSnapshot = () => anonymousCurrency;
22
+
23
+ /** Account-scoped server preference. A late save can never replace another account. */
24
+ export function useDisplayCurrency() {
25
+ const { oxyServices, activeSessionId, isAuthenticated, user } = useOxy();
26
+ const current = useCurrentUser({ enabled: isAuthenticated });
27
+ const client = useQueryClient();
28
+ const anonymous = useSyncExternalStore(
29
+ subscribeAnonymous,
30
+ anonymousSnapshot,
31
+ () => 'USD' as const,
32
+ );
33
+ const currency = isAuthenticated
34
+ ? (current.data?.userPreferences?.displayCurrency ??
35
+ user?.userPreferences?.displayCurrency ??
36
+ 'USD')
37
+ : anonymous;
38
+ const mutation = useMutation({
39
+ mutationKey: [...mutationKeys.account.updateSettings],
40
+ mutationFn: async (input: { currency: DisplayCurrency; sessionId: string; userId: string }) => {
41
+ const updated = await authenticatedApiCall(oxyServices, input.sessionId, () =>
42
+ oxyServices.users.updateMe({ userPreferences: { displayCurrency: input.currency } }),
43
+ );
44
+ if (updated.id !== input.userId) throw new Error('Account changed. Please try again.');
45
+ return updated;
46
+ },
47
+ onSuccess: (updated, input) => {
48
+ client.setQueryData(queryKeys.accounts.current(input.sessionId), updated);
49
+ if (useAuthStore.getState().user?.id === input.userId)
50
+ useAuthStore.getState().setUser(updated);
51
+ },
52
+ });
53
+ function selectCurrency(next: DisplayCurrency) {
54
+ if (!DISPLAY_CURRENCIES.includes(next)) return;
55
+ if (!isAuthenticated) {
56
+ anonymousCurrency = next;
57
+ for (const listener of anonymousListeners) listener();
58
+ return;
59
+ }
60
+ if (activeSessionId && user?.id)
61
+ mutation.mutate({ currency: next, sessionId: activeSessionId, userId: user.id });
62
+ }
63
+ return { currency, selectCurrency, isSaving: mutation.isPending, error: mutation.error };
64
+ }
@@ -0,0 +1,40 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { useEffect, useState } from 'react';
3
+ import { convertDisplayAmount, readDisplayRates } from '@peable.to/sdk/display';
4
+ import { useDisplayCurrency } from './useDisplayCurrency';
5
+ import { useI18n } from './useI18n';
6
+
7
+ /** Display conversion only. Pricing and checkout request bodies retain their source units. */
8
+ export function useDisplayPrice() {
9
+ const { currency } = useDisplayCurrency();
10
+ const { t } = useI18n();
11
+ const [now, setNow] = useState(Date.now);
12
+ const rates = useQuery({
13
+ queryKey: ['display-currency', 'faircoin-indexed-price'],
14
+ queryFn: ({ signal }) => readDisplayRates({ signal }),
15
+ enabled: currency !== 'USD',
16
+ staleTime: 30_000,
17
+ gcTime: 0,
18
+ retry: false,
19
+ refetchInterval: 60_000,
20
+ refetchOnWindowFocus: 'always',
21
+ });
22
+ useEffect(() => {
23
+ const timer = setInterval(() => setNow(Date.now()), 1000);
24
+ return () => clearInterval(timer);
25
+ }, []);
26
+ function formatUsd(minorUnits: number) {
27
+ const display = convertDisplayAmount({
28
+ units: String(minorUnits),
29
+ currency: 'USD',
30
+ displayCurrency: currency,
31
+ rates: rates.isError ? null : rates.data,
32
+ now,
33
+ });
34
+ if (!display) return t('preferences.displayCurrency.unavailable', { currency });
35
+ return currency === 'USD'
36
+ ? display
37
+ : t('preferences.displayCurrency.approximate', { amount: display });
38
+ }
39
+ return { currency, formatUsd };
40
+ }