@oxy.so/services 11.2.0-oxy-one.7 → 11.3.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 (77) hide show
  1. package/lib/commonjs/ui/components/organizations/OrganizationClaimReview.js +208 -0
  2. package/lib/commonjs/ui/components/organizations/OrganizationClaimReview.js.map +1 -0
  3. package/lib/commonjs/ui/components/organizations/OrganizationClaims.js +77 -0
  4. package/lib/commonjs/ui/components/organizations/OrganizationClaims.js.map +1 -0
  5. package/lib/commonjs/ui/components/organizations/OrganizationDirectorySearch.js +218 -0
  6. package/lib/commonjs/ui/components/organizations/OrganizationDirectorySearch.js.map +1 -0
  7. package/lib/commonjs/ui/components/organizations/OrganizationSettings.js +147 -0
  8. package/lib/commonjs/ui/components/organizations/OrganizationSettings.js.map +1 -0
  9. package/lib/commonjs/ui/components/organizations/OrganizationTypePicker.js +34 -0
  10. package/lib/commonjs/ui/components/organizations/OrganizationTypePicker.js.map +1 -0
  11. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +14 -5
  12. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  13. package/lib/commonjs/ui/screens/CreateAccountScreen.js +148 -115
  14. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  15. package/lib/commonjs/ui/screens/ManageAccountScreen.js +13 -1
  16. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  17. package/lib/module/ui/components/organizations/OrganizationClaimReview.js +205 -0
  18. package/lib/module/ui/components/organizations/OrganizationClaimReview.js.map +1 -0
  19. package/lib/module/ui/components/organizations/OrganizationClaims.js +73 -0
  20. package/lib/module/ui/components/organizations/OrganizationClaims.js.map +1 -0
  21. package/lib/module/ui/components/organizations/OrganizationDirectorySearch.js +215 -0
  22. package/lib/module/ui/components/organizations/OrganizationDirectorySearch.js.map +1 -0
  23. package/lib/module/ui/components/organizations/OrganizationSettings.js +143 -0
  24. package/lib/module/ui/components/organizations/OrganizationSettings.js.map +1 -0
  25. package/lib/module/ui/components/organizations/OrganizationTypePicker.js +30 -0
  26. package/lib/module/ui/components/organizations/OrganizationTypePicker.js.map +1 -0
  27. package/lib/module/ui/screens/AccountSettingsScreen.js +14 -5
  28. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  29. package/lib/module/ui/screens/CreateAccountScreen.js +149 -115
  30. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  31. package/lib/module/ui/screens/ManageAccountScreen.js +13 -1
  32. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  33. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaimReview.d.ts +3 -0
  34. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaimReview.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaims.d.ts +2 -0
  36. package/lib/typescript/commonjs/ui/components/organizations/OrganizationClaims.d.ts.map +1 -0
  37. package/lib/typescript/commonjs/ui/components/organizations/OrganizationDirectorySearch.d.ts +6 -0
  38. package/lib/typescript/commonjs/ui/components/organizations/OrganizationDirectorySearch.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/ui/components/organizations/OrganizationSettings.d.ts +5 -0
  40. package/lib/typescript/commonjs/ui/components/organizations/OrganizationSettings.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/ui/components/organizations/OrganizationTypePicker.d.ts +7 -0
  42. package/lib/typescript/commonjs/ui/components/organizations/OrganizationTypePicker.d.ts.map +1 -0
  43. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
  44. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -2
  46. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
  47. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  48. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/components/organizations/OrganizationClaimReview.d.ts +3 -0
  52. package/lib/typescript/module/ui/components/organizations/OrganizationClaimReview.d.ts.map +1 -0
  53. package/lib/typescript/module/ui/components/organizations/OrganizationClaims.d.ts +2 -0
  54. package/lib/typescript/module/ui/components/organizations/OrganizationClaims.d.ts.map +1 -0
  55. package/lib/typescript/module/ui/components/organizations/OrganizationDirectorySearch.d.ts +6 -0
  56. package/lib/typescript/module/ui/components/organizations/OrganizationDirectorySearch.d.ts.map +1 -0
  57. package/lib/typescript/module/ui/components/organizations/OrganizationSettings.d.ts +5 -0
  58. package/lib/typescript/module/ui/components/organizations/OrganizationSettings.d.ts.map +1 -0
  59. package/lib/typescript/module/ui/components/organizations/OrganizationTypePicker.d.ts +7 -0
  60. package/lib/typescript/module/ui/components/organizations/OrganizationTypePicker.d.ts.map +1 -0
  61. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
  62. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -2
  64. package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
  65. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  66. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  67. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  68. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  69. package/package.json +8 -8
  70. package/src/ui/components/organizations/OrganizationClaimReview.tsx +218 -0
  71. package/src/ui/components/organizations/OrganizationClaims.tsx +67 -0
  72. package/src/ui/components/organizations/OrganizationDirectorySearch.tsx +228 -0
  73. package/src/ui/components/organizations/OrganizationSettings.tsx +131 -0
  74. package/src/ui/components/organizations/OrganizationTypePicker.tsx +31 -0
  75. package/src/ui/screens/AccountSettingsScreen.tsx +22 -4
  76. package/src/ui/screens/CreateAccountScreen.tsx +177 -124
  77. package/src/ui/screens/ManageAccountScreen.tsx +18 -0
