@tuturuuu/ui 0.22.0 → 0.24.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/CHANGELOG.md +36 -0
- package/biome.json +1 -1
- package/package.json +78 -78
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
- package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
- package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
- package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
- package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
- package/src/components/ui/chat/chat-utils.test.ts +107 -0
- package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
- package/src/components/ui/chat/chat-workspace.tsx +66 -24
- package/src/components/ui/chat/hooks-conversations.ts +9 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -0
- package/src/components/ui/chat/message-bubble.tsx +49 -4
- package/src/components/ui/chat/message-composer.tsx +29 -23
- package/src/components/ui/chat/message-markdown.tsx +4 -1
- package/src/components/ui/chat/query-keys.ts +7 -1
- package/src/components/ui/chat/utils.ts +85 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
- package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
- package/src/components/ui/custom/workspace-select.tsx +37 -38
- package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
- package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
- package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
- package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
- package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
- package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
- package/src/components/ui/legacy/meet/form.tsx +25 -1
- package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
- package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
- package/src/components/ui/legacy/meet/page.tsx +1 -0
- package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
- package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
- package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
- package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
- package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
- package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
- package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
- package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
- package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
- package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
- package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
- package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
- package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
- package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
- package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
- package/src/components/ui/nuqs-adapter.tsx +1 -0
- package/src/hooks/time-blocking-provider.tsx +127 -211
|
@@ -13,13 +13,19 @@ export const chatQueryKeys = {
|
|
|
13
13
|
] as const,
|
|
14
14
|
conversations: (wsId: string, archived = 'active') =>
|
|
15
15
|
[...chatQueryKeys.all(wsId), 'conversations', archived] as const,
|
|
16
|
-
conversationsInfinite: (
|
|
16
|
+
conversationsInfinite: (
|
|
17
|
+
wsId: string,
|
|
18
|
+
archived = 'active',
|
|
19
|
+
limit = 40,
|
|
20
|
+
scope: 'all' | 'external' = 'all'
|
|
21
|
+
) =>
|
|
17
22
|
[
|
|
18
23
|
...chatQueryKeys.all(wsId),
|
|
19
24
|
'conversations',
|
|
20
25
|
archived,
|
|
21
26
|
'infinite',
|
|
22
27
|
limit,
|
|
28
|
+
scope,
|
|
23
29
|
] as const,
|
|
24
30
|
directory: (wsId: string, query: string) =>
|
|
25
31
|
[...chatQueryKeys.all(wsId), 'directory', query] as const,
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
ChatUserProfile,
|
|
6
6
|
} from '@tuturuuu/internal-api';
|
|
7
7
|
|
|
8
|
-
export type ChatConversationScope = 'personal' | 'workspaces';
|
|
8
|
+
export type ChatConversationScope = 'external' | 'personal' | 'workspaces';
|
|
9
9
|
export type ChatConversationArchiveFilter = 'active' | 'all' | 'archived';
|
|
10
10
|
|
|
11
11
|
export const DEFAULT_CHAT_SCOPE: ChatConversationScope = 'personal';
|
|
@@ -16,15 +16,54 @@ export const CHAT_CONVERSATION_TYPE_FILTERS = [
|
|
|
16
16
|
'ai',
|
|
17
17
|
] as const satisfies ChatConversationType[];
|
|
18
18
|
|
|
19
|
+
export type PendingChatSendRequest = {
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
requestId: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function resolvePendingChatSendRequest(
|
|
25
|
+
previous: PendingChatSendRequest | null,
|
|
26
|
+
payload: {
|
|
27
|
+
attachments: Array<{
|
|
28
|
+
filename: string;
|
|
29
|
+
path: string;
|
|
30
|
+
sizeBytes?: number | null;
|
|
31
|
+
}>;
|
|
32
|
+
content: string;
|
|
33
|
+
},
|
|
34
|
+
createRequestId: () => string
|
|
35
|
+
): PendingChatSendRequest {
|
|
36
|
+
const fingerprint = JSON.stringify({
|
|
37
|
+
attachments: payload.attachments.map((attachment) => ({
|
|
38
|
+
filename: attachment.filename,
|
|
39
|
+
path: attachment.path,
|
|
40
|
+
sizeBytes: attachment.sizeBytes,
|
|
41
|
+
})),
|
|
42
|
+
content: payload.content,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return previous?.fingerprint === fingerprint
|
|
46
|
+
? previous
|
|
47
|
+
: { fingerprint, requestId: createRequestId() };
|
|
48
|
+
}
|
|
49
|
+
|
|
19
50
|
export function normalizeChatConversationScope(
|
|
20
|
-
scope?: string | null
|
|
51
|
+
scope?: string | null,
|
|
52
|
+
fallback: ChatConversationScope = 'personal'
|
|
21
53
|
): ChatConversationScope {
|
|
22
|
-
|
|
54
|
+
if (scope === 'external' || scope === 'personal' || scope === 'workspaces') {
|
|
55
|
+
return scope;
|
|
56
|
+
}
|
|
57
|
+
return fallback;
|
|
23
58
|
}
|
|
24
59
|
|
|
25
60
|
export function getChatConversationScope(
|
|
26
61
|
conversation: Pick<ChatConversation, 'metadata' | 'type'>
|
|
27
62
|
): ChatConversationScope {
|
|
63
|
+
if (conversation.metadata?.externalChat === true) {
|
|
64
|
+
return 'external';
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
if (conversation.metadata?.scope === 'personal') {
|
|
29
68
|
return 'personal';
|
|
30
69
|
}
|
|
@@ -63,6 +102,8 @@ export function isChatConversation(value: unknown): value is ChatConversation {
|
|
|
63
102
|
export function getChatConversationTypesForScope(
|
|
64
103
|
scope: ChatConversationScope
|
|
65
104
|
): ChatConversationType[] {
|
|
105
|
+
if (scope === 'external') return ['channel'];
|
|
106
|
+
|
|
66
107
|
return scope === 'personal'
|
|
67
108
|
? ['direct', 'group', 'channel', 'ai']
|
|
68
109
|
: ['channel', 'ai'];
|
|
@@ -115,6 +156,35 @@ export function getChatInitials(profile?: ChatUserProfile | string | null) {
|
|
|
115
156
|
.slice(0, 2);
|
|
116
157
|
}
|
|
117
158
|
|
|
159
|
+
export function getChatMessageSenderLabel(
|
|
160
|
+
message: Pick<ChatMessage, 'kind' | 'metadata' | 'sender'>,
|
|
161
|
+
fallback: { assistant: string; external: string; unknown: string }
|
|
162
|
+
) {
|
|
163
|
+
const nativeDisplayName = readNonEmptyString(message.sender?.displayName);
|
|
164
|
+
if (nativeDisplayName) return nativeDisplayName;
|
|
165
|
+
if (message.kind === 'assistant') return fallback.assistant;
|
|
166
|
+
|
|
167
|
+
const externalSender = message.metadata?.externalSender;
|
|
168
|
+
if (isRecord(externalSender)) {
|
|
169
|
+
const displayName =
|
|
170
|
+
readNonEmptyString(externalSender.displayName) ??
|
|
171
|
+
readNonEmptyString(externalSender.name);
|
|
172
|
+
if (displayName) return displayName;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return message.metadata?.externalChat === true
|
|
176
|
+
? fallback.external
|
|
177
|
+
: fallback.unknown;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
181
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readNonEmptyString(value: unknown) {
|
|
185
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
118
188
|
export function getConversationTitle(
|
|
119
189
|
conversation: ChatConversation,
|
|
120
190
|
currentUserId: string,
|
|
@@ -237,6 +307,18 @@ export function isReadOnlyChatConversation(
|
|
|
237
307
|
);
|
|
238
308
|
}
|
|
239
309
|
|
|
310
|
+
export function canPersistChatReadState({
|
|
311
|
+
externalChat,
|
|
312
|
+
hasMembership,
|
|
313
|
+
readOnly,
|
|
314
|
+
}: {
|
|
315
|
+
externalChat: boolean;
|
|
316
|
+
hasMembership: boolean;
|
|
317
|
+
readOnly: boolean;
|
|
318
|
+
}) {
|
|
319
|
+
return !readOnly && (hasMembership || externalChat);
|
|
320
|
+
}
|
|
321
|
+
|
|
240
322
|
export function getChatSelectionStorageKey(
|
|
241
323
|
wsId: string,
|
|
242
324
|
scope: ChatConversationScope
|
|
@@ -3,10 +3,82 @@ import { join } from 'node:path';
|
|
|
3
3
|
import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
import {
|
|
6
|
+
buildWorkspaceSetupHandoffUrl,
|
|
6
7
|
mergeWorkspaceSelectWorkspaces,
|
|
7
8
|
normalizeWorkspaceSwitchPath,
|
|
9
|
+
resolveWorkspaceAvatarUrl,
|
|
8
10
|
} from '../workspace-select-helpers';
|
|
9
11
|
|
|
12
|
+
describe('buildWorkspaceSetupHandoffUrl', () => {
|
|
13
|
+
it('builds a localized Platform setup URL with an encoded satellite return', () => {
|
|
14
|
+
const handoffUrl = new URL(
|
|
15
|
+
buildWorkspaceSetupHandoffUrl({
|
|
16
|
+
locale: 'vi',
|
|
17
|
+
platformUrl: 'https://tuturuuu.com',
|
|
18
|
+
returnOrigin: 'https://tasks.tuturuuu.com',
|
|
19
|
+
returnPath: '/workspace-1/tasks?view=mine',
|
|
20
|
+
workspaceId: 'workspace-1',
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(handoffUrl.pathname).toBe('/vi/workspace-1/workspace-setup');
|
|
25
|
+
expect(handoffUrl.searchParams.get('returnUrl')).toBe(
|
|
26
|
+
'https://tasks.tuturuuu.com/vi/workspace-1/tasks?view=mine'
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does not duplicate an existing locale prefix', () => {
|
|
31
|
+
const handoffUrl = new URL(
|
|
32
|
+
buildWorkspaceSetupHandoffUrl({
|
|
33
|
+
locale: 'en',
|
|
34
|
+
platformUrl: 'http://platform.tuturuuu.localhost',
|
|
35
|
+
returnOrigin: 'http://meet.tuturuuu.localhost',
|
|
36
|
+
returnPath: '/en/workspace/workspace-1',
|
|
37
|
+
workspaceId: 'workspace-1',
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(handoffUrl.searchParams.get('returnUrl')).toBe(
|
|
42
|
+
'http://meet.tuturuuu.localhost/en/workspace/workspace-1'
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects a return path that changes the satellite origin', () => {
|
|
47
|
+
expect(() =>
|
|
48
|
+
buildWorkspaceSetupHandoffUrl({
|
|
49
|
+
locale: 'en',
|
|
50
|
+
platformUrl: 'https://tuturuuu.com',
|
|
51
|
+
returnOrigin: 'https://tasks.tuturuuu.com',
|
|
52
|
+
returnPath: 'https://evil.example/workspace-1',
|
|
53
|
+
workspaceId: 'workspace-1',
|
|
54
|
+
})
|
|
55
|
+
).toThrow('Workspace return path must stay on the current app');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('resolveWorkspaceAvatarUrl', () => {
|
|
60
|
+
it('preserves a valid hosted avatar when an app provides a local fallback', () => {
|
|
61
|
+
expect(
|
|
62
|
+
resolveWorkspaceAvatarUrl(
|
|
63
|
+
'https://tuturuuu.com/media/logos/transparent.png',
|
|
64
|
+
{
|
|
65
|
+
rootWorkspaceLogoUrl: '/media/logos/transparent.png',
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
).toBe('https://tuturuuu.com/media/logos/transparent.png');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('uses the canonical root logo only when the root workspace has no avatar', () => {
|
|
72
|
+
expect(
|
|
73
|
+
resolveWorkspaceAvatarUrl(null, {
|
|
74
|
+
rootWorkspaceLogoUrl:
|
|
75
|
+
'https://tuturuuu.com/media/logos/transparent.png',
|
|
76
|
+
})
|
|
77
|
+
).toBe('https://tuturuuu.com/media/logos/transparent.png');
|
|
78
|
+
expect(resolveWorkspaceAvatarUrl(null)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
10
82
|
describe('mergeWorkspaceSelectWorkspaces', () => {
|
|
11
83
|
it('uses the current workspace fallback when the workspace list is unavailable', () => {
|
|
12
84
|
const fallback: InternalApiWorkspaceSummary = {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { MessageSquareText, Play, RotateCcw } from '@tuturuuu/icons';
|
|
4
|
+
import { updateConnectedOnboardingProgress } from '@tuturuuu/internal-api/onboarding';
|
|
5
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
6
|
+
import {
|
|
7
|
+
getLaunchableApp,
|
|
8
|
+
getLaunchableAppByHostname,
|
|
9
|
+
resolveLaunchableAppUrl,
|
|
10
|
+
} from '@tuturuuu/utils/launchable-apps';
|
|
11
|
+
import { useTranslations } from 'next-intl';
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
|
|
14
|
+
function currentAppSlug() {
|
|
15
|
+
if (typeof window === 'undefined') return 'platform';
|
|
16
|
+
return (
|
|
17
|
+
getLaunchableAppByHostname(window.location.hostname)?.slug ?? 'platform'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function closeSettingsDialog() {
|
|
22
|
+
window.dispatchEvent(new Event('tuturuuu:settings-dialog-close-intent'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function OnboardingSettingsPanel() {
|
|
26
|
+
const t = useTranslations('onboarding_guide');
|
|
27
|
+
const [pending, setPending] = useState<'replay' | 'restart' | null>(null);
|
|
28
|
+
|
|
29
|
+
const replayApp = async () => {
|
|
30
|
+
const appSlug = currentAppSlug();
|
|
31
|
+
setPending('replay');
|
|
32
|
+
await updateConnectedOnboardingProgress({
|
|
33
|
+
dismissed_at: null,
|
|
34
|
+
replay_app: appSlug,
|
|
35
|
+
}).catch(() => null);
|
|
36
|
+
setPending(null);
|
|
37
|
+
closeSettingsDialog();
|
|
38
|
+
window.dispatchEvent(
|
|
39
|
+
new CustomEvent('tuturuuu:onboarding-replay', { detail: { appSlug } })
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const restartJourney = async () => {
|
|
44
|
+
setPending('restart');
|
|
45
|
+
await updateConnectedOnboardingProgress({
|
|
46
|
+
completed_missions: [],
|
|
47
|
+
dismissed_at: null,
|
|
48
|
+
goals: [],
|
|
49
|
+
guidance_mode: 'standard',
|
|
50
|
+
journey_revision: 2,
|
|
51
|
+
persona: null,
|
|
52
|
+
replay_app: 'platform',
|
|
53
|
+
}).catch(() => null);
|
|
54
|
+
|
|
55
|
+
const platform = getLaunchableApp('platform');
|
|
56
|
+
if (!platform) return;
|
|
57
|
+
window.location.assign(
|
|
58
|
+
resolveLaunchableAppUrl({
|
|
59
|
+
app: platform,
|
|
60
|
+
currentOrigin: window.location.origin,
|
|
61
|
+
path: '/personal',
|
|
62
|
+
searchParams: { guide: 'platform' },
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const shareFeedback = () => {
|
|
68
|
+
closeSettingsDialog();
|
|
69
|
+
window.dispatchEvent(
|
|
70
|
+
new CustomEvent('tuturuuu:report-problem-open-intent', {
|
|
71
|
+
detail: { context: `onboarding:${currentAppSlug()}` },
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const actions = [
|
|
77
|
+
{
|
|
78
|
+
description: t('replay_app_description'),
|
|
79
|
+
icon: Play,
|
|
80
|
+
label: t('replay_app'),
|
|
81
|
+
onClick: replayApp,
|
|
82
|
+
pending: pending === 'replay',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
description: t('restart_journey_description'),
|
|
86
|
+
icon: RotateCcw,
|
|
87
|
+
label: t('restart_journey'),
|
|
88
|
+
onClick: restartJourney,
|
|
89
|
+
pending: pending === 'restart',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
description: t('feedback_description'),
|
|
93
|
+
icon: MessageSquareText,
|
|
94
|
+
label: t('feedback'),
|
|
95
|
+
onClick: shareFeedback,
|
|
96
|
+
pending: false,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="space-y-3">
|
|
102
|
+
{actions.map((action) => (
|
|
103
|
+
<div
|
|
104
|
+
key={action.label}
|
|
105
|
+
className="flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
|
|
106
|
+
>
|
|
107
|
+
<div className="flex gap-3">
|
|
108
|
+
<div className="grid size-9 shrink-0 place-items-center rounded-lg border bg-muted/40">
|
|
109
|
+
<action.icon className="size-4" />
|
|
110
|
+
</div>
|
|
111
|
+
<div>
|
|
112
|
+
<p className="font-medium text-sm">{action.label}</p>
|
|
113
|
+
<p className="mt-1 max-w-xl text-muted-foreground text-sm">
|
|
114
|
+
{action.description}
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<Button
|
|
119
|
+
type="button"
|
|
120
|
+
variant="outline"
|
|
121
|
+
disabled={action.pending}
|
|
122
|
+
onClick={action.onClick}
|
|
123
|
+
>
|
|
124
|
+
<action.icon className="size-4" />
|
|
125
|
+
{action.label}
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ArrowLeft,
|
|
5
5
|
ChevronDown,
|
|
6
6
|
ChevronRight,
|
|
7
|
+
Compass,
|
|
7
8
|
Search,
|
|
8
9
|
X,
|
|
9
10
|
} from '@tuturuuu/icons';
|
|
@@ -63,6 +64,7 @@ import { useTranslations } from 'next-intl';
|
|
|
63
64
|
import { parseAsString, useQueryState } from 'nuqs';
|
|
64
65
|
import type { ComponentType, KeyboardEvent, ReactNode } from 'react';
|
|
65
66
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
67
|
+
import { OnboardingSettingsPanel } from './onboarding-settings-panel';
|
|
66
68
|
import type { createSettingsSearchEngine } from './settings-dialog-search';
|
|
67
69
|
import { loadSettingsSearchEngine } from './settings-dialog-search-loader';
|
|
68
70
|
|
|
@@ -158,17 +160,41 @@ export function SettingsDialogShell({
|
|
|
158
160
|
})
|
|
159
161
|
);
|
|
160
162
|
|
|
163
|
+
const extendedNavItems = useMemo<SettingsNavGroup[]>(
|
|
164
|
+
() => [
|
|
165
|
+
...navItems,
|
|
166
|
+
{
|
|
167
|
+
label: t('onboarding_guide.title'),
|
|
168
|
+
items: [
|
|
169
|
+
{
|
|
170
|
+
name: 'onboarding_guide',
|
|
171
|
+
label: t('onboarding_guide.title'),
|
|
172
|
+
description: t('onboarding_guide.description'),
|
|
173
|
+
icon: Compass,
|
|
174
|
+
keywords: [
|
|
175
|
+
t('onboarding_guide.replay_app'),
|
|
176
|
+
t('onboarding_guide.restart_journey'),
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
[navItems, t]
|
|
183
|
+
);
|
|
184
|
+
|
|
161
185
|
const searchEngine = useMemo<SettingsSearchEngine | null>(
|
|
162
|
-
() => searchEngineFactory?.(
|
|
163
|
-
[
|
|
186
|
+
() => searchEngineFactory?.(extendedNavItems) ?? null,
|
|
187
|
+
[extendedNavItems, searchEngineFactory]
|
|
164
188
|
);
|
|
165
189
|
|
|
166
190
|
const allNavItems = useMemo(
|
|
167
|
-
() =>
|
|
168
|
-
|
|
191
|
+
() =>
|
|
192
|
+
searchEngine?.allItems ??
|
|
193
|
+
extendedNavItems.flatMap((group) => group.items),
|
|
194
|
+
[extendedNavItems, searchEngine]
|
|
169
195
|
);
|
|
170
196
|
|
|
171
|
-
const activeGroup =
|
|
197
|
+
const activeGroup = extendedNavItems.find((group) =>
|
|
172
198
|
group.items.some((item) => item.name === activeTab)
|
|
173
199
|
);
|
|
174
200
|
|
|
@@ -209,8 +235,10 @@ export function SettingsDialogShell({
|
|
|
209
235
|
|
|
210
236
|
const filteredNavItems = useMemo(
|
|
211
237
|
() =>
|
|
212
|
-
searchQuery
|
|
213
|
-
|
|
238
|
+
searchQuery
|
|
239
|
+
? (searchEngine?.search(searchQuery) ?? extendedNavItems)
|
|
240
|
+
: extendedNavItems,
|
|
241
|
+
[extendedNavItems, searchEngine, searchQuery]
|
|
214
242
|
);
|
|
215
243
|
|
|
216
244
|
const filteredEnabledItems = useMemo(
|
|
@@ -221,6 +249,11 @@ export function SettingsDialogShell({
|
|
|
221
249
|
),
|
|
222
250
|
[filteredNavItems, searchEngine, searchQuery]
|
|
223
251
|
);
|
|
252
|
+
const keyboardEnabledItems = useMemo(
|
|
253
|
+
() =>
|
|
254
|
+
filteredEnabledItems.filter((item) => item.name !== 'onboarding_guide'),
|
|
255
|
+
[filteredEnabledItems]
|
|
256
|
+
);
|
|
224
257
|
|
|
225
258
|
const updateSearchQuery = useCallback(
|
|
226
259
|
(value: string) => {
|
|
@@ -252,30 +285,30 @@ export function SettingsDialogShell({
|
|
|
252
285
|
|
|
253
286
|
const changeActiveItem = useCallback(
|
|
254
287
|
(targetIndex: number) => {
|
|
255
|
-
const targetItem =
|
|
288
|
+
const targetItem = keyboardEnabledItems[targetIndex];
|
|
256
289
|
if (targetItem) selectActiveTab(targetItem.name);
|
|
257
290
|
},
|
|
258
|
-
[
|
|
291
|
+
[keyboardEnabledItems, selectActiveTab]
|
|
259
292
|
);
|
|
260
293
|
|
|
261
294
|
const moveActiveItem = useCallback(
|
|
262
295
|
(direction: 1 | -1) => {
|
|
263
|
-
if (
|
|
296
|
+
if (keyboardEnabledItems.length === 0) return;
|
|
264
297
|
|
|
265
|
-
const currentIndex =
|
|
298
|
+
const currentIndex = keyboardEnabledItems.findIndex(
|
|
266
299
|
(item) => item.name === activeTab
|
|
267
300
|
);
|
|
268
301
|
const nextIndex =
|
|
269
302
|
currentIndex === -1
|
|
270
303
|
? direction === 1
|
|
271
304
|
? 0
|
|
272
|
-
:
|
|
273
|
-
: (currentIndex + direction +
|
|
274
|
-
|
|
305
|
+
: keyboardEnabledItems.length - 1
|
|
306
|
+
: (currentIndex + direction + keyboardEnabledItems.length) %
|
|
307
|
+
keyboardEnabledItems.length;
|
|
275
308
|
|
|
276
309
|
changeActiveItem(nextIndex);
|
|
277
310
|
},
|
|
278
|
-
[activeTab, changeActiveItem,
|
|
311
|
+
[activeTab, changeActiveItem, keyboardEnabledItems]
|
|
279
312
|
);
|
|
280
313
|
|
|
281
314
|
const handleKeyboardNavigation = useCallback(
|
|
@@ -325,12 +358,12 @@ export function SettingsDialogShell({
|
|
|
325
358
|
|
|
326
359
|
if (event.key === 'End') {
|
|
327
360
|
event.preventDefault();
|
|
328
|
-
changeActiveItem(
|
|
361
|
+
changeActiveItem(keyboardEnabledItems.length - 1);
|
|
329
362
|
}
|
|
330
363
|
},
|
|
331
364
|
[
|
|
332
365
|
changeActiveItem,
|
|
333
|
-
|
|
366
|
+
keyboardEnabledItems.length,
|
|
334
367
|
focusSearch,
|
|
335
368
|
keyboardNavigation,
|
|
336
369
|
moveActiveItem,
|
|
@@ -569,7 +602,11 @@ export function SettingsDialogShell({
|
|
|
569
602
|
<Separator />
|
|
570
603
|
</>
|
|
571
604
|
)}
|
|
572
|
-
{
|
|
605
|
+
{activeTab === 'onboarding_guide' ? (
|
|
606
|
+
<OnboardingSettingsPanel />
|
|
607
|
+
) : (
|
|
608
|
+
children
|
|
609
|
+
)}
|
|
573
610
|
</div>
|
|
574
611
|
</div>
|
|
575
612
|
</main>
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
|
|
2
2
|
|
|
3
|
+
export function resolveWorkspaceAvatarUrl(
|
|
4
|
+
avatarUrl: string | null | undefined,
|
|
5
|
+
{
|
|
6
|
+
rootWorkspaceLogoUrl,
|
|
7
|
+
}: {
|
|
8
|
+
rootWorkspaceLogoUrl?: string;
|
|
9
|
+
} = {}
|
|
10
|
+
) {
|
|
11
|
+
return avatarUrl || rootWorkspaceLogoUrl || null;
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
export function mergeWorkspaceSelectWorkspaces(
|
|
4
15
|
workspaces: InternalApiWorkspaceSummary[] | undefined,
|
|
5
16
|
currentWorkspaceFallback: InternalApiWorkspaceSummary | null | undefined
|
|
@@ -19,6 +30,45 @@ export function mergeWorkspaceSelectWorkspaces(
|
|
|
19
30
|
return [...workspaceList, currentWorkspaceFallback];
|
|
20
31
|
}
|
|
21
32
|
|
|
33
|
+
export function buildWorkspaceSetupHandoffUrl({
|
|
34
|
+
locale,
|
|
35
|
+
platformUrl,
|
|
36
|
+
returnOrigin,
|
|
37
|
+
returnPath,
|
|
38
|
+
workspaceId,
|
|
39
|
+
}: {
|
|
40
|
+
locale: string;
|
|
41
|
+
platformUrl: string;
|
|
42
|
+
returnOrigin: string;
|
|
43
|
+
returnPath: string;
|
|
44
|
+
workspaceId: string;
|
|
45
|
+
}) {
|
|
46
|
+
const origin = new URL(returnOrigin).origin;
|
|
47
|
+
const returnUrl = new URL(returnPath, origin);
|
|
48
|
+
|
|
49
|
+
if (returnUrl.origin !== origin) {
|
|
50
|
+
throw new Error('Workspace return path must stay on the current app');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const localePrefix = `/${locale}`;
|
|
54
|
+
if (
|
|
55
|
+
returnUrl.pathname !== localePrefix &&
|
|
56
|
+
!returnUrl.pathname.startsWith(`${localePrefix}/`)
|
|
57
|
+
) {
|
|
58
|
+
returnUrl.pathname = `${localePrefix}${
|
|
59
|
+
returnUrl.pathname.startsWith('/') ? '' : '/'
|
|
60
|
+
}${returnUrl.pathname}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const setupUrl = new URL(
|
|
64
|
+
`/${locale}/${encodeURIComponent(workspaceId)}/workspace-setup`,
|
|
65
|
+
platformUrl
|
|
66
|
+
);
|
|
67
|
+
setupUrl.searchParams.set('returnUrl', returnUrl.toString());
|
|
68
|
+
|
|
69
|
+
return setupUrl.toString();
|
|
70
|
+
}
|
|
71
|
+
|
|
22
72
|
export function normalizeWorkspaceSwitchPath(
|
|
23
73
|
pathname: string,
|
|
24
74
|
nextSlug: string
|