@oxy.so/services 11.3.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 (71) 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/hooks/useDisplayCurrency.js +77 -0
  8. package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +1 -0
  9. package/lib/commonjs/ui/hooks/useDisplayPrice.js +59 -0
  10. package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +1 -0
  11. package/lib/commonjs/ui/screens/AccountMembersScreen.js +63 -20
  12. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  13. package/lib/commonjs/ui/screens/PreferencesScreen.js +4 -3
  14. package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
  15. package/lib/module/index.js +4 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/AccountInvitationAcceptance.js +94 -0
  18. package/lib/module/ui/components/AccountInvitationAcceptance.js.map +1 -0
  19. package/lib/module/ui/components/DisplayCurrencySelector.js +44 -0
  20. package/lib/module/ui/components/DisplayCurrencySelector.js.map +1 -0
  21. package/lib/module/ui/hooks/useDisplayCurrency.js +72 -0
  22. package/lib/module/ui/hooks/useDisplayCurrency.js.map +1 -0
  23. package/lib/module/ui/hooks/useDisplayPrice.js +56 -0
  24. package/lib/module/ui/hooks/useDisplayPrice.js.map +1 -0
  25. package/lib/module/ui/screens/AccountMembersScreen.js +63 -20
  26. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  27. package/lib/module/ui/screens/PreferencesScreen.js +4 -3
  28. package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +4 -0
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  32. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +3 -0
  34. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
  36. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
  38. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +11 -0
  40. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +6 -0
  42. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  43. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
  44. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
  46. package/lib/typescript/module/index.d.ts +4 -0
  47. package/lib/typescript/module/index.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  49. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  50. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +3 -0
  51. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  52. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
  53. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
  55. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +11 -0
  57. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  58. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +6 -0
  59. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  60. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
  61. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
  63. package/package.json +6 -5
  64. package/src/index.ts +5 -0
  65. package/src/ui/components/AccountInvitationAcceptance.tsx +90 -0
  66. package/src/ui/components/DisplayCurrencySelector.tsx +38 -0
  67. package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +61 -0
  68. package/src/ui/hooks/useDisplayCurrency.ts +64 -0
  69. package/src/ui/hooks/useDisplayPrice.ts +40 -0
  70. package/src/ui/screens/AccountMembersScreen.tsx +71 -27
  71. package/src/ui/screens/PreferencesScreen.tsx +2 -0
@@ -1 +1 @@
1
- {"version":3,"file":"usePersonalPlans.d.ts","sourceRoot":"","sources":["../../../../../../src/ui/hooks/queries/usePersonalPlans.ts"],"names":[],"mappings":"AAIA,iFAAiF;AACjF,wBAAgB,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;aA0B4xO,CAAC;;;;;;;;;UAnB5zO;AAED,kFAAkF;AAClF,wBAAgB,4BAA4B;;;;;;;;;;;;;;;;;;;;;;;;;YAe3C"}
1
+ {"version":3,"file":"usePersonalPlans.d.ts","sourceRoot":"","sources":["../../../../../../src/ui/hooks/queries/usePersonalPlans.ts"],"names":[],"mappings":"AAIA,iFAAiF;AACjF,wBAAgB,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;aA0Bk1O,CAAC;;;;;;;;;UAnBl3O;AAED,kFAAkF;AAClF,wBAAgB,4BAA4B;;;;;;;;;;;;;;;;;;;;;;;;;;YAe3C"}
@@ -0,0 +1,11 @@
1
+ import { type UserPreferences } from '@oxy.so/core';
2
+ export type DisplayCurrency = NonNullable<UserPreferences['displayCurrency']>;
3
+ export declare const DISPLAY_CURRENCIES: readonly DisplayCurrency[];
4
+ /** Account-scoped server preference. A late save can never replace another account. */
5
+ export declare function useDisplayCurrency(): {
6
+ currency: DisplayCurrency;
7
+ selectCurrency: (next: DisplayCurrency) => void;
8
+ isSaving: boolean;
9
+ error: Error | null;
10
+ };
11
+ //# sourceMappingURL=useDisplayCurrency.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDisplayCurrency.d.ts","sourceRoot":"","sources":["../../../../../src/ui/hooks/useDisplayCurrency.ts"],"names":[],"mappings":"AAEA,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAO1E,MAAM,MAAM,eAAe,GAAG,WAAW,CAAC,eAAe,CAAC,iBAAiB,CAAC,CAAC,CAAC;AAC9E,eAAO,MAAM,kBAAkB,EAAE,SAAS,eAAe,EAA2B,CAAC;AAYrF,uFAAuF;AACvF,wBAAgB,kBAAkB;;2BA6BF,eAAe;;;EAW9C"}
@@ -0,0 +1,6 @@
1
+ /** Display conversion only. Pricing and checkout request bodies retain their source units. */
2
+ export declare function useDisplayPrice(): {
3
+ currency: import("./useDisplayCurrency.js").DisplayCurrency;
4
+ formatUsd: (minorUnits: number) => string;
5
+ };
6
+ //# sourceMappingURL=useDisplayPrice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDisplayPrice.d.ts","sourceRoot":"","sources":["../../../../../src/ui/hooks/useDisplayPrice.ts"],"names":[],"mappings":"AAMA,8FAA8F;AAC9F,wBAAgB,eAAe;;4BAkBE,MAAM;EActC"}
@@ -1,7 +1,7 @@
1
1
  import type React from 'react';
