@umituz/web-dashboard 3.2.2 → 4.0.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/README.md +202 -14
- package/package.json +21 -29
- package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
- package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
- package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
- package/src/domains/analytics/components/MetricCard.tsx +42 -64
- package/src/domains/analytics/hooks/index.ts +7 -1
- package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
- package/src/domains/analytics/index.ts +11 -0
- package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
- package/src/domains/analytics/services/PerformanceService.ts +117 -69
- package/src/domains/analytics/types/analytics.ts +20 -6
- package/src/domains/analytics/types/index.ts +3 -0
- package/src/domains/analytics/utils/analytics.test.ts +183 -0
- package/src/domains/analytics/utils/analytics.ts +26 -9
- package/src/domains/analytics/utils/i18nKeys.ts +40 -0
- package/src/domains/analytics/utils/index.ts +2 -0
- package/src/domains/auth/components/AuthLayout.tsx +63 -54
- package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
- package/src/domains/auth/components/LoginForm.tsx +167 -226
- package/src/domains/auth/components/RegisterForm.tsx +202 -272
- package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
- package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
- package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
- package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
- package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
- package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
- package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
- package/src/domains/auth/components/shared/index.ts +20 -0
- package/src/domains/auth/hooks/index.ts +1 -0
- package/src/domains/auth/hooks/useAuth.ts +151 -232
- package/src/domains/auth/index.ts +8 -4
- package/src/domains/auth/types/auth.ts +26 -5
- package/src/domains/auth/types/index.ts +1 -0
- package/src/domains/auth/utils/auth.test.ts +182 -0
- package/src/domains/auth/utils/auth.ts +71 -130
- package/src/domains/auth/utils/i18nKeys.ts +127 -0
- package/src/domains/auth/utils/index.ts +7 -1
- package/src/domains/auth/utils/secureToken.ts +33 -0
- package/src/domains/auth/utils/validationKeys.ts +23 -0
- package/src/domains/calendar/hooks/useCalendar.ts +145 -151
- package/src/domains/calendar/index.ts +0 -1
- package/src/domains/calendar/services/CalendarService.ts +122 -179
- package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
- package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
- package/src/domains/calendar/utils/index.ts +33 -30
- package/src/domains/layouts/components/BrandLogo.tsx +12 -9
- package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
- package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
- package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
- package/src/domains/layouts/types/layout.ts +7 -1
- package/src/domains/layouts/types/user.ts +3 -1
- package/src/domains/modules/build.test.tsx +97 -0
- package/src/domains/modules/build.tsx +71 -0
- package/src/domains/modules/index.ts +2 -0
- package/src/domains/modules/types.ts +29 -0
- package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
- package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
- package/src/domains/onboarding/components/PlanStep.tsx +156 -123
- package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
- package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
- package/src/domains/onboarding/components/StepProgress.tsx +62 -0
- package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
- package/src/domains/onboarding/components/index.ts +4 -0
- package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
- package/src/domains/onboarding/index.ts +1 -0
- package/src/domains/onboarding/types/onboarding.ts +57 -17
- package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
- package/src/domains/onboarding/utils/index.ts +2 -0
- package/src/domains/permissions/PermissionsContext.tsx +103 -0
- package/src/domains/permissions/hasPermission.test.ts +50 -0
- package/src/domains/permissions/hasPermission.ts +38 -0
- package/src/domains/permissions/index.ts +19 -0
- package/src/domains/permissions/types.ts +35 -0
- package/src/domains/permissions/utils/i18nKeys.ts +11 -0
- package/src/domains/session/DemoLogin.tsx +50 -0
- package/src/domains/session/OrgSwitcher.tsx +110 -0
- package/src/domains/session/SessionContext.tsx +150 -0
- package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
- package/src/domains/session/index.ts +18 -0
- package/src/domains/session/types.ts +47 -0
- package/src/domains/session/utils/i18nKeys.ts +12 -0
- package/src/domains/settings/components/AuditLogList.tsx +59 -0
- package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
- package/src/domains/settings/components/SettingsLayout.tsx +75 -60
- package/src/domains/settings/components/SettingsSection.tsx +35 -22
- package/src/domains/settings/components/UserManagementTable.tsx +87 -0
- package/src/domains/settings/components/index.ts +10 -0
- package/src/domains/settings/hooks/useSettings.ts +77 -52
- package/src/domains/settings/types/settings.ts +2 -0
- package/src/domains/settings/utils/i18nKeys.ts +18 -0
- package/src/domains/settings/utils/settings.ts +11 -4
- package/src/i18n/I18nContext.tsx +33 -0
- package/src/i18n/defaultT.test.ts +43 -0
- package/src/i18n/defaultT.ts +34 -0
- package/src/i18n/index.ts +7 -0
- package/src/i18n/types.ts +13 -0
- package/src/index.ts +1 -7
- package/src/primitives/Button.tsx +30 -0
- package/src/primitives/Checkbox.tsx +40 -0
- package/src/primitives/Input.tsx +19 -0
- package/src/primitives/Skeleton.tsx +26 -0
- package/src/primitives/cn.ts +15 -0
- package/src/primitives/index.ts +23 -0
- package/src/styles/dashboard.css +3045 -0
- package/src/styles/tokens.contract.test.ts +66 -0
- package/src/styles/tokens.css +124 -0
- package/src/domains/billing/components/BillingLayout.tsx +0 -63
- package/src/domains/billing/components/BillingPage.tsx +0 -49
- package/src/domains/billing/components/BillingPortal.tsx +0 -198
- package/src/domains/billing/components/InvoiceCard.tsx +0 -143
- package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
- package/src/domains/billing/components/PlanComparison.tsx +0 -199
- package/src/domains/billing/components/UsageCard.tsx +0 -103
- package/src/domains/billing/components/index.ts +0 -13
- package/src/domains/billing/constants/billing.ts +0 -69
- package/src/domains/billing/hooks/index.ts +0 -7
- package/src/domains/billing/hooks/useBilling.ts +0 -385
- package/src/domains/billing/index.ts +0 -73
- package/src/domains/billing/types/billing.ts +0 -342
- package/src/domains/billing/types/index.ts +0 -30
- package/src/domains/billing/utils/billing.ts +0 -360
- package/src/domains/billing/utils/index.ts +0 -31
- package/src/domains/billing/utils/polar.ts +0 -63
- package/src/domains/calendar/components/index.ts +0 -7
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Calendar Service
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Camp-agnostic calendar service for managing content items.
|
|
5
|
+
* Persistence is injected via the `ICalendarDatabase` seam — the
|
|
6
|
+
* package ships no Firebase (or any other) backend. Use
|
|
7
|
+
* `createInMemoryCalendarDatabase()` for demos and tests, or adapt
|
|
8
|
+
* your own backend to the interface.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import type {
|
|
@@ -16,7 +20,7 @@ import type {
|
|
|
16
20
|
* Database interface for calendar operations
|
|
17
21
|
* Implementations can provide different backends
|
|
18
22
|
*/
|
|
19
|
-
interface ICalendarDatabase {
|
|
23
|
+
export interface ICalendarDatabase {
|
|
20
24
|
getItems(userId: string): Promise<ContentItem[]>;
|
|
21
25
|
getItemById(id: string): Promise<ContentItem | null>;
|
|
22
26
|
createItem(userId: string, item: CreateContentItemParams): Promise<ContentItem>;
|
|
@@ -25,125 +29,120 @@ interface ICalendarDatabase {
|
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
|
-
*
|
|
32
|
+
* Resolve scheduled_at to ISO string regardless of source type
|
|
29
33
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const { collection, query, where, getDocs } = await import('firebase/firestore');
|
|
34
|
-
const { getFirebaseDB } = await import('@umituz/web-firebase');
|
|
35
|
-
const db = getFirebaseDB();
|
|
36
|
-
|
|
37
|
-
const calendarQuery = query(
|
|
38
|
-
collection(db as any, 'calendar_items'),
|
|
39
|
-
where("user_id", "==", userId)
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
const postsQuery = query(
|
|
43
|
-
collection(db as any, "posts"),
|
|
44
|
-
where("userId", "==", userId)
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
const [calendarSnap, postsSnap] = await Promise.all([
|
|
48
|
-
getDocs(calendarQuery),
|
|
49
|
-
getDocs(postsQuery)
|
|
50
|
-
]);
|
|
51
|
-
|
|
52
|
-
const calendarItems = calendarSnap.docs.map((doc: { id: string; data: () => Record<string, unknown> }) => ({
|
|
53
|
-
id: doc.id,
|
|
54
|
-
...doc.data()
|
|
55
|
-
} as ContentItem));
|
|
56
|
-
|
|
57
|
-
const postItems = postsSnap.docs.map((doc: { id: string; data: () => Record<string, unknown> }) => {
|
|
58
|
-
const data = doc.data();
|
|
59
|
-
return {
|
|
60
|
-
id: doc.id,
|
|
61
|
-
title: data.title || 'Untitled Post',
|
|
62
|
-
description: data.content || '',
|
|
63
|
-
scheduled_at: data.scheduledAt
|
|
64
|
-
? (typeof data.scheduledAt === 'string' ? data.scheduledAt : (data.scheduledAt as { toDate: () => Date })?.toDate?.().toISOString())
|
|
65
|
-
: new Date().toISOString(),
|
|
66
|
-
platforms: data.platform ? [data.platform] : [],
|
|
67
|
-
app_name: data.appName || 'My App',
|
|
68
|
-
status: data.status || 'draft',
|
|
69
|
-
type: 'post'
|
|
70
|
-
} as ContentItem;
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
return [...calendarItems, ...postItems].sort((a, b) =>
|
|
74
|
-
new Date(b.scheduled_at).getTime() - new Date(a.scheduled_at).getTime()
|
|
75
|
-
);
|
|
34
|
+
const resolveScheduledAt = (value: string | Date | undefined): string => {
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
throw new Error('scheduled_at is required to create or update a content item');
|
|
76
37
|
}
|
|
38
|
+
if (typeof value === 'string') return value;
|
|
39
|
+
return value.toISOString();
|
|
40
|
+
};
|
|
77
41
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
42
|
+
/**
|
|
43
|
+
* In-memory calendar database — zero-dependency default for demos
|
|
44
|
+
* and tests. Data lives per-instance for the lifetime of the page.
|
|
45
|
+
*/
|
|
46
|
+
export const createInMemoryCalendarDatabase = (): ICalendarDatabase => {
|
|
47
|
+
const items = new Map<string, ContentItem>();
|
|
48
|
+
|
|
49
|
+
const findByUser = (userId: string): ContentItem[] =>
|
|
50
|
+
[...items.values()].filter((item) => item.user_id === userId);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
async getItems(userId: string): Promise<ContentItem[]> {
|
|
54
|
+
return findByUser(userId).sort(
|
|
55
|
+
(a, b) =>
|
|
56
|
+
new Date(b.scheduled_at).getTime() - new Date(a.scheduled_at).getTime(),
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async getItemById(id: string): Promise<ContentItem | null> {
|
|
61
|
+
return items.get(id) ?? null;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async createItem(
|
|
65
|
+
userId: string,
|
|
66
|
+
item: CreateContentItemParams,
|
|
67
|
+
): Promise<ContentItem> {
|
|
68
|
+
const created: ContentItem = {
|
|
69
|
+
id: `item-${items.size + 1}-${userId}`,
|
|
70
|
+
title: item.title ?? '',
|
|
71
|
+
description: item.description ?? '',
|
|
72
|
+
scheduled_at: resolveScheduledAt(item.scheduled_at),
|
|
73
|
+
platforms: item.platforms ?? [],
|
|
74
|
+
app_name: item.app_name ?? '',
|
|
75
|
+
status: item.status ?? 'draft',
|
|
76
|
+
type: item.type ?? 'post',
|
|
77
|
+
user_id: userId,
|
|
78
|
+
created_at: new Date().toISOString(),
|
|
79
|
+
};
|
|
80
|
+
items.set(created.id, created);
|
|
81
|
+
return created;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async updateItem(id: string, updates: UpdateContentItemParams): Promise<void> {
|
|
85
|
+
const current = items.get(id);
|
|
86
|
+
if (!current) {
|
|
87
|
+
throw new Error(`Calendar item not found: ${id}`);
|
|
88
|
+
}
|
|
89
|
+
items.set(id, {
|
|
90
|
+
...current,
|
|
91
|
+
...updates,
|
|
92
|
+
scheduled_at:
|
|
93
|
+
updates.scheduled_at !== undefined
|
|
94
|
+
? resolveScheduledAt(updates.scheduled_at)
|
|
95
|
+
: current.scheduled_at,
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async deleteItem(id: string): Promise<void> {
|
|
100
|
+
items.delete(id);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
};
|
|
85
104
|
|
|
86
|
-
|
|
87
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Apply a CalendarFilter to a list of items.
|
|
107
|
+
* Single source of truth for filtering, shared between hook and service.
|
|
108
|
+
*/
|
|
109
|
+
const applyCalendarFilter = (items: ContentItem[], filter?: CalendarFilter): ContentItem[] => {
|
|
110
|
+
if (!filter) return items;
|
|
111
|
+
|
|
112
|
+
return items.filter((item) => {
|
|
113
|
+
if (filter.search) {
|
|
114
|
+
const title = item.title?.toLowerCase() ?? '';
|
|
115
|
+
const query = filter.search.toLowerCase();
|
|
116
|
+
if (!title.includes(query)) return false;
|
|
88
117
|
}
|
|
89
118
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
async createItem(userId: string, item: CreateContentItemParams): Promise<ContentItem> {
|
|
97
|
-
const { collection, addDoc, serverTimestamp } = await import('firebase/firestore');
|
|
98
|
-
const { getFirebaseDB } = await import('@umituz/web-firebase');
|
|
99
|
-
const db = getFirebaseDB();
|
|
100
|
-
|
|
101
|
-
const docRef = await addDoc(collection(db as any, 'calendar_items'), {
|
|
102
|
-
...item,
|
|
103
|
-
scheduled_at: typeof item.scheduled_at === 'string' ? item.scheduled_at : item.scheduled_at.toISOString(),
|
|
104
|
-
user_id: userId,
|
|
105
|
-
created_at: serverTimestamp(),
|
|
106
|
-
updated_at: serverTimestamp()
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
id: docRef.id,
|
|
111
|
-
...item,
|
|
112
|
-
scheduled_at: typeof item.scheduled_at === 'string' ? item.scheduled_at : item.scheduled_at.toISOString(),
|
|
113
|
-
user_id: userId,
|
|
114
|
-
created_at: new Date().toISOString()
|
|
115
|
-
} as ContentItem;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async updateItem(id: string, updates: UpdateContentItemParams): Promise<void> {
|
|
119
|
-
const { doc, updateDoc, serverTimestamp } = await import('firebase/firestore');
|
|
120
|
-
const { getFirebaseDB } = await import('@umituz/web-firebase');
|
|
121
|
-
const db = getFirebaseDB();
|
|
122
|
-
|
|
123
|
-
const docRef = doc(db as any, 'calendar_items', id);
|
|
119
|
+
if (filter.platforms && filter.platforms.length > 0) {
|
|
120
|
+
const itemPlatforms = item.platforms ?? [];
|
|
121
|
+
if (!itemPlatforms.some((p) => filter.platforms?.includes(p))) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
: updates.scheduled_at.toISOString();
|
|
126
|
+
if (filter.types && filter.types.length > 0) {
|
|
127
|
+
if (!item.type || !filter.types.includes(item.type)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
136
|
-
}
|
|
132
|
+
if (filter.status && item.status !== filter.status) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
137
135
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
if (filter.dateRange) {
|
|
137
|
+
const itemDate = new Date(item.scheduled_at);
|
|
138
|
+
if (itemDate < filter.dateRange.start || itemDate > filter.dateRange.end) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
};
|
|
147
146
|
|
|
148
147
|
/**
|
|
149
148
|
* Calendar Service Implementation
|
|
@@ -152,19 +151,10 @@ class FirebaseCalendarDatabase implements ICalendarDatabase {
|
|
|
152
151
|
* Uses database interface for backend abstraction
|
|
153
152
|
*/
|
|
154
153
|
export class CalendarService implements ICalendarService {
|
|
155
|
-
private static instance: CalendarService;
|
|
156
154
|
private database: ICalendarDatabase;
|
|
157
155
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.database = new FirebaseCalendarDatabase();
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
public static getInstance(): CalendarService {
|
|
164
|
-
if (!CalendarService.instance) {
|
|
165
|
-
CalendarService.instance = new CalendarService();
|
|
166
|
-
}
|
|
167
|
-
return CalendarService.instance;
|
|
156
|
+
constructor(database: ICalendarDatabase = createInMemoryCalendarDatabase()) {
|
|
157
|
+
this.database = database;
|
|
168
158
|
}
|
|
169
159
|
|
|
170
160
|
/**
|
|
@@ -175,90 +165,43 @@ export class CalendarService implements ICalendarService {
|
|
|
175
165
|
}
|
|
176
166
|
|
|
177
167
|
/**
|
|
178
|
-
* Get all content items for a user
|
|
168
|
+
* Get all content items for a user, with optional filter
|
|
179
169
|
*/
|
|
180
170
|
async getContentItems(userId: string, filter?: CalendarFilter): Promise<ContentItem[]> {
|
|
181
171
|
const items = await this.database.getItems(userId);
|
|
182
|
-
|
|
183
|
-
if (!filter) return items;
|
|
184
|
-
|
|
185
|
-
// Apply filters
|
|
186
|
-
return items.filter(item => {
|
|
187
|
-
// Search filter
|
|
188
|
-
if (filter.search && !item.title.toLowerCase().includes(filter.search.toLowerCase())) {
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Platform filter
|
|
193
|
-
if (filter.platforms && filter.platforms.length > 0) {
|
|
194
|
-
if (!item.platforms.some(p => filter.platforms?.includes(p))) {
|
|
195
|
-
return false;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Type filter
|
|
200
|
-
if (filter.types && filter.types.length > 0) {
|
|
201
|
-
if (!item.type || !filter.types.includes(item.type)) {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Status filter
|
|
207
|
-
if (filter.status && item.status !== filter.status) {
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Date range filter
|
|
212
|
-
if (filter.dateRange) {
|
|
213
|
-
const itemDate = new Date(item.scheduled_at);
|
|
214
|
-
if (itemDate < filter.dateRange.start || itemDate > filter.dateRange.end) {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return true;
|
|
220
|
-
});
|
|
172
|
+
return applyCalendarFilter(items, filter);
|
|
221
173
|
}
|
|
222
174
|
|
|
223
|
-
/**
|
|
224
|
-
* Get a single content item by ID
|
|
225
|
-
*/
|
|
226
175
|
async getContentItemById(id: string): Promise<ContentItem | null> {
|
|
227
176
|
return this.database.getItemById(id);
|
|
228
177
|
}
|
|
229
178
|
|
|
230
|
-
/**
|
|
231
|
-
* Create a new content item
|
|
232
|
-
*/
|
|
233
179
|
async createContentItem(userId: string, item: CreateContentItemParams): Promise<ContentItem> {
|
|
234
180
|
return this.database.createItem(userId, item);
|
|
235
181
|
}
|
|
236
182
|
|
|
237
|
-
/**
|
|
238
|
-
* Update an existing content item
|
|
239
|
-
*/
|
|
240
183
|
async updateContentItem(id: string, updates: UpdateContentItemParams): Promise<void> {
|
|
241
184
|
await this.database.updateItem(id, updates);
|
|
242
185
|
}
|
|
243
186
|
|
|
244
|
-
/**
|
|
245
|
-
* Delete a content item
|
|
246
|
-
*/
|
|
247
187
|
async deleteContentItem(id: string): Promise<void> {
|
|
248
188
|
await this.database.deleteItem(id);
|
|
249
189
|
}
|
|
250
190
|
|
|
251
|
-
/**
|
|
252
|
-
* Move content item to a new date
|
|
253
|
-
*/
|
|
254
191
|
async moveContentItem(id: string, newDate: Date): Promise<void> {
|
|
255
192
|
await this.updateContentItem(id, {
|
|
256
|
-
scheduled_at: newDate
|
|
193
|
+
scheduled_at: newDate,
|
|
257
194
|
});
|
|
258
195
|
}
|
|
259
196
|
}
|
|
260
197
|
|
|
261
198
|
/**
|
|
262
|
-
*
|
|
199
|
+
* Default singleton instance (in-memory — swap the backend via
|
|
200
|
+
* `setDatabase` or construct your own `CalendarService`).
|
|
201
|
+
*/
|
|
202
|
+
export const calendarService = new CalendarService();
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Exported for testing/extension.
|
|
263
206
|
*/
|
|
264
|
-
export
|
|
207
|
+
export { applyCalendarFilter };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the calendar Firestore → ContentItem mappers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { mapCalendarDocument, mapPostDocument } from "./contentItemMapper";
|
|
7
|
+
import type { FirestoreDocument } from "./contentItemMapper";
|
|
8
|
+
|
|
9
|
+
const makeDoc = (data: Record<string, unknown>): FirestoreDocument => ({
|
|
10
|
+
id: "doc-1",
|
|
11
|
+
data: () => data as never,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("mapCalendarDocument", () => {
|
|
15
|
+
it("maps a fully populated document", () => {
|
|
16
|
+
const item = mapCalendarDocument(
|
|
17
|
+
makeDoc({
|
|
18
|
+
title: "Launch post",
|
|
19
|
+
description: "Hello world",
|
|
20
|
+
scheduled_at: "2026-09-01T10:00:00.000Z",
|
|
21
|
+
platforms: ["twitter", "linkedin"],
|
|
22
|
+
app_name: "MyApp",
|
|
23
|
+
status: "scheduled",
|
|
24
|
+
type: "post",
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(item).toEqual({
|
|
29
|
+
id: "doc-1",
|
|
30
|
+
title: "Launch post",
|
|
31
|
+
description: "Hello world",
|
|
32
|
+
scheduled_at: "2026-09-01T10:00:00.000Z",
|
|
33
|
+
platforms: ["twitter", "linkedin"],
|
|
34
|
+
app_name: "MyApp",
|
|
35
|
+
status: "scheduled",
|
|
36
|
+
type: "post",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("falls back to empty strings, never to package-side copy", () => {
|
|
41
|
+
const item = mapCalendarDocument(makeDoc({}));
|
|
42
|
+
|
|
43
|
+
expect(item.title).toBe("");
|
|
44
|
+
expect(item.description).toBe("");
|
|
45
|
+
expect(item.app_name).toBe("");
|
|
46
|
+
expect(item.platforms).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("normalizes unknown status/type to draft/post", () => {
|
|
50
|
+
const item = mapCalendarDocument(
|
|
51
|
+
makeDoc({ status: "weird", type: "unsupported" }),
|
|
52
|
+
);
|
|
53
|
+
expect(item.status).toBe("draft");
|
|
54
|
+
expect(item.type).toBe("post");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("converts Firestore timestamps via toDate()", () => {
|
|
58
|
+
const item = mapCalendarDocument(
|
|
59
|
+
makeDoc({
|
|
60
|
+
scheduled_at: { toDate: () => new Date("2026-09-01T00:00:00.000Z") },
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
expect(item.scheduled_at).toBe("2026-09-01T00:00:00.000Z");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("maps a missing scheduled_at to the epoch, not 'now'", () => {
|
|
67
|
+
const item = mapCalendarDocument(makeDoc({}));
|
|
68
|
+
expect(item.scheduled_at).toBe(new Date(0).toISOString());
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prefers camelCase appName as fallback", () => {
|
|
72
|
+
const item = mapCalendarDocument(makeDoc({ appName: "CamelApp" }));
|
|
73
|
+
expect(item.app_name).toBe("CamelApp");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("mapPostDocument", () => {
|
|
78
|
+
it("maps a posts-collection document", () => {
|
|
79
|
+
const item = mapPostDocument(
|
|
80
|
+
makeDoc({
|
|
81
|
+
title: "Post title",
|
|
82
|
+
content: "Body text",
|
|
83
|
+
scheduledAt: "2026-10-01T08:00:00.000Z",
|
|
84
|
+
platform: "instagram",
|
|
85
|
+
appName: "MyApp",
|
|
86
|
+
status: "published",
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(item).toEqual({
|
|
91
|
+
id: "doc-1",
|
|
92
|
+
title: "Post title",
|
|
93
|
+
description: "Body text",
|
|
94
|
+
scheduled_at: "2026-10-01T08:00:00.000Z",
|
|
95
|
+
platforms: ["instagram"],
|
|
96
|
+
app_name: "MyApp",
|
|
97
|
+
status: "published",
|
|
98
|
+
type: "post",
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("falls back to empty strings for missing display fields", () => {
|
|
103
|
+
const item = mapPostDocument(makeDoc({}));
|
|
104
|
+
expect(item.title).toBe("");
|
|
105
|
+
expect(item.description).toBe("");
|
|
106
|
+
expect(item.platforms).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Item Mapper
|
|
3
|
+
*
|
|
4
|
+
* Type-safe transformation between wire-format documents and domain ContentItem objects.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ContentItem, ContentStatus, ContentType } from '../types/calendar.types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Raw wire document shape for calendar_items records
|
|
11
|
+
*/
|
|
12
|
+
interface CalendarItemDocument {
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
scheduled_at?: string | { toDate: () => Date };
|
|
16
|
+
platforms?: string[];
|
|
17
|
+
app_name?: string;
|
|
18
|
+
status?: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
content?: string;
|
|
21
|
+
platform?: string;
|
|
22
|
+
appName?: string;
|
|
23
|
+
scheduledAt?: string | { toDate: () => Date };
|
|
24
|
+
userId?: string;
|
|
25
|
+
user_id?: string;
|
|
26
|
+
created_at?: string | { toDate: () => Date };
|
|
27
|
+
updated_at?: string | { toDate: () => Date };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Firestore document with id
|
|
32
|
+
*/
|
|
33
|
+
export interface FirestoreDocument<T = Record<string, unknown>> {
|
|
34
|
+
id: string;
|
|
35
|
+
data: () => T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert Firebase timestamp value to ISO string.
|
|
40
|
+
* A missing value maps to the epoch rather than "now" — missing data
|
|
41
|
+
* must not masquerade as freshly-scheduled content in date-sorted views.
|
|
42
|
+
*/
|
|
43
|
+
const toIsoString = (value: string | { toDate: () => Date } | undefined): string => {
|
|
44
|
+
if (!value) return new Date(0).toISOString();
|
|
45
|
+
if (typeof value === 'string') return value;
|
|
46
|
+
return value.toDate().toISOString();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize status string to ContentStatus union
|
|
51
|
+
*/
|
|
52
|
+
const normalizeStatus = (status: string | undefined): ContentStatus => {
|
|
53
|
+
const valid: ContentStatus[] = ['draft', 'scheduled', 'published', 'failed'];
|
|
54
|
+
return valid.includes(status as ContentStatus) ? (status as ContentStatus) : 'draft';
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Normalize type string to ContentType union
|
|
59
|
+
*/
|
|
60
|
+
const normalizeType = (type: string | undefined): ContentType => {
|
|
61
|
+
const valid: ContentType[] = ['post', 'story', 'reel', 'tweet', 'article'];
|
|
62
|
+
return valid.includes(type as ContentType) ? (type as ContentType) : 'post';
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Map calendar_items document to ContentItem
|
|
67
|
+
*/
|
|
68
|
+
export const mapCalendarDocument = (doc: FirestoreDocument<CalendarItemDocument>): ContentItem => {
|
|
69
|
+
const data = doc.data();
|
|
70
|
+
return {
|
|
71
|
+
id: doc.id,
|
|
72
|
+
title: data.title ?? '',
|
|
73
|
+
description: data.description ?? data.content ?? '',
|
|
74
|
+
scheduled_at: toIsoString(data.scheduled_at),
|
|
75
|
+
platforms: Array.isArray(data.platforms) ? data.platforms : [],
|
|
76
|
+
app_name: data.app_name ?? data.appName ?? '',
|
|
77
|
+
status: normalizeStatus(data.status),
|
|
78
|
+
type: normalizeType(data.type),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Map posts document to ContentItem.
|
|
84
|
+
* Missing display fields fall back to empty strings so the consuming
|
|
85
|
+
* UI can render its own (localized) placeholder — no package-side copy.
|
|
86
|
+
*/
|
|
87
|
+
export const mapPostDocument = (doc: FirestoreDocument<CalendarItemDocument>): ContentItem => {
|
|
88
|
+
const data = doc.data();
|
|
89
|
+
return {
|
|
90
|
+
id: doc.id,
|
|
91
|
+
title: data.title ?? '',
|
|
92
|
+
description: data.content ?? '',
|
|
93
|
+
scheduled_at: toIsoString(data.scheduledAt),
|
|
94
|
+
platforms: data.platform ? [data.platform] : [],
|
|
95
|
+
app_name: data.appName ?? '',
|
|
96
|
+
status: normalizeStatus(data.status),
|
|
97
|
+
type: 'post',
|
|
98
|
+
};
|
|
99
|
+
};
|
|
@@ -4,36 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
import type { CalendarConfig } from '../types/calendar.types';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*/
|
|
10
|
-
export const DEFAULT_CALENDAR_CONFIG: CalendarConfig = {
|
|
11
|
-
defaultView: 'month',
|
|
12
|
-
showWeekends: true,
|
|
13
|
-
startOfWeek: 0, // Sunday
|
|
14
|
-
hourRange: {
|
|
15
|
-
start: 0,
|
|
16
|
-
end: 23,
|
|
17
|
-
},
|
|
18
|
-
enableDragDrop: true,
|
|
19
|
-
showPlatformFilters: true,
|
|
20
|
-
platforms: ['instagram', 'facebook', 'twitter', 'linkedin', 'tiktok'],
|
|
21
|
-
slotDuration: 30, // 30 minutes
|
|
22
|
-
};
|
|
7
|
+
export { mapCalendarDocument, mapPostDocument } from './contentItemMapper';
|
|
8
|
+
export type { FirestoreDocument } from './contentItemMapper';
|
|
23
9
|
|
|
24
10
|
/**
|
|
25
11
|
* Get week dates for a given date
|
|
26
12
|
*/
|
|
27
13
|
export function getWeekDates(date: Date, startOfWeek: number = 0): Date[] {
|
|
28
|
-
const week = [];
|
|
14
|
+
const week: Date[] = [];
|
|
29
15
|
const current = new Date(date);
|
|
30
16
|
|
|
31
|
-
// Find the first day of the week
|
|
32
17
|
const day = current.getDay();
|
|
33
18
|
const diff = (day < startOfWeek ? 7 : 0) + day - startOfWeek;
|
|
34
19
|
current.setDate(current.getDate() - diff);
|
|
35
20
|
|
|
36
|
-
// Get 7 days
|
|
37
21
|
for (let i = 0; i < 7; i++) {
|
|
38
22
|
week.push(new Date(current));
|
|
39
23
|
current.setDate(current.getDate() + 1);
|
|
@@ -68,17 +52,22 @@ export function isDateInRange(date: Date, range: { start: Date; end: Date }): bo
|
|
|
68
52
|
}
|
|
69
53
|
|
|
70
54
|
/**
|
|
71
|
-
* Format date for display
|
|
55
|
+
* Format date for display.
|
|
56
|
+
* The locale is taken as a parameter so the consumer can decide.
|
|
72
57
|
*/
|
|
73
|
-
export function formatDate(
|
|
58
|
+
export function formatDate(
|
|
59
|
+
date: Date,
|
|
60
|
+
format: 'short' | 'long' | 'time' = 'short',
|
|
61
|
+
locale: string = 'en-US',
|
|
62
|
+
): string {
|
|
74
63
|
if (format === 'time') {
|
|
75
|
-
return date.toLocaleTimeString(
|
|
64
|
+
return date.toLocaleTimeString(locale, {
|
|
76
65
|
hour: '2-digit',
|
|
77
66
|
minute: '2-digit',
|
|
78
67
|
});
|
|
79
68
|
}
|
|
80
69
|
|
|
81
|
-
return date.toLocaleDateString(
|
|
70
|
+
return date.toLocaleDateString(locale, {
|
|
82
71
|
month: format === 'long' ? 'long' : 'short',
|
|
83
72
|
day: 'numeric',
|
|
84
73
|
year: 'numeric',
|
|
@@ -99,25 +88,32 @@ export function isSameDay(date1: Date, date2: Date): boolean {
|
|
|
99
88
|
/**
|
|
100
89
|
* Get date range for a view
|
|
101
90
|
*/
|
|
102
|
-
export function getViewRange(
|
|
91
|
+
export function getViewRange(
|
|
92
|
+
view: 'month' | 'week',
|
|
93
|
+
date: Date,
|
|
94
|
+
): { start: Date; end: Date } {
|
|
103
95
|
if (view === 'month') {
|
|
104
96
|
const start = new Date(date.getFullYear(), date.getMonth(), 1);
|
|
105
97
|
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
|
106
98
|
return { start, end };
|
|
107
99
|
}
|
|
108
100
|
|
|
109
|
-
// Week view
|
|
110
101
|
const week = getWeekDates(date);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
102
|
+
const firstDay = new Date(week[0]);
|
|
103
|
+
firstDay.setHours(0, 0, 0, 0);
|
|
104
|
+
const lastDay = new Date(week[6]);
|
|
105
|
+
lastDay.setHours(23, 59, 59, 999);
|
|
106
|
+
return { start: firstDay, end: lastDay };
|
|
115
107
|
}
|
|
116
108
|
|
|
117
109
|
/**
|
|
118
110
|
* Generate time slots
|
|
119
111
|
*/
|
|
120
|
-
export function generateTimeSlots(
|
|
112
|
+
export function generateTimeSlots(
|
|
113
|
+
startHour: number,
|
|
114
|
+
endHour: number,
|
|
115
|
+
slotDuration: number,
|
|
116
|
+
): string[] {
|
|
121
117
|
const slots: string[] = [];
|
|
122
118
|
|
|
123
119
|
for (let hour = startHour; hour < endHour; hour++) {
|
|
@@ -129,3 +125,10 @@ export function generateTimeSlots(startHour: number, endHour: number, slotDurati
|
|
|
129
125
|
|
|
130
126
|
return slots;
|
|
131
127
|
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Re-export the default calendar config from the central config module
|
|
131
|
+
* so consumers using only the calendar domain still get sensible defaults.
|
|
132
|
+
*/
|
|
133
|
+
export { DEFAULT_CALENDAR_CONFIG } from '../../../domain/config/CalendarConfig';
|
|
134
|
+
export type { CalendarConfig };
|