@tuturuuu/ui 0.37.2 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -0,0 +1,269 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from '@testing-library/react';
10
+ import { NextIntlClientProvider } from 'next-intl';
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import en from '../../../../../../../../apps/contacts/messages/en.json';
13
+ import viMessages from '../../../../../../../../apps/contacts/messages/vi.json';
14
+ import { WorkspaceVisibilityProvider } from '../../../../../hooks/use-workspace-visibility';
15
+ import { FinanceRouteProvider } from '../../finance-route-context';
16
+ import { InvoiceUserHistoryAccordion } from './invoice-user-history-accordion';
17
+
18
+ const transport = vi.fn();
19
+ let observers: IntersectionObserverCallback[] = [];
20
+ const labels = {
21
+ en: {
22
+ error: 'Invoice history could not be loaded. Try again.',
23
+ retry: 'Retry',
24
+ },
25
+ vi: {
26
+ error: 'Không thể tải lịch sử hóa đơn. Vui lòng thử lại.',
27
+ retry: 'Thử lại',
28
+ },
29
+ };
30
+ function reply(label?: string, count = 0, id = 'synthetic-invoice') {
31
+ return new Response(
32
+ JSON.stringify({
33
+ data: label
34
+ ? [
35
+ {
36
+ id,
37
+ notice: label,
38
+ price: 100,
39
+ total_diff: 0,
40
+ created_at: '2026-09-01T12:00:00.000Z',
41
+ },
42
+ ]
43
+ : [],
44
+ count,
45
+ }),
46
+ { status: 200 }
47
+ );
48
+ }
49
+ function failure(status: number) {
50
+ return new Response(
51
+ JSON.stringify({ message: 'Synthetic private server diagnostic' }),
52
+ { status }
53
+ );
54
+ }
55
+ function held() {
56
+ let resolve!: (response: Response) => void;
57
+ const promise = new Promise<Response>((yes) => {
58
+ resolve = yes;
59
+ });
60
+ return { promise, resolve };
61
+ }
62
+ function mount(locale: 'en' | 'vi') {
63
+ const client = new QueryClient({
64
+ defaultOptions: {
65
+ queries: {
66
+ retry: false,
67
+ staleTime: 300_000,
68
+ refetchOnWindowFocus: false,
69
+ },
70
+ },
71
+ });
72
+ render(
73
+ <QueryClientProvider client={client}>
74
+ <WorkspaceVisibilityProvider actorId="account-A">
75
+ <NextIntlClientProvider
76
+ locale={locale}
77
+ messages={locale === 'en' ? en : viMessages}
78
+ timeZone="UTC"
79
+ now={new Date('2026-09-01T12:00:00Z')}
80
+ >
81
+ <FinanceRouteProvider prefix="">
82
+ <InvoiceUserHistoryAccordion wsId="ws-1" userId="customer-1" />
83
+ </FinanceRouteProvider>
84
+ </NextIntlClientProvider>
85
+ </WorkspaceVisibilityProvider>
86
+ </QueryClientProvider>
87
+ );
88
+ return client;
89
+ }
90
+ beforeEach(() => {
91
+ transport.mockReset();
92
+ observers = [];
93
+ transport.mockImplementation((input: string) => {
94
+ if (
95
+ !new URL(input, window.location.origin).pathname.endsWith(
96
+ '/finance/invoices'
97
+ )
98
+ )
99
+ throw new Error('Unexpected synthetic history route');
100
+ return Promise.resolve(reply());
101
+ });
102
+ vi.stubGlobal('fetch', transport);
103
+ vi.stubGlobal(
104
+ 'IntersectionObserver',
105
+ class {
106
+ constructor(callback: IntersectionObserverCallback) {
107
+ observers.push(callback);
108
+ }
109
+ observe() {}
110
+ unobserve() {}
111
+ disconnect() {}
112
+ }
113
+ );
114
+ vi.stubGlobal(
115
+ 'ResizeObserver',
116
+ class {
117
+ observe() {}
118
+ unobserve() {}
119
+ disconnect() {}
120
+ }
121
+ );
122
+ });
123
+ afterEach(() => {
124
+ cleanup();
125
+ vi.unstubAllGlobals();
126
+ });
127
+
128
+ describe('actual invoice history failure presentation and recovery', () => {
129
+ for (const locale of ['en', 'vi'] as const) {
130
+ const messages = locale === 'en' ? en : viMessages;
131
+ for (const status of [403, 503]) {
132
+ it(`${locale}: distinguishes initial ${status} failure from successful empty history and recovers`, async () => {
133
+ transport.mockImplementationOnce(() =>
134
+ Promise.resolve(failure(status))
135
+ );
136
+ mount(locale);
137
+ const error = await screen.findByRole('alert');
138
+ expect(error).toHaveTextContent(labels[locale].error);
139
+ expect(
140
+ screen.queryByText(
141
+ messages['ws-invoices'].no_transaction_or_invoice_history
142
+ )
143
+ ).not.toBeInTheDocument();
144
+ expect(
145
+ screen.queryByText('Synthetic private server diagnostic')
146
+ ).not.toBeInTheDocument();
147
+ expect(transport).toHaveBeenCalledOnce();
148
+ const pending = held();
149
+ transport.mockReturnValueOnce(pending.promise);
150
+ const retry = screen.getByRole('button', {
151
+ name: labels[locale].retry,
152
+ });
153
+ fireEvent.click(retry);
154
+ await waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
155
+ expect(retry).toBeDisabled();
156
+ fireEvent.click(retry);
157
+ expect(transport).toHaveBeenCalledTimes(2);
158
+ await act(async () => {
159
+ pending.resolve(reply());
160
+ await pending.promise;
161
+ });
162
+ await screen.findByText(
163
+ messages['ws-invoices'].no_transaction_or_invoice_history
164
+ );
165
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
166
+ });
167
+ }
168
+ it(`${locale}: refuses a queued old observer callback after a page failure`, async () => {
169
+ transport.mockImplementation((input: string) =>
170
+ Promise.resolve(
171
+ new URL(input, window.location.origin).searchParams.get('page') ===
172
+ '2'
173
+ ? failure(503)
174
+ : reply('loaded-history', 11)
175
+ )
176
+ );
177
+ mount(locale);
178
+ fireEvent.click(
179
+ await screen.findByRole('button', {
180
+ name: new RegExp(messages['ws-invoices'].plural),
181
+ })
182
+ );
183
+ await waitFor(() => expect(observers.length).toBeGreaterThan(0));
184
+ const oldObserver = observers[0];
185
+ fireEvent.click(
186
+ screen.getByRole('button', { name: messages['ws-invoices'].load_more })
187
+ );
188
+ await screen.findByRole('alert');
189
+ expect(
190
+ screen.queryByRole('button', {
191
+ name: messages['ws-invoices'].load_more,
192
+ })
193
+ ).not.toBeInTheDocument();
194
+ expect(transport).toHaveBeenCalledTimes(2);
195
+ await act(async () => {
196
+ oldObserver?.(
197
+ [{ isIntersecting: true } as IntersectionObserverEntry],
198
+ {} as IntersectionObserver
199
+ );
200
+ await Promise.resolve();
201
+ });
202
+ expect(transport).toHaveBeenCalledTimes(2);
203
+ expect(screen.getByText('loaded-history')).toBeVisible();
204
+ });
205
+ it(`${locale}: presents genuine empty success without a failure or retry`, async () => {
206
+ mount(locale);
207
+ await screen.findByText(
208
+ messages['ws-invoices'].no_transaction_or_invoice_history
209
+ );
210
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
211
+ expect(
212
+ screen.queryByRole('button', { name: labels[locale].retry })
213
+ ).not.toBeInTheDocument();
214
+ expect(transport).toHaveBeenCalledOnce();
215
+ });
216
+ it(`${locale}: retains loaded rows on a page failure and retries the failed page`, async () => {
217
+ transport.mockImplementation((input: string) =>
218
+ Promise.resolve(
219
+ new URL(input, window.location.origin).searchParams.get('page') ===
220
+ '2'
221
+ ? failure(503)
222
+ : reply('loaded-history', 11)
223
+ )
224
+ );
225
+ mount(locale);
226
+ fireEvent.click(
227
+ await screen.findByRole('button', {
228
+ name: new RegExp(messages['ws-invoices'].plural),
229
+ })
230
+ );
231
+ await waitFor(() =>
232
+ expect(screen.getByText('loaded-history')).toBeVisible()
233
+ );
234
+ fireEvent.click(
235
+ screen.getByRole('button', { name: messages['ws-invoices'].load_more })
236
+ );
237
+ const error = await screen.findByRole('alert');
238
+ expect(error).toHaveTextContent(labels[locale].error);
239
+ expect(screen.getByText('loaded-history')).toBeVisible();
240
+ expect(
241
+ screen.queryByText(
242
+ messages['ws-invoices'].no_transaction_or_invoice_history
243
+ )
244
+ ).not.toBeInTheDocument();
245
+ transport.mockImplementation((input: string) =>
246
+ Promise.resolve(
247
+ new URL(input, window.location.origin).searchParams.get('page') ===
248
+ '2'
249
+ ? reply('recovered-page', 11, 'synthetic-second')
250
+ : reply('loaded-history', 11)
251
+ )
252
+ );
253
+ fireEvent.click(
254
+ screen.getByRole('button', { name: labels[locale].retry })
255
+ );
256
+ await waitFor(() =>
257
+ expect(screen.getByText('recovered-page')).toBeVisible()
258
+ );
259
+ expect(screen.getByText('loaded-history')).toBeVisible();
260
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
261
+ expect(
262
+ new URL(
263
+ transport.mock.calls.at(-1)?.[0],
264
+ window.location.origin
265
+ ).searchParams.get('page')
266
+ ).toBe('2');
267
+ });
268
+ }
269
+ });
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@tuturuuu/ui/button';
4
+ import { useTranslations } from 'next-intl';
5
+ import { useRef, useState } from 'react';
6
+
7
+ /** Retain the failure notice while the query temporarily clears its error. */
8
+ export function InvoiceHistoryRecovery({
9
+ failed,
10
+ fetching,
11
+ retry,
12
+ }: {
13
+ failed: boolean;
14
+ fetching: boolean;
15
+ retry: () => Promise<unknown>;
16
+ }) {
17
+ const t = useTranslations();
18
+ const owner = useRef(false);
19
+ const [pending, setPending] = useState(false);
20
+ if (!failed && !pending) return null;
21
+ const recover = async () => {
22
+ if (owner.current || fetching) return;
23
+ owner.current = true;
24
+ setPending(true);
25
+ try {
26
+ await retry();
27
+ } finally {
28
+ owner.current = false;
29
+ setPending(false);
30
+ }
31
+ };
32
+ return (
33
+ <div className="space-y-2 py-4" role="alert">
34
+ <p className="text-destructive text-sm">
35
+ {t('ws-invoices.history_load_failed')}
36
+ </p>
37
+ <Button
38
+ variant="outline"
39
+ size="sm"
40
+ disabled={pending || fetching}
41
+ onClick={recover}
42
+ >
43
+ {t('common.retry')}
44
+ </Button>
45
+ </div>
46
+ );
47
+ }
@@ -17,6 +17,7 @@ import { useEffect, useRef, useState } from 'react';
17
17
  import { useFinanceHref } from '../../finance-route-context';