2
2
  import type { BaseScreenProps } from '../types/navigation.js';
3
3
  /**
4
- * Manage the members of an account (invite by username/email, change roles,
4
+ * Manage the members of an account (invite by email, change roles,
5
5
  * remove, transfer ownership). Gated on the caller's `callerMembership`
6
6
  * permissions. Receives the target `accountId` via navigation props.
7
7
  */
@@ -1 +1 @@
1
- {"version":3,"file":"AccountMembersScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/AccountMembersScreen.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAc/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AAkB3D;;;;GAIG;AACH,QAAA,MAAM,oBAAoB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CAscnD,CAAC;AAEF,eAAe,oBAAoB,CAAC"}
1
+ {"version":3,"file":"AccountMembersScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/AccountMembersScreen.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAc/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AAa3D;;;;GAIG;AACH,QAAA,MAAM,oBAAoB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CAufnD,CAAC;AAEF,eAAe,oBAAoB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"PreferencesScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/PreferencesScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoD,MAAM,OAAO,CAAC;AAOzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;;AAwL3D,wBAA6C"}
1
+ {"version":3,"file":"PreferencesScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/PreferencesScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoD,MAAM,OAAO,CAAC;AAQzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;;AAyL3D,wBAA6C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/services",
3
- "version": "11.3.0",
3
+ "version": "11.4.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -155,14 +155,15 @@
155
155
  },
156
156
  "dependencies": {
157
157
  "@expo/config-plugins": "57.0.9",
158
- "@oxy.so/contracts": "4.14.0",
159
- "color": "^5.0.3"
158
+ "@oxy.so/contracts": "4.15.0",
159
+ "color": "^5.0.3",
160
+ "@peable.to/sdk": "0.2.7-oxy-one.1"
160
161
  },
161
162
  "devDependencies": {
162
163
  "@commitlint/cli": "^21.2.1",
163
164
  "@commitlint/config-conventional": "^21.2.0",
164
165
  "@expo/vector-icons": "^15.0.3",
165
- "@oxy.so/core": "4.8.0",
166
+ "@oxy.so/core": "4.9.0",
166
167
  "@react-native-async-storage/async-storage": "^2.0.0",
167
168
  "@react-native-community/netinfo": "^12.0.1",
168
169
  "@react-native/babel-preset": "^0.86.2",
@@ -215,7 +216,7 @@
215
216
  "peerDependencies": {
216
217
  "@expo/vector-icons": "^15.0.3",
217
218
  "@oxy.so/bloom": ">=6.2.1 <6.3.0 || >=7.1.2 <7.2.0 || >=7.7.0 <7.8.0 || >=7.13.1 <7.34.0 || >=8.0.0 <8.2.0 || >=9.0.0 <9.1.0 || >=10.0.0",
218
- "@oxy.so/core": "4.8.0",
219
+ "@oxy.so/core": "4.9.0",
219
220
  "@react-native-async-storage/async-storage": "^2.0.0",
220
221
  "@react-native-community/netinfo": ">=11.4.1 <13.0.0",
221
222
  "@tanstack/query-async-storage-persister": "^5.101",
package/src/index.ts CHANGED
@@ -453,3 +453,8 @@ export {
453
453
  usePersonalPlans,
454
454
  usePersonalPlanSubscriptions,
455
455
  } from './ui/hooks/queries/usePersonalPlans';
456
+ export { AccountInvitationAcceptance } from './ui/components/AccountInvitationAcceptance';
457
+
458
+ export { DisplayCurrencySelector } from './ui/components/DisplayCurrencySelector';
459
+ export { useDisplayCurrency } from './ui/hooks/useDisplayCurrency';
460
+ export { useDisplayPrice } from './ui/hooks/useDisplayPrice';
@@ -0,0 +1,90 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import { Text } from '@oxy.so/bloom/typography';
4
+ import { Button } from '@oxy.so/bloom/button';
5
+ import type { AccountInvitationPreview } from '@oxy.so/core';
6
+ import { getNormalizedUserHandle } from '@oxy.so/core';
7
+ import { useOxy } from '../context/OxyContext';
8
+
9
+ /** The link never grants access on navigation, preview, sign-in or account switching. */
10
+ export function AccountInvitationAcceptance({ token }: { token: string }) {
11
+ return <InvitationContent key={token} token={token} />;
12
+ }
13
+
14
+ function InvitationContent({ token }: { token: string }) {
15
+ const { oxyServices, user, canUsePrivateApi, openAccountDialog } = useOxy();
16
+ const [preview, setPreview] = useState<AccountInvitationPreview | null>(null);
17
+ const [error, setError] = useState('');
18
+ const [busy, setBusy] = useState(false);
19
+ const [accepted, setAccepted] = useState(false);
20
+ useEffect(() => {
21
+ let alive = true;
22
+ setPreview(null);
23
+ setError('');
24
+ if (canUsePrivateApi && token) {
25
+ oxyServices.accounts.invitations
26
+ .preview(token)
27
+ .then((value) => {
28
+ if (alive) setPreview(value);
29
+ })
30
+ .catch((reason) => {
31
+ if (alive) setError(reason instanceof Error ? reason.message : 'Invitation unavailable');
32
+ });
33
+ }
34
+ return () => {
35
+ alive = false;
36
+ };
37
+ }, [oxyServices, canUsePrivateApi, token]);
38
+
39
+ const accept = async () => {
40
+ if (!user || !preview) return;
41
+ setBusy(true);
42
+ setError('');
43
+ try {
44
+ await oxyServices.accounts.invitations.accept(token, user.id);
45
+ setAccepted(true);
46
+ } catch (reason) {
47
+ setError(reason instanceof Error ? reason.message : 'Could not accept invitation');
48
+ } finally {
49
+ setBusy(false);
50
+ }
51
+ };
52
+ return (
53
+ <View className="gap-space-16 p-space-24">
54
+ <Text className="text-title font-semibold text-text">Account invitation</Text>
55
+ {canUsePrivateApi && token && !preview && !error ? (
56
+ <Text accessibilityRole="progressbar">Loading invitation…</Text>
57
+ ) : null}
58
+ {accepted ? (
59
+ <Text>Invitation accepted. You can now access this account.</Text>
60
+ ) : (
61
+ <>
62
+ {preview ? (
63
+ <Text>
64
+ Join {preview.account.displayName ?? preview.account.username} as{' '}
65
+ {preview.invitation.role}.
66
+ </Text>
67
+ ) : null}
68
+ <Text>
69
+ Selected account:{' '}
70
+ {user
71
+ ? `${user.name?.displayName ?? getNormalizedUserHandle(user) ?? user.id} (${user.email ?? user.id})`
72
+ : 'Not signed in'}
73
+ </Text>
74
+ <Text>
75
+ The selected account receives access. Its email can differ from the invitation address.
76
+ No access is granted until you confirm.
77
+ </Text>
78
+ <Button disabled={busy} onPress={() => openAccountDialog()}>
79
+ {user ? 'Change account' : 'Sign in or create account'}
80
+ </Button>
81
+ <Button disabled={busy || !user || !preview || user.kind !== 'personal'} onPress={accept}>
82
+ Confirm and accept
83
+ </Button>
84
+ </>
85
+ )}
86
+ {error ? <Text accessibilityRole="alert">{error}</Text> : null}
87
+ {!token ? <Text>This invitation link is incomplete.</Text> : null}
88
+ </View>
89
+ );
90
+ }
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import {
4
+ SegmentedControl,
5
+ SegmentedControlItem,
6
+ SegmentedControlItemText,
7
+ } from '@oxy.so/bloom/segmented-control';
8
+ import { Text } from '@oxy.so/bloom/typography';
9
+ import { DISPLAY_CURRENCIES, useDisplayCurrency } from '../hooks/useDisplayCurrency';
10
+ import { useI18n } from '../hooks/useI18n';
11
+
12
+ export function DisplayCurrencySelector() {
13
+ const { currency, selectCurrency, isSaving, error } = useDisplayCurrency();
14
+ const { t } = useI18n();
15
+ return (
16
+ <View>
17
+ <Text>{t('preferences.displayCurrency.label')}</Text>
18
+ <SegmentedControl
19
+ type="radio"
20
+ label={t('preferences.displayCurrency.label')}
21
+ value={currency}
22
+ onValueChange={(value) => {
23
+ const selected = DISPLAY_CURRENCIES.find((item) => item === value);
24
+ if (selected && !isSaving) selectCurrency(selected);
25
+ }}
26
+ >
27
+ {DISPLAY_CURRENCIES.map((value) => (
28
+ <SegmentedControlItem key={value} value={value} disabled={isSaving}>
29
+ <SegmentedControlItemText>{value}</SegmentedControlItemText>
30
+ </SegmentedControlItem>
31
+ ))}
32
+ </SegmentedControl>
33
+ {error && (
34
+ <Text accessibilityLiveRegion="polite">{t('preferences.displayCurrency.saveFailed')}</Text>
35
+ )}
36
+ </View>
37
+ );
38
+ }
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { AccountInvitationAcceptance } from '../AccountInvitationAcceptance';
4
+ const mockPreview = jest.fn();
5
+ const mockAccept = jest.fn();
6
+ const mockSwitch = jest.fn();
7
+ let mockUser: { id: string; kind: string; email: string; username: string } | null = null;
8
+ const mockServices = { accounts: { invitations: { preview: mockPreview, accept: mockAccept } } };
9
+ jest.mock('../../context/OxyContext', () => ({
10
+ useOxy: () => ({
11
+ oxyServices: mockServices,
12
+ user: mockUser,
13
+ canUsePrivateApi: !!mockUser,
14
+ openAccountDialog: mockSwitch,
15
+ }),
16
+ }));
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ mockUser = null;
20
+ mockPreview.mockResolvedValue({
21
+ invitation: { role: 'editor' },
22
+ account: { displayName: 'Community' },
23
+ });
24
+ mockAccept.mockResolvedValue({ success: true });
25
+ });
26
+ test('sign-in and switching never accept; confirmation names the displayed selected account', async () => {
27
+ const { rerender } = render(<AccountInvitationAcceptance token="secret" />);
28
+ fireEvent.click(screen.getByText('Sign in or create account'));
29
+ expect(mockSwitch).toHaveBeenCalled();
30
+ expect(mockAccept).not.toHaveBeenCalled();
31
+ mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'different@example.com' };
32
+ rerender(<AccountInvitationAcceptance token="secret" />);
33
+ await screen.findByText('Join Community as editor.');
34
+ fireEvent.click(screen.getByText('Change account'));
35
+ expect(mockAccept).not.toHaveBeenCalled();
36
+ mockUser = { id: 'second', kind: 'personal', username: 'second', email: 'second@example.com' };
37
+ rerender(<AccountInvitationAcceptance token="secret" />);
38
+ expect(screen.getByText(/Selected account: second/)).toBeTruthy();
39
+ fireEvent.click(screen.getByText('Confirm and accept'));
40
+ await waitFor(() => expect(mockAccept).toHaveBeenCalledWith('secret', 'second'));
41
+ await screen.findByText('Invitation accepted. You can now access this account.');
42
+ });
43
+ test('a new link resets previous acceptance and does not auto-accept', async () => {
44
+ mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'first@example.com' };
45
+ const { rerender } = render(<AccountInvitationAcceptance token="old" />);
46
+ await screen.findByText('Join Community as editor.');
47
+ fireEvent.click(screen.getByText('Confirm and accept'));
48
+ await screen.findByText('Invitation accepted. You can now access this account.');
49
+ rerender(<AccountInvitationAcceptance token="new" />);
50
+ await screen.findByText('Join Community as editor.');
51
+ expect(screen.queryByText('Invitation accepted. You can now access this account.')).toBeNull();
52
+ expect(mockAccept).toHaveBeenCalledTimes(1);
53
+ });
54
+ test('preview failure leaves confirmation disabled', async () => {
55
+ mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'first@example.com' };
56
+ mockPreview.mockRejectedValue(new Error('Invitation expired'));
57
+ render(<AccountInvitationAcceptance token="expired" />);
58
+ await screen.findByText('Invitation expired');
59
+ fireEvent.click(screen.getByText('Confirm and accept'));
60
+ expect(mockAccept).not.toHaveBeenCalled();
61
+ });
@@ -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
+ }
@@ -30,13 +30,8 @@ const ASSIGNABLE_ROLES: AssignableRole[] = ['admin', 'editor', 'developer', 'bil
30
30
  const errorMessage = (error: unknown, fallback: string): string =>
31
31
  error instanceof Error && error.message ? error.message : fallback;
32
32
 
33
- const isUserNotFound = (error: unknown): boolean => {
34
- const message = error instanceof Error ? error.message.toLowerCase() : '';
35
- return message.includes('user not found') || message.includes('not found');
36
- };
37
-
38
33
  /**
39
- * Manage the members of an account (invite by username/email, change roles,
34
+ * Manage the members of an account (invite by email, change roles,
40
35
  * remove, transfer ownership). Gated on the caller's `callerMembership`
41
36
  * permissions. Receives the target `accountId` via navigation props.
42
37
  */
@@ -86,6 +81,27 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
86
81
  queryFn: () => oxyServices.accounts.members.list(id),
87
82
  enabled: canUsePrivateApi && id.length > 0 && canRead,
88
83
  });
