@tuturuuu/ui 0.1.0 → 0.3.1

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 (128) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +82 -70
  3. package/src/components/ui/__tests__/avatar.test.tsx +8 -5
  4. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
  8. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
  9. package/src/components/ui/chart.test.tsx +29 -0
  10. package/src/components/ui/chart.tsx +12 -3
  11. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +43 -13
  12. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +138 -74
  13. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +70 -0
  14. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +60 -1
  15. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +13 -5
  16. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +110 -0
  17. package/src/components/ui/chat/chat-sidebar-panel.tsx +13 -3
  18. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  19. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  20. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  21. package/src/components/ui/custom/common-footer.tsx +16 -1
  22. package/src/components/ui/custom/production-indicator.tsx +1 -1
  23. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  24. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  25. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  26. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  27. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  28. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  29. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  30. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  31. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  32. package/src/components/ui/custom/workspace-select.tsx +33 -12
  33. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  34. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  35. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  36. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  37. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  38. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  39. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  40. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  41. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  42. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  43. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  44. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  46. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  47. package/src/components/ui/finance/invoices/utils.ts +75 -17
  48. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  49. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  50. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  51. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  52. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  53. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  54. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  55. package/src/components/ui/finance/transactions/form.tsx +60 -0
  56. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  57. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  58. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  59. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  60. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  61. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  62. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  63. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  64. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  65. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  66. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  67. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  68. package/src/components/ui/legacy/meet/page.tsx +87 -39
  69. package/src/components/ui/legacy/meet/planId/page.tsx +10 -4
  70. package/src/components/ui/text-editor/__tests__/task-mention-chip.test.tsx +203 -6
  71. package/src/components/ui/text-editor/task-mention-chip.tsx +29 -7
  72. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  73. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  74. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  75. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  77. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  78. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  79. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  80. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  81. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  82. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  83. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  84. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  85. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  86. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  87. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  88. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  89. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  90. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  91. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  92. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  93. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  94. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  95. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  96. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  97. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  98. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  99. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  100. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  101. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  102. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  103. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  104. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  105. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  106. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  107. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  108. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  109. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  110. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  111. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  112. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  113. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-realtime-sync.test.tsx +37 -9
  114. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  115. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +89 -70
  116. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  117. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  118. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  119. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  120. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  121. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  122. package/src/hooks/use-calendar-sync.tsx +22 -277
  123. package/src/hooks/use-calendar.tsx +95 -525
  124. package/src/hooks/use-task-actions.ts +43 -117
  125. package/src/hooks/use-user-config.ts +1 -1
  126. package/src/hooks/use-workspace-config.ts +6 -2
  127. package/src/hooks/use-workspace-presence.ts +1 -1
  128. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
@@ -1,12 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { useLocalStorage } from '@tuturuuu/ui/hooks/use-local-storage';
4
- import {
5
- useUpdateUserConfig,
6
- useUserConfig,
7
- } from '@tuturuuu/ui/hooks/use-user-config';
8
4
  import { setCookie } from 'cookies-next';