18
18
  import { FinanceDisplayAmount } from '../../shared/finance-display-amount';
19
19
  import { useInfiniteUserInvoices } from '../hooks';
20
+ import { InvoiceHistoryRecovery } from './invoice-history-recovery';
20
21
 
21
22
  interface Props {
22
23
  wsId: string;
@@ -32,12 +33,20 @@ export function InvoiceUserHistoryAccordion({
32
33
  const t = useTranslations();
33
34
  const locale = useLocale();
34
35
  const financeHref = useFinanceHref();
35
- const formatDate = (value: string) =>
36
- new Intl.DateTimeFormat(locale).format(new Date(value));
36
+ const formatDate = (value: string) => {
37
+ const date = new Date(value);
38
+ return Number.isFinite(date.getTime())
39
+ ? new Intl.DateTimeFormat(locale).format(date)
40
+ : t('ws-invoices.no_date');
41
+ };
37
42
 
38
43
  const {
39
44
  data: userInvoicesData,
40
45
  isLoading,
46
+ isError,
47
+ isFetching,
48
+ isFetchNextPageError,
49
+ refetch,
41
50
  fetchNextPage,
42
51
  hasNextPage,
43
52
  isFetchingNextPage,
@@ -50,7 +59,13 @@ export function InvoiceUserHistoryAccordion({
50
59
  const invoices = userInvoicesData?.pages.flatMap((page) => page.data) || [];
51
60
 
52
61
  useEffect(() => {
53
- if (!isOpen || !loadMoreRef.current || !hasNextPage || isFetchingNextPage)
62
+ if (
63
+ !isOpen ||
64
+ !loadMoreRef.current ||
65
+ !hasNextPage ||
66
+ isFetchingNextPage ||
67
+ isError
68
+ )
54
69
  return;
55
70
 
56
71
  const scrollViewport = scrollAreaWrapperRef.current?.querySelector(
@@ -59,9 +74,10 @@ export function InvoiceUserHistoryAccordion({
59
74
 
60
75
  if (!scrollViewport) return;
61
76
 
77
+ let active = true;
62
78
  const observer = new IntersectionObserver(
63
79
  (entries) => {
64
- if (entries[0]?.isIntersecting) {
80
+ if (active && entries[0]?.isIntersecting) {
65
81
  fetchNextPage();
66
82
  }
67
83
  },
@@ -70,12 +86,25 @@ export function InvoiceUserHistoryAccordion({
70
86
 
71
87
  observer.observe(loadMoreRef.current);
72
88
 
73
- return () => observer.disconnect();
74
- }, [isOpen, hasNextPage, isFetchingNextPage, fetchNextPage]);
89
+ return () => {
90
+ active = false;
91
+ observer.disconnect();
92
+ };
93
+ }, [isOpen, hasNextPage, isFetchingNextPage, fetchNextPage, isError]);
94
+
95
+ const recovery = (
96
+ <InvoiceHistoryRecovery
97
+ key={JSON.stringify([wsId, userId])}
98
+ failed={isError}
99
+ fetching={isFetching}
100
+ retry={() => (isFetchNextPageError ? fetchNextPage() : refetch())}
101
+ />
102
+ );
75
103
 
76
104
  if (isLoading) {
77
105
  return (
78
106
  <div className="py-4 text-center">
107
+ {recovery}
79
108
  <p className="text-muted-foreground text-sm">
80
109
  {t('ws-invoices.loading_user_history')}
81
110
  </p>
@@ -86,15 +115,19 @@ export function InvoiceUserHistoryAccordion({
86
115
  if (invoices.length === 0) {
87
116
  return (
88
117
  <div className="py-4 text-center">
89
- <p className="text-muted-foreground text-sm">
90
- {t('ws-invoices.no_transaction_or_invoice_history')}
91
- </p>
118
+ {recovery}
119
+ {!isError && (
120
+ <p className="text-muted-foreground text-sm">
121
+ {t('ws-invoices.no_transaction_or_invoice_history')}
122
+ </p>
123
+ )}
92
124
  </div>
93
125
  );
94
126
  }
95
127
 
96
128
  return (
97
129
  <div className="mt-4">
130
+ {recovery}
98
131
  <Accordion
99
132
  type="single"
100
133
  collapsible
@@ -194,7 +227,7 @@ export function InvoiceUserHistoryAccordion({
194
227
  ))}
195
228
 
196
229
  <div ref={loadMoreRef} className="py-2">
197
- {hasNextPage && (
230
+ {hasNextPage && !isError && (
198
231
  <Button
199
232
  variant="ghost"
200
233
  size="sm"
@@ -0,0 +1,42 @@
1
+ import { Loader2 } from '@tuturuuu/icons';
2
+ import { Button } from '@tuturuuu/ui/button';
3
+ import { useTranslations } from 'next-intl';
4
+
5
+ export function SubscriptionCreateAction({
6
+ onCreate,
7
+ blockedReason,
8
+ creating,
9
+ disabled,
10
+ }: {
11
+ onCreate: () => Promise<void>;
12
+ blockedReason?:
13
+ | 'invalid_subscription_inventory'
14
+ | 'subscription_context_not_ready';
15
+ creating: boolean;
16
+ disabled: boolean;
17
+ }) {
18
+ const t = useTranslations();
19
+ return (
20
+ <>
21
+ {blockedReason && (
22
+ <p role="alert" className="text-destructive text-sm">
23
+ {t(`ws-invoices.${blockedReason}`)}
24
+ </p>
25
+ )}
26
+ <Button
27
+ className="w-full"
28
+ onClick={onCreate}
29
+ disabled={disabled || creating || !!blockedReason}
30
+ >
31
+ {creating ? (
32
+ <>
33
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
34
+ {t('ws-invoices.creating_subscription_invoice')}
35
+ </>
36
+ ) : (
37
+ t('ws-invoices.create_subscription_invoice')
38
+ )}
39
+ </Button>
40
+ </>
41
+ );
42
+ }