84
+ const invitationsQuery = useQuery({
85
+ queryKey: ['account-invitations', id],
86
+ queryFn: () => oxyServices.accounts.invitations.list(id),
87
+ enabled: canUsePrivateApi && !!id && canRead,
88
+ });
89
+ const invitationAction = useMutation({
90
+ mutationFn: async ({
91
+ invitationId,
92
+ action,
93
+ }: {
94
+ invitationId: string;
95
+ action: 'resend' | 'cancel';
96
+ }) => {
97
+ if (action === 'resend') await oxyServices.accounts.invitations.resend(id, invitationId);
98
+ else await oxyServices.accounts.invitations.cancel(id, invitationId);
99
+ },
100
+ onSuccess: () => {
101
+ void invitationsQuery.refetch();
102
+ },
103
+ onError: (error) => toast.error(errorMessage(error, 'Could not update invitation')),
104
+ });
89
105
  const members = useMemo<AccountMember[]>(() => membersQuery.data ?? [], [membersQuery.data]);
90
106
  const memberUserIds = useMemo(
91
107
  () => [...new Set(members.map((member) => member.memberUserId).filter((id) => id.length > 0))],
@@ -126,6 +142,7 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
126
142
  // tables — mirror the SDK's per-account prefix sweep so every cached member
127
143
  // list refetches, not only the account that was mutated.
128
144
  queryClient.invalidateQueries({ queryKey: ['accounts', 'members'] });
145
+ queryClient.invalidateQueries({ queryKey: ['account-invitations', id] });
129
146
  queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
130
147
  }, [queryClient, id]);