9
5
  import {
6
+ type ComponentType,
10
7
  createContext,
11
8
  type Dispatch,
12
9
  type ReactNode,
@@ -14,7 +11,6 @@ import {
14
11
  useCallback,
15
12
  useContext,
16
13
  useEffect,
17
- useRef,
18
14
  useState,
19
15
  } from 'react';
20
16
 
@@ -23,9 +19,6 @@ export const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
23
19
 
24
20
  export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
25
21
 
26
- const isValidBehavior = (value: string | undefined): value is SidebarBehavior =>
27
- value === 'expanded' || value === 'collapsed' || value === 'hover';
28
-
29
22
  interface SidebarContextProps {
30
23
  behavior: SidebarBehavior;
31
24
  setBehavior: Dispatch<SetStateAction<SidebarBehavior>>;
@@ -42,6 +35,38 @@ export const SidebarContext = createContext<SidebarContextProps | undefined>(
42
35
  // Persistent cookie options — ensures setting survives browser restarts
43
36
  const COOKIE_OPTIONS = { maxAge: 365 * 24 * 60 * 60, path: '/' } as const;
44
37
 
38
+ type SidebarRemoteBehaviorBridgeComponent = ComponentType<{
39
+ behavior: SidebarBehavior;
40
+ localOverride: boolean;
41
+ localOverrideVersion: number;
42
+ onApplyRemoteBehavior: (newBehavior: SidebarBehavior) => void;
43
+ onRemoteBehaviorAvailable: (remoteBehavior: SidebarBehavior) => void;
44
+ userChangeVersion: number;
45
+ }>;
46
+
47
+ function useSidebarRemoteBehaviorBridge() {
48
+ const [RemoteBehaviorBridge, setRemoteBehaviorBridge] =
49
+ useState<SidebarRemoteBehaviorBridgeComponent | null>(null);
50
+
51
+ useEffect(() => {
52
+ let active = true;
53
+
54
+ // biome-ignore lint/suspicious/noTsIgnore: NodeNext requires .js, but Next/Turbopack resolves workspace TypeScript source here before package emit.
55
+ // @ts-ignore
56
+ void import('./sidebar-remote-behavior-bridge').then((module) => {
57
+ if (active) {
58
+ setRemoteBehaviorBridge(() => module.SidebarRemoteBehaviorBridge);
59
+ }
60
+ });
61
+
62
+ return () => {
63
+ active = false;
64
+ };
65
+ }, []);
66
+
67
+ return RemoteBehaviorBridge;
68
+ }
69
+
45
70
  export const SidebarProvider = ({
46
71
  children,
47
72
  initialBehavior,
@@ -54,74 +79,39 @@ export const SidebarProvider = ({
54
79
  'sidebar-local-override',
55
80
  false
56
81
  );
57
- const hasAppliedRemote = useRef(false);
58
- // Prevents remote sync from overwriting a user-initiated change
59
- const userChangedRef = useRef(false);
60
- // Allows reading current behavior inside the sync effect without
61
- // adding `behavior` to its dependency array
62
- const behaviorRef = useRef(behavior);
63
- behaviorRef.current = behavior;
64
-
65
- // Fetch account-wide preference
66
- const { data: remoteBehavior, isSuccess: remoteLoaded } = useUserConfig(
67
- SIDEBAR_BEHAVIOR_CONFIG_KEY,
68
- 'expanded'
82
+ const [remoteBehavior, setRemoteBehavior] = useState<SidebarBehavior | null>(
83
+ null
69
84
  );
85
+ const [userChangeVersion, setUserChangeVersion] = useState(0);
86
+ const [localOverrideVersion, setLocalOverrideVersion] = useState(0);
87
+ const RemoteBehaviorBridge = useSidebarRemoteBehaviorBridge();
70
88
 
71
- const updateConfig = useUpdateUserConfig();
72
-
73
- // Sync from user_configs when not locally overridden
74
- useEffect(() => {
75
- if (
76
- !remoteLoaded ||
77
- localOverride ||
78
- hasAppliedRemote.current ||
79
- userChangedRef.current
80
- )
81
- return;
82
- hasAppliedRemote.current = true;
83
- if (
84
- isValidBehavior(remoteBehavior) &&
85
- remoteBehavior !== behaviorRef.current
86
- ) {
87
- setBehavior(remoteBehavior);
88
- setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, remoteBehavior, COOKIE_OPTIONS);
89
- }
90
- }, [remoteLoaded, remoteBehavior, localOverride]);
89
+ const applyBehavior = useCallback((newBehavior: SidebarBehavior) => {
90
+ setBehavior(newBehavior);
91
+ setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, newBehavior, COOKIE_OPTIONS);
92
+ }, []);
91
93
 
92
94
  const handleBehaviorChange = useCallback(
93
95
  (newBehavior: SidebarBehavior) => {
94
- userChangedRef.current = true;
95
- setBehavior(newBehavior);
96
- // Always update cookie for SSR
97
- setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, newBehavior, COOKIE_OPTIONS);
98
- // Save to user_configs (account-wide) unless locally overridden
96
+ applyBehavior(newBehavior);
97
+
99
98
  if (!localOverride) {
100
- updateConfig.mutate({
101
- configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
102
- value: newBehavior,
103
- });
99
+ setUserChangeVersion((version) => version + 1);
104
100
  }
105
101
  },
106
- [localOverride, updateConfig]
102
+ [applyBehavior, localOverride]
107
103
  );
108
104
 
109
105
  const setLocalOverride = useCallback(
110
106
  (enabled: boolean) => {
111
107
  setLocalOverrideRaw(enabled);
112
- if (!enabled && isValidBehavior(remoteBehavior)) {
113
- // Turning off local override — sync to account-wide value
114
- setBehavior(remoteBehavior);
115
- setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, remoteBehavior, COOKIE_OPTIONS);
116
- } else if (enabled) {
117
- // Turning on local override — save current behavior to account-wide first
118
- updateConfig.mutate({
119
- configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
120
- value: behavior,
121
- });
108
+ setLocalOverrideVersion((version) => version + 1);
109
+
110
+ if (!enabled && remoteBehavior) {
111
+ applyBehavior(remoteBehavior);
122
112
  }
123
113
  },
124
- [setLocalOverrideRaw, remoteBehavior, behavior, updateConfig]
114
+ [applyBehavior, remoteBehavior, setLocalOverrideRaw]
125
115
  );
126
116
 
127
117
  return (
@@ -134,6 +124,16 @@ export const SidebarProvider = ({
134
124
  setLocalOverride,
135
125
  }}
136
126
  >
127
+ {RemoteBehaviorBridge && (
128
+ <RemoteBehaviorBridge
129
+ behavior={behavior}
130
+ localOverride={localOverride}
131
+ localOverrideVersion={localOverrideVersion}
132
+ onApplyRemoteBehavior={applyBehavior}
133
+ onRemoteBehaviorAvailable={setRemoteBehavior}
134
+ userChangeVersion={userChangeVersion}
135
+ />
136
+ )}
137
137
  {children}
138
138
  </SidebarContext.Provider>
139
139
  );
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useUpdateUserConfig,
5
+ useUserConfig,
6
+ } from '@tuturuuu/ui/hooks/use-user-config';
7
+ import { useEffect, useRef } from 'react';
8
+
9
+ type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
10
+
11
+ const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
12
+
13
+ const isValidBehavior = (value: string | undefined): value is SidebarBehavior =>
14
+ value === 'expanded' || value === 'collapsed' || value === 'hover';
15
+
16
+ interface SidebarRemoteBehaviorBridgeProps {
17
+ behavior: SidebarBehavior;
18
+ localOverride: boolean;
19
+ localOverrideVersion: number;
20
+ onApplyRemoteBehavior: (newBehavior: SidebarBehavior) => void;
21
+ onRemoteBehaviorAvailable: (remoteBehavior: SidebarBehavior) => void;
22
+ userChangeVersion: number;
23
+ }
24
+
25
+ export function SidebarRemoteBehaviorBridge({
26
+ behavior,
27
+ localOverride,
28
+ localOverrideVersion,
29
+ onApplyRemoteBehavior,
30
+ onRemoteBehaviorAvailable,
31
+ userChangeVersion,
32
+ }: SidebarRemoteBehaviorBridgeProps) {
33
+ const { data: remoteBehavior, isSuccess: remoteLoaded } = useUserConfig(
34
+ SIDEBAR_BEHAVIOR_CONFIG_KEY,
35
+ 'expanded'
36
+ );
37
+ const updateConfig = useUpdateUserConfig();
38
+ const hasAppliedRemote = useRef(false);
39
+ const persistedUserChangeVersion = useRef(0);
40
+ const handledLocalOverrideVersion = useRef(0);
41
+
42
+ useEffect(() => {
43
+ if (!remoteLoaded || !isValidBehavior(remoteBehavior)) return;
44
+
45
+ onRemoteBehaviorAvailable(remoteBehavior);
46
+ }, [onRemoteBehaviorAvailable, remoteBehavior, remoteLoaded]);
47
+
48
+ useEffect(() => {
49
+ if (
50
+ !remoteLoaded ||
51
+ localOverride ||
52
+ hasAppliedRemote.current ||
53
+ userChangeVersion > 0 ||
54
+ !isValidBehavior(remoteBehavior)
55
+ ) {
56
+ return;
57
+ }
58
+
59
+ hasAppliedRemote.current = true;
60
+
61
+ if (remoteBehavior !== behavior) {
62
+ onApplyRemoteBehavior(remoteBehavior);
63
+ }
64
+ }, [
65
+ behavior,
66
+ localOverride,
67
+ onApplyRemoteBehavior,
68
+ remoteBehavior,
69
+ remoteLoaded,
70
+ userChangeVersion,
71
+ ]);
72
+
73
+ useEffect(() => {
74
+ if (
75
+ !remoteLoaded ||
76
+ localOverride ||
77
+ userChangeVersion === 0 ||
78
+ persistedUserChangeVersion.current === userChangeVersion
79
+ ) {
80
+ return;
81
+ }
82
+
83
+ persistedUserChangeVersion.current = userChangeVersion;
84
+ updateConfig.mutate({
85
+ configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
86
+ value: behavior,
87
+ });
88
+ }, [behavior, localOverride, remoteLoaded, updateConfig, userChangeVersion]);
89
+
90
+ useEffect(() => {
91
+ if (
92
+ !remoteLoaded ||
93
+ localOverrideVersion === 0 ||
94
+ handledLocalOverrideVersion.current === localOverrideVersion
95
+ ) {
96
+ return;
97
+ }
98
+
99
+ handledLocalOverrideVersion.current = localOverrideVersion;
100
+
101
+ if (localOverride) {
102
+ updateConfig.mutate({
103
+ configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
104
+ value: behavior,
105
+ });
106
+ return;
107
+ }
108
+
109
+ if (isValidBehavior(remoteBehavior)) {
110
+ onApplyRemoteBehavior(remoteBehavior);
111
+ }
112
+ }, [
113
+ behavior,
114
+ localOverride,
115
+ localOverrideVersion,
116
+ onApplyRemoteBehavior,
117
+ remoteBehavior,
118
+ remoteLoaded,
119
+ updateConfig,
120
+ ]);
121
+
122
+ return null;
123
+ }
@@ -0,0 +1,6 @@
1
+ /** Absolute URL to the Tuturuuu logo hosted on the production domain. */
2
+ export const TUTURUUU_REMOTE_LOGO_URL =
3
+ 'https://tuturuuu.com/media/logos/transparent.png';
4
+
5
+ export const TUTURUUU_LOCAL_LOGO_URL = '/media/logos/transparent.png';
6
+ export const TUTURUUU_LOGO_URL = TUTURUUU_REMOTE_LOGO_URL;
@@ -1,16 +1,34 @@
1
- import Image from 'next/image';
1
+ import Image, { type ImageProps } from 'next/image';
2
+ import { TUTURUUU_LOGO_URL } from './tuturuuu-logo-urls';
2
3
 
3
- /** Absolute URL to the Tuturuuu logo hosted on the production domain. */
4
- export const TUTURUUU_LOGO_URL =
5
- 'https://tuturuuu.com/media/logos/transparent.png';
4
+ export {
5
+ TUTURUUU_LOCAL_LOGO_URL,
6
+ TUTURUUU_LOGO_URL,
7
+ TUTURUUU_REMOTE_LOGO_URL,
8
+ } from './tuturuuu-logo-urls';
6
9
 
7
10
  /**
8
11
  * Convenience wrapper around `<Image>` pre-configured with the Tuturuuu logo.
9
- * Uses `unoptimized` so no per-app `remotePatterns` config is required.
12
+ * Remote logo usage stays unoptimized so no per-app `remotePatterns` config is required.
10
13
  */
11
14
  export function TuturuuLogo({
12
15
  alt = 'Tuturuuu Logo',
16
+ src = TUTURUUU_LOGO_URL,
17
+ unoptimized,
13
18
  ...props
14
- }: Omit<React.ComponentProps<typeof Image>, 'src' | 'alt'> & { alt?: string }) {
15
- return <Image src={TUTURUUU_LOGO_URL} alt={alt} unoptimized {...props} />;
19
+ }: Omit<ImageProps, 'alt' | 'src'> & {
20
+ alt?: string;
21
+ src?: ImageProps['src'];
22
+ }) {
23
+ const shouldSkipOptimization =
24
+ unoptimized ?? (typeof src === 'string' && /^https?:\/\//u.test(src));
25
+
26
+ return (
27
+ <Image
28
+ src={src}
29
+ alt={alt}
30
+ unoptimized={shouldSkipOptimization}
31
+ {...props}
32
+ />
33
+ );
16
34
  }
@@ -0,0 +1,20 @@
1
+ import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
2
+
3
+ export function mergeWorkspaceSelectWorkspaces(
4
+ workspaces: InternalApiWorkspaceSummary[] | undefined,
5
+ currentWorkspaceFallback: InternalApiWorkspaceSummary | null | undefined
6
+ ) {
7
+ const workspaceList = workspaces ?? [];
8
+
9
+ if (!currentWorkspaceFallback) return workspaceList;
10
+
11
+ if (
12
+ workspaceList.some(
13
+ (workspace) => workspace.id === currentWorkspaceFallback.id
14
+ )
15
+ ) {
16
+ return workspaceList;
17
+ }
18
+
19
+ return [...workspaceList, currentWorkspaceFallback];
20
+ }
@@ -10,7 +10,10 @@ import {
10
10
  Star,
11
11
  } from '@tuturuuu/icons';
12
12
  import { updateCurrentUserDefaultWorkspace } from '@tuturuuu/internal-api/users';
13
- import { acceptWorkspaceInvite } from '@tuturuuu/internal-api/workspaces';
13
+ import {
14
+ acceptWorkspaceInvite,
15
+ getWorkspace,
16
+ } from '@tuturuuu/internal-api/workspaces';
14
17
  import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
15
18
  import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
16
19
  import {
@@ -63,6 +66,7 @@ import {
63
66
  import { Input } from '../input';
64
67
  import { Popover, PopoverContent, PopoverTrigger } from '../popover';
65
68
  import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
69
+ import { mergeWorkspaceSelectWorkspaces } from './workspace-select-helpers';
66
70
 
67
71
  const FormSchema = z.object({
68
72
  name: z.string().min(1).max(100),
@@ -161,17 +165,34 @@ export function WorkspaceSelect({
161
165
  const pathname = usePathname();
162
166
  const queryClient = useQueryClient();
163
167
 
164
- const { data: workspaces } = useQuery({
168
+ const resolvedWorkspaceId =
169
+ wsId && wsId !== PERSONAL_WORKSPACE_SLUG
170
+ ? resolveWorkspaceId(wsId)
171
+ : undefined;
172
+ const { data: listedWorkspaces } = useQuery({
165
173
  queryKey: ['workspaces'],
166
174
  queryFn: fetchWorkspaces,
167
175
  enabled: !!wsId,
168
176
  });
177
+ const hasListedCurrentWorkspace = Boolean(
178
+ resolvedWorkspaceId &&
179
+ listedWorkspaces?.some(
180
+ (workspace) => workspace.id === resolvedWorkspaceId
181
+ )
182
+ );
183
+ const { data: currentWorkspaceFallback } = useQuery({
184
+ queryKey: ['workspace-select-current-workspace', resolvedWorkspaceId],
185
+ queryFn: async () =>
186
+ (await getWorkspace(resolvedWorkspaceId!)) as InternalApiWorkspaceSummary,
187
+ enabled: Boolean(resolvedWorkspaceId && !hasListedCurrentWorkspace),
188
+ retry: 1,
189
+ });
190
+ const workspaces = mergeWorkspaceSelectWorkspaces(
191
+ listedWorkspaces,
192
+ currentWorkspaceFallback
193
+ );
169
194
  const { data: currentUser } = useWorkspaceUser();
170
195
 
171
- const resolvedWorkspaceId =
172
- wsId && wsId !== PERSONAL_WORKSPACE_SLUG
173
- ? resolveWorkspaceId(wsId)
174
- : undefined;
175
196
  const defaultWorkspaceId = currentUser?.default_workspace_id || null;
176
197
 
177
198
  const form = useForm({
@@ -435,9 +456,12 @@ export function WorkspaceSelect({
435
456
  const workspace =
436
457
  wsId === PERSONAL_WORKSPACE_SLUG
437
458
  ? personalWorkspace
438
- : workspaces?.find((ws) => ws.id === resolvedWorkspaceId);
459
+ : (workspaces.find((ws) => ws.id === resolvedWorkspaceId) ??
460
+ guestWorkspaces.find((ws) => ws.id === resolvedWorkspaceId));
439
461
  if (!wsId) return <div />;
440
462
 
463
+ const hasSelectableWorkspaces = workspaces.length > 0;
464
+
441
465
  async function onJoinByHandleSubmit(
442
466
  formData: z.infer<typeof JoinWorkspaceByHandleFormSchema>
443
467
  ) {
@@ -540,10 +564,7 @@ export function WorkspaceSelect({
540
564
  }}
541
565
  >
542
566
  <Popover open={open} onOpenChange={setOpen}>
543
- <PopoverTrigger
544
- asChild
545
- disabled={!workspaces || workspaces.length === 0}
546
- >
567
+ <PopoverTrigger asChild disabled={!hasSelectableWorkspaces}>
547
568
  <Button
548
569
  size="xs"
549
570
  variant="outline"
@@ -553,7 +574,7 @@ export function WorkspaceSelect({
553
574
  hideLeading ? 'justify-center p-0' : 'justify-start',
554
575
  'w-full whitespace-normal text-start'
555
576
  )}
556
- disabled={!workspaces || workspaces.length === 0}
577
+ disabled={!hasSelectableWorkspaces}
557
578
  >
558
579
  <WorkspaceIcon
559
580
  fallbackLogoUrl={fallbackLogoUrl}
@@ -45,7 +45,10 @@ export function InvoiceCheckoutSummary({
45
45
  <span className="text-muted-foreground">
46
46
  {t('ws-invoices.subtotal')}
47
47
  </span>
48
- <FinanceDisplayAmount value={formatCurrency(subtotal, currency)} />
48
+ <FinanceDisplayAmount
49
+ alwaysShow
50
+ value={formatCurrency(subtotal, currency)}
51
+ />
49
52
  </div>
50
53
 
51
54
  {discountAmount !== undefined && discountLabel && (
@@ -54,6 +57,7 @@ export function InvoiceCheckoutSummary({
54
57
  {t('ws-invoices.discount')} ({discountLabel})
55
58
  </span>
56
59
  <FinanceDisplayAmount
60
+ alwaysShow
57
61
  className={discountClassName}
58
62
  value={`-${formatCurrency(discountAmount, currency)}`}
59
63
  />
@@ -65,6 +69,7 @@ export function InvoiceCheckoutSummary({
65
69
  <div className="flex justify-between font-semibold">
66
70
  <span>{t('ws-invoices.total')}</span>
67
71
  <FinanceDisplayAmount
72
+ alwaysShow
68
73
  value={formatCurrency(roundedTotal, currency)}
69
74
  />
70
75
  </div>
@@ -73,6 +78,7 @@ export function InvoiceCheckoutSummary({
73
78
  <div className="flex justify-between text-muted-foreground text-sm">
74
79
  <span>{t('ws-invoices.adjustment')}</span>
75
80
  <FinanceDisplayAmount
81
+ alwaysShow
76
82
  value={`${roundedTotal > totalBeforeRounding ? '+' : ''}${formatCurrency(
77
83
  roundedTotal - totalBeforeRounding,
78
84
  currency
@@ -51,6 +51,7 @@ interface InvoicePaymentSettingsProps {
51
51
  categoryLabelClassName?: string;
52
52
  currency?: string;
53
53
  walletDisabled?: boolean;
54
+ walletPermissionWarning?: ReactNode;
54
55
  }
55
56
 
56
57
  export function InvoicePaymentSettings({
@@ -76,6 +77,7 @@ export function InvoicePaymentSettings({
76
77
  categoryLabelClassName = 'text-dynamic-red',
77
78
  currency = 'USD',
78
79
  walletDisabled = false,
80
+ walletPermissionWarning,
79
81
  }: InvoicePaymentSettingsProps) {
80
82
  const t = useTranslations();
81
83
  const shouldShowPromotion = showPromotion && promotionsAllowed;
@@ -168,6 +170,7 @@ export function InvoicePaymentSettings({
168
170
  ))}
169
171
  </SelectContent>
170
172
  </Select>
173
+ {walletDisabled && walletPermissionWarning}
171
174
  </div>
172
175
 
173
176
  <div className="space-y-2">
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import { AlertTriangle } from '@tuturuuu/icons';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import { useTranslations } from 'next-intl';
6
+ import {
7
+ type FinancePermissionRequestUser,
8
+ FinancePermissionWarningDialog,
9
+ } from '../../shared/finance-permission-warning-dialog';
10
+
11
+ interface InvoiceProductsPermissionWarningProps {
12
+ missingPermissions: string[];
13
+ user?: FinancePermissionRequestUser | null;
14
+ }
15
+
16
+ export function InvoiceProductsPermissionWarning({
17
+ missingPermissions,
18
+ user,
19
+ }: InvoiceProductsPermissionWarningProps) {
20
+ const t = useTranslations();
21
+ const uniqueMissingPermissions = [...new Set(missingPermissions)];
22
+
23
+ if (uniqueMissingPermissions.length === 0) return null;
24
+
25
+ return (
26
+ <div className="rounded-lg border border-dynamic-orange/30 bg-dynamic-orange/5 p-3">
27
+ <div className="flex items-start gap-3">
28
+ <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-dynamic-orange" />
29
+ <div className="space-y-3">
30
+ <div className="space-y-1">
31
+ <p className="font-medium text-sm">
32
+ {t('finance-permission-warning.summary')}
33
+ </p>
34
+ <p className="text-muted-foreground text-sm">
35
+ {t('finance-permission-warning.description')}
36
+ </p>
37
+ </div>
38
+ <FinancePermissionWarningDialog
39
+ missingPermissions={uniqueMissingPermissions}
40
+ user={user}
41
+ trigger={
42
+ <Button type="button" variant="outline" size="sm">
43
+ {t('finance-permission-warning.open_request')}
44
+ </Button>
45
+ }
46
+ />
47
+ </div>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export function isPermissionRequestError(error: unknown) {
54
+ if (!error || typeof error !== 'object') return false;
55
+
56
+ const status = 'status' in error ? Number(error.status) : 0;
57
+ return status === 401 || status === 403;
58
+ }
@@ -26,7 +26,8 @@ import { useState } from 'react';
26
26
  import {
27
27
  type GroupPaymentStatus,
28
28
  getGroupPaymentStatus,
29
- getMonthStartDate,
29
+ getSubscriptionCoverageInvoiceForGroup,
30
+ isSubscriptionMonthPaidForGroup,
30
31
  parseLocalCalendarDate,
31
32
  } from '../utils';
32
33
 
@@ -47,18 +48,6 @@ function hasSchedule(group: UserGroupItem['workspace_user_groups']): boolean {
47
48
  return Array.isArray(sessions) && sessions.length > 0;
48
49
  }
49
50
 
50
- function isMonthPaidForGroup(
51
- groupId: string,
52
- selectedMonth: string,
53
- latestInvoices: LatestInvoice[]
54
- ): boolean {
55
- const inv = latestInvoices.find((i) => i.group_id === groupId);
56
- if (!inv?.valid_until) return false;
57
- const monthStart = getMonthStartDate(selectedMonth);
58
- const validUntilStart = getMonthStartDate(inv.valid_until);
59
- return monthStart < validUntilStart;
60
- }
61
-
62
51
  interface SubscriptionGroupSelectorProps {
63
52
  userGroups: UserGroupItem[];
64
53
  userGroupsLoading: boolean;
@@ -267,10 +256,11 @@ export function SubscriptionGroupSelector({
267
256
  const group = groupItem.workspace_user_groups;
268
257
  if (!group) return null;
269
258
  const isSelected = selectedGroupIds.includes(group.id);
270
- const latestInvoice = latestSubscriptionInvoices.find(
271
- (inv) => inv.group_id === group.id
259
+ const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
260
+ latestSubscriptionInvoices,
261
+ group.id
272
262
  );
273
- const isMonthPaid = isMonthPaidForGroup(
263
+ const isMonthPaid = isSubscriptionMonthPaidForGroup(
274
264
  group.id,
275
265
  selectedMonth,
276
266
  latestSubscriptionInvoices
@@ -312,10 +302,12 @@ export function SubscriptionGroupSelector({
312
302
  const group = groupItem.workspace_user_groups;
313
303
  if (!group) return null;
314
304
  const isSelected = selectedGroupIds.includes(group.id);
315
- const latestInvoice = latestSubscriptionInvoices.find(
316
- (inv) => inv.group_id === group.id
317
- );
318
- const isMonthPaid = isMonthPaidForGroup(
305
+ const latestInvoice =
306
+ getSubscriptionCoverageInvoiceForGroup(
307
+ latestSubscriptionInvoices,
308
+ group.id
309
+ );
310
+ const isMonthPaid = isSubscriptionMonthPaidForGroup(
319
311
  group.id,
320
312
  selectedMonth,
321
313
  latestSubscriptionInvoices
@@ -18,10 +18,11 @@ import type { UserGroup } from '../utils';
18
18
  import {
19
19
  getEffectiveAttendanceDays,
20
20
  getEffectiveDays,
21
- getMonthStartDate,
22
21
  getSessionsForMonth,
23
22
  getSessionsUntilMonth,
23
+ getSubscriptionCoverageInvoiceForGroup,
24
24
  getTotalSessionsForGroups,
25
+ isSubscriptionMonthPaidForGroup,
25
26
  parseLocalCalendarDate,
26
27
  } from '../utils';
27
28
 
@@ -174,19 +175,19 @@ const computeGroupAttendanceDaysMap = (
174
175
  );
175
176
  if (!group) continue;
176
177
 
177
- const latestInvoice = latestSubscriptionInvoices.find(
178
- (inv) => inv.group_id === groupId
178
+ const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
179
+ latestSubscriptionInvoices,
180
+ groupId
179
181
  );
180
182
  const validUntil = latestInvoice?.valid_until
181
183
  ? parseLocalCalendarDate(latestInvoice.valid_until)
182
184
  : null;
183
185
 
184
- const isGroupPaid = (() => {
185
- if (!validUntil) return false;
186
- const selectedMonthStart = getMonthStartDate(selectedMonth);
187
- const validUntilMonthStart = getMonthStartDate(validUntil);
188
- return selectedMonthStart < validUntilMonthStart;
189
- })();
186
+ const isGroupPaid = isSubscriptionMonthPaidForGroup(
187
+ groupId,
188
+ selectedMonth,
189
+ latestSubscriptionInvoices
190
+ );
190
191
 
191
192
  const sessionsArray = group.workspace_user_groups?.sessions || [];
192
193
  const groupSessions = isGroupPaid