@@ -0,0 +1,218 @@
1
+ import { useState } from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { isHttpRequestError } from '@oxy.so/core';
5
+ import {
6
+ organizationClaimReviewSchema,
7
+ type OrganizationClaimReview as OrganizationClaimReviewInput,
8
+ } from '@oxy.so/contracts';
9
+ import { Button } from '@oxy.so/bloom/button';
10
+ import { SettingsListGroup, SettingsListItem } from '@oxy.so/bloom/settings-list';
11
+ import { TextField, TextFieldInput } from '@oxy.so/bloom/text-field';
12
+ import { Text } from '@oxy.so/bloom/typography';
13
+ import { useOxy } from '../../context/OxyContext';
14
+ import { useI18n } from '../../hooks/useI18n';
15
+
16
+ /** The queue is requested only after an explicit action; the server rechecks staff authority. */
17
+ export function OrganizationClaimReview() {
18
+ const { t } = useI18n();
19
+ const { user, canUsePrivateApi } = useOxy();
20
+ const [open, setOpen] = useState(false);
21
+ return (
22
+ <SettingsListGroup>
23
+ <SettingsListItem
24
+ title={t('organizationDirectory.review.title')}
25
+ disabled={!canUsePrivateApi}
26
+ onPress={() => setOpen((value) => !value)}
27
+ />
28
+ {open && user?.kind === 'personal' && canUsePrivateApi ? <ReviewQueue key={user.id} /> : null}
29
+ </SettingsListGroup>
30
+ );
31
+ }
32
+ function ReviewQueue() {
33
+ const { t } = useI18n();
34
+ const { oxyServices, user, canUsePrivateApi } = useOxy();
35
+ const [selected, setSelected] = useState<string | null>(null);
36
+ const requests = useInfiniteQuery({
37
+ queryKey: ['oxy', 'organizations', 'review', user?.id, canUsePrivateApi],
38
+ queryFn: ({ pageParam }) =>
39
+ oxyServices.organizations.reviewClaims({
40
+ status: 'pending',
41
+ limit: 20,
42
+ ...(pageParam ? { cursor: pageParam } : {}),
43
+ }),
44
+ initialPageParam: undefined as string | undefined,
45
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
46
+ enabled: canUsePrivateApi,
47
+ retry: false,
48
+ gcTime: 0,
49
+ });
50
+ const claims = requests.data?.pages.flatMap((page) => page.items) ?? [];
51
+ const ids = [...new Set(claims.map((claim) => claim.accountId))].sort();
52
+ const organizations = useQuery({
53
+ queryKey: ['oxy', 'organizations', 'review-organizations', ids],
54
+ queryFn: async () => {
55
+ const batches = [];
56
+ for (let offset = 0; offset < ids.length; offset += 100)
57
+ batches.push(oxyServices.organizations.batch({ ids: ids.slice(offset, offset + 100) }));
58
+ return (await Promise.all(batches)).flatMap((batch) => batch.items);
59
+ },
60
+ enabled: canUsePrivateApi && requests.isSuccess && ids.length > 0,
61
+ retry: false,
62
+ });
63
+ if (requests.isPending) return <ActivityIndicator />;
64
+ if (requests.isError)
65
+ return (
66
+ <View className="gap-space-16 p-space-16">
67
+ <Text accessibilityRole="alert">
68
+ {t(
69
+ isHttpRequestError(requests.error) && requests.error.status === 403
70
+ ? 'organizationDirectory.review.forbidden'
71
+ : 'organizationDirectory.requestError',
72
+ )}
73
+ </Text>
74
+ <Button onPress={() => requests.refetch()}>{t('organizationDirectory.retry')}</Button>
75
+ </View>
76
+ );
77
+ if (selected)
78
+ return <ReviewDetail claimId={selected} key={selected} onClose={() => setSelected(null)} />;
79
+ return (
80
+ <View className="gap-space-16 p-space-16">
81
+ {claims.length === 0 ? <Text>{t('organizationDirectory.review.empty')}</Text> : null}
82
+ {claims.map((claim) => (
83
+ <SettingsListItem
84
+ key={claim.id}
85
+ title={
86
+ organizations.data?.find((item) => item.requestedAccountId === claim.accountId)
87
+ ?.organization.name ?? t('organizationDirectory.view')
88
+ }
89
+ description={t('organizationDirectory.pending')}
90
+ onPress={() => setSelected(claim.id)}
91
+ />
92
+ ))}
93
+ {requests.hasNextPage ? (
94
+ <Button onPress={() => requests.fetchNextPage()} disabled={requests.isFetchingNextPage}>
95
+ {t('organizationDirectory.loadMore')}
96
+ </Button>
97
+ ) : null}
98
+ </View>
99
+ );
100
+ }
101
+ function ReviewDetail({ claimId, onClose }: { claimId: string; onClose: () => void }) {
102
+ const { t } = useI18n();
103
+ const { oxyServices, user, canUsePrivateApi } = useOxy();
104
+ const queryClient = useQueryClient();
105
+ const [evidence, setEvidence] = useState('');
106
+ const [authorizationRevoked, setAuthorizationRevoked] = useState(false);
107
+ const detail = useQuery({
108
+ queryKey: ['oxy', 'organizations', 'review-detail', user?.id, canUsePrivateApi, claimId],
109
+ queryFn: () => oxyServices.organizations.reviewClaimDetail(claimId),
110
+ enabled: canUsePrivateApi,
111
+ retry: false,
112
+ gcTime: 0,
113
+ });
114
+ const review = useMutation({
115
+ mutationFn: (decision: OrganizationClaimReviewInput['decision']) => {
116
+ if (
117
+ !canUsePrivateApi ||
118
+ authorizationRevoked ||
119
+ !detail.isSuccess ||
120
+ !detail.data ||
121
+ detail.data.claimantUserId === user?.id
122
+ )
123
+ throw new Error('Independent staff review is required');
124
+ return oxyServices.organizations.reviewClaim(
125
+ claimId,
126
+ organizationClaimReviewSchema.parse({ decision, evidenceReference: evidence }),
127
+ );
128
+ },
129
+ onError: (error) => {
130
+ if (isHttpRequestError(error) && error.status === 403) setAuthorizationRevoked(true);
131
+ },
132
+ onSuccess: async () => {
133
+ await queryClient.invalidateQueries({ queryKey: ['oxy', 'organizations'] });
134
+ await queryClient.invalidateQueries({ queryKey: ['accounts'] });
135
+ onClose();
136
+ },
137
+ });
138
+ const conflict = isHttpRequestError(review.error) && review.error.status === 409;
139
+ const forbidden = isHttpRequestError(review.error) && review.error.status === 403;
140
+ const selfReview = detail.data?.claimantUserId === user?.id;
141
+ return (
142
+ <View className="gap-space-16 p-space-16">
143
+ {detail.isPending ? <ActivityIndicator /> : null}
144
+ {detail.isError ? (
145
+ <>
146
+ <Text accessibilityRole="alert">
147
+ {t(
148
+ isHttpRequestError(detail.error) && detail.error.status === 403
149
+ ? 'organizationDirectory.review.forbidden'
150
+ : 'organizationDirectory.requestError',
151
+ )}
152
+ </Text>
153
+ <Button onPress={() => detail.refetch()}>{t('organizationDirectory.retry')}</Button>
154
+ </>
155
+ ) : null}
156
+ {authorizationRevoked ? (
157
+ <Text accessibilityRole="alert">{t('organizationDirectory.review.forbidden')}</Text>
158
+ ) : null}
159
+ {detail.isSuccess && canUsePrivateApi && !authorizationRevoked ? (
160
+ <>
161
+ <Text>{t('organizationDirectory.evidence')}</Text>
162
+ <Text selectable>{detail.data.evidenceReference}</Text>
163
+ <Text>{t('organizationDirectory.review.hint')}</Text>
164
+ {selfReview ? <Text>{t('organizationDirectory.review.independent')}</Text> : null}
165
+ <TextField>
166
+ <TextFieldInput
167
+ label={t('organizationDirectory.review.evidence')}
168
+ value={evidence}
169
+ onChangeText={setEvidence}
170
+ maxLength={1000}
171
+ />
172
+ </TextField>
173
+ {review.isError ? (
174
+ <Text accessibilityRole="alert">
175
+ {t(
176
+ conflict
177
+ ? 'organizationDirectory.review.conflict'
178
+ : forbidden
179
+ ? 'organizationDirectory.review.forbidden'
180
+ : 'organizationDirectory.review.failed',
181
+ )}
182
+ </Text>
183
+ ) : null}
184
+ {(['approved', 'rejected'] as const).map((decision) => (
185
+ <Button
186
+ key={decision}
187
+ disabled={
188
+ !canUsePrivateApi ||
189
+ selfReview ||
190
+ detail.data.status !== 'pending' ||
191
+ !evidence.trim() ||
192
+ review.isPending ||
193
+ conflict ||
194
+ forbidden
195
+ }
196
+ onPress={() => review.mutate(decision)}
197
+ >
198
+ {t(`organizationDirectory.review.${decision}`)}
199
+ </Button>
200
+ ))}
201
+ {conflict ? (
202
+ <Button
203
+ onPress={async () => {
204
+ await queryClient.invalidateQueries({ queryKey: ['oxy', 'organizations'] });
205
+ onClose();
206
+ }}
207
+ >
208
+ {t('organizationDirectory.retry')}
209
+ </Button>
210
+ ) : null}
211
+ </>
212
+ ) : null}
213
+ <Button appearance="outline" onPress={onClose} disabled={review.isPending}>
214
+ {t('organizationDirectory.back')}
215
+ </Button>
216
+ </View>
217
+ );
218
+ }
@@ -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
+ }