131
148
 
@@ -137,24 +154,20 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
137
154
  const inviteMutation = useMutation({
138
155
  mutationKey: ['accounts', 'members', 'invite', id],
139
156
  mutationFn: (input: { usernameOrEmail: string; role: AssignableRole }) =>
140
- oxyServices.accounts.members.invite(id, input),
157
+ oxyServices.accounts.invitations.create(id, {
158
+ email: input.usernameOrEmail,
159
+ role: input.role,
160
+ }),
141
161
  onSuccess: () => {
142
162
  setInviteIdentifier('');
143
163
  setInviteRole('editor');
144
164
  setInviteError(null);
145
165
  invalidateMembers();
146
- toast.success(t('accounts.members.toasts.added') || 'Member added');
166
+ toast.success('Invitation sent');
147
167
  },
148
168
  onError: (error) => {
149
- if (isUserNotFound(error)) {
150
- const message = t('accounts.members.errors.userNotFound') || 'User not found';
151
- setInviteError(message);
152
- toast.error(message);
153
- return;
154
- }
155
- toast.error(
156
- errorMessage(error, t('accounts.members.toasts.addFailed') || 'Failed to add member'),
157
- );
169
+ invalidateMembers();
170
+ toast.error(errorMessage(error, 'Could not send invitation'));
158
171
  },
159
172
  });
