@oxy.so/services 11.9.0 → 11.10.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.
- package/lib/commonjs/index.js +28 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/AccountInvitationAcceptance.js +97 -0
- package/lib/commonjs/ui/components/AccountInvitationAcceptance.js.map +1 -0
- package/lib/commonjs/ui/components/DisplayCurrencySelector.js +49 -0
- package/lib/commonjs/ui/components/DisplayCurrencySelector.js.map +1 -0
- package/lib/commonjs/ui/hooks/useDisplayCurrency.js +77 -0
- package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +1 -0
- package/lib/commonjs/ui/hooks/useDisplayPrice.js +59 -0
- package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +1 -0
- package/lib/commonjs/ui/screens/AccountMembersScreen.js +63 -20
- package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/PreferencesScreen.js +4 -3
- package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/AccountInvitationAcceptance.js +94 -0
- package/lib/module/ui/components/AccountInvitationAcceptance.js.map +1 -0
- package/lib/module/ui/components/DisplayCurrencySelector.js +44 -0
- package/lib/module/ui/components/DisplayCurrencySelector.js.map +1 -0
- package/lib/module/ui/hooks/useDisplayCurrency.js +72 -0
- package/lib/module/ui/hooks/useDisplayCurrency.js.map +1 -0
- package/lib/module/ui/hooks/useDisplayPrice.js +56 -0
- package/lib/module/ui/hooks/useDisplayPrice.js.map +1 -0
- package/lib/module/ui/screens/AccountMembersScreen.js +63 -20
- package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/module/ui/screens/PreferencesScreen.js +4 -3
- package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +5 -0
- package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +3 -0
- package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +11 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +6 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +5 -0
- package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +3 -0
- package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +11 -0
- package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +6 -0
- package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/index.ts +5 -0
- package/src/ui/components/AccountInvitationAcceptance.tsx +90 -0
- package/src/ui/components/DisplayCurrencySelector.tsx +38 -0
- package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +61 -0
- package/src/ui/hooks/useDisplayCurrency.ts +64 -0
- package/src/ui/hooks/useDisplayPrice.ts +40 -0
- package/src/ui/screens/AccountMembersScreen.tsx +71 -27
- 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;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
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
|
|
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;
|
|
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;
|
|
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
|
+
"version": "11.10.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",
|
|
@@ -140,14 +140,15 @@
|
|
|
140
140
|
},
|
|
141
141
|
"dependencies": {
|
|
142
142
|
"@expo/config-plugins": "57.0.9",
|
|
143
|
-
"@oxy.so/contracts": "^4.
|
|
144
|
-
"color": "^5.0.3"
|
|
143
|
+
"@oxy.so/contracts": "^4.23.0",
|
|
144
|
+
"color": "^5.0.3",
|
|
145
|
+
"@peable.to/sdk": "0.2.7"
|
|
145
146
|
},
|
|
146
147
|
"devDependencies": {
|
|
147
148
|
"@commitlint/cli": "^21.2.1",
|
|
148
149
|
"@commitlint/config-conventional": "^21.2.0",
|
|
149
150
|
"@expo/vector-icons": "^15.0.3",
|
|
150
|
-
"@oxy.so/core": "4.
|
|
151
|
+
"@oxy.so/core": "4.19.0",
|
|
151
152
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
152
153
|
"@react-native-community/netinfo": "^12.0.1",
|
|
153
154
|
"@react-native/babel-preset": "^0.86.2",
|
|
@@ -200,7 +201,7 @@
|
|
|
200
201
|
"peerDependencies": {
|
|
201
202
|
"@expo/vector-icons": "^15.0.3",
|
|
202
203
|
"@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",
|
|
203
|
-
"@oxy.so/core": "^4.
|
|
204
|
+
"@oxy.so/core": "^4.19.0",
|
|
204
205
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
205
206
|
"@react-native-community/netinfo": ">=11.4.1 <13.0.0",
|
|
206
207
|
"@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
|
|
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.
|
|
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(
|
|
166
|
+
toast.success('Invitation sent');
|
|
147
167
|
},
|
|
148
168
|
onError: (error) => {
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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={
|
|
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={
|
|
344
|
+
accessibilityLabel={'Send invitation'}
|
|
336
345
|
className="w-full"
|
|
337
346
|
>
|
|
338
|
-
{
|
|
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'}
|