160
173
 
@@ -207,14 +220,12 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
207
220
  const handleInvite = useCallback(() => {
208
221
  const usernameOrEmail = inviteIdentifier.trim();
209
222
  if (!usernameOrEmail) {
210
- setInviteError(
211
- t('accounts.members.errors.identifierRequired') || 'Enter a username or email to invite',
212
- );
223
+ setInviteError('Enter an email to invite');
213
224
  return;
214
225
  }
215
226
  setInviteError(null);
216
227
  inviteMutation.mutate({ usernameOrEmail, role: inviteRole });
217
- }, [inviteIdentifier, inviteRole, inviteMutation, t]);
228
+ }, [inviteIdentifier, inviteRole, inviteMutation]);
218
229
 
219
230
  const handleChangeRole = useCallback(
220
231
  (member: AccountMember, role: AccountRole) => {
@@ -282,13 +293,11 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
282
293
  {/* Invite form */}
283
294
  {canInvite ? (
284
295
  <View className="gap-space-12 p-space-16 rounded-radius-20 bg-fill">
285
- <Text className="text-body font-semibold text-text">
286
- {t('accounts.members.invite.title') || 'Add a member'}
287
- </Text>
296
+ <Text className="text-body font-semibold text-text">{'Invite a member'}</Text>
288
297
  <TextField isInvalid={!!inviteError}>
289
298
  <TextFieldInput
290
299
  floatingLabel
291
- label={t('accounts.members.invite.identifierLabel') || 'Username or email'}
300
+ label={'Email'}
292
301
  value={inviteIdentifier}
293
302
  onChangeText={(value) => {
294
303
  setInviteIdentifier(value);
@@ -332,14 +341,49 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
332
341
  onPress={handleInvite}
333
342
  disabled={inviteMutation.isPending || !inviteIdentifier.trim()}
334
343
  loading={inviteMutation.isPending}
335
- accessibilityLabel={t('accounts.members.invite.submit') || 'Add member'}
344
+ accessibilityLabel={'Send invitation'}
336
345
  className="w-full"
337
346
  >
338
- {t('accounts.members.invite.submit') || 'Add member'}
347
+ {'Send invitation'}
339
348
  </Button>
340
349
  </View>
341
350
  ) : null}
342
351
 
352
+ {canRead ? (
353
+ <View className="gap-space-12">
354
+ <Text className="text-body font-semibold text-text">Pending invitations</Text>
355
+ {invitationsQuery.isError ? <Text>Could not load invitations.</Text> : null}
356
+ {(invitationsQuery.data ?? []).map((invitation) => (
357
+ <View key={invitation.id} className="gap-space-8 p-space-16 rounded-radius-20 bg-fill">
358
+ <Text>
359
+ {invitation.email} · {roleLabel(invitation.role)} · {invitation.status}
360
+ </Text>
361
+ <Text>Expires {new Date(invitation.expiresAt).toLocaleDateString()}</Text>
362
+ {canInvite ? (
363
+ <Button
364
+ disabled={invitationAction.isPending}
365
+ onPress={() =>
366
+ invitationAction.mutate({ invitationId: invitation.id, action: 'resend' })
367
+ }
368
+ >
369
+ Resend
370
+ </Button>
371
+ ) : null}
372
+ {canRemove ? (
373
+ <Button
374
+ disabled={invitationAction.isPending}
375
+ onPress={() =>
376
+ invitationAction.mutate({ invitationId: invitation.id, action: 'cancel' })
377
+ }
378
+ >
379
+ Cancel invitation
380
+ </Button>
381
+ ) : null}
382
+ </View>
383
+ ))}
384
+ </View>
385
+ ) : null}
386
+
343
387
  {/* Member list */}
344
388
  {canRead ? (
345
389
  <View className="gap-space-8">
@@ -5,6 +5,7 @@ import { Switch } from '@oxy.so/bloom/switch';
5
5
  import { useTheme } from '@oxy.so/bloom/theme';
6
6
  import type { UserPreferences } from '@oxy.so/core';
7
7
  import { getNativeLanguageName } from '@oxy.so/core';
8
+ import { DisplayCurrencySelector } from '../components/DisplayCurrencySelector';
8
9
  import type { BaseScreenProps } from '../types/navigation';
9
10
  import { SettingsIcon } from '../components/SettingsIcon';
10
11
  import { useI18n } from '../hooks/useI18n';
@@ -158,6 +159,7 @@ const PreferencesScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, navigat
158
159
  </SettingsListGroup>
159
160
 
160
161
  <SettingsListGroup title={t('preferences.sections.region') || 'Region'}>
162
+ <DisplayCurrencySelector />
161
163
  <SettingsListItem
162
164
  icon={<SettingsIcon name="clock-outline" color={bloomTheme.colors.info} />}
163
165
  title={t('preferences.items.timezone.title') || 'Timezone'}