Compare commits
239 Commits
4b5dfc6557
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b4496dca | ||
|
|
d58f597ba6 | ||
|
|
8be40dc0f9 | ||
|
|
52b16dce07 | ||
|
|
8e64780f72 | ||
|
|
da84d0e44d | ||
|
|
f3117308c3 | ||
|
|
d47be3aca6 | ||
|
|
221ae195e9 | ||
|
|
ca163e13cf | ||
|
|
713d881445 | ||
|
|
9c2f367447 | ||
|
|
d0d824a661 | ||
|
|
e268ef7756 | ||
|
|
bfc1f505bc | ||
|
|
1ef85a22d5 | ||
|
|
7165fcee9b | ||
|
|
a1ba5f342b | ||
|
|
a801e2d344 | ||
|
|
269e384665 | ||
|
|
d6126d1943 | ||
|
|
a423a6d31d | ||
|
|
87539eb51f | ||
|
|
e4a09a676e | ||
|
|
e79e982401 | ||
|
|
aeeb02d36b | ||
|
|
47a645520c | ||
|
|
8d40a8cb29 | ||
|
|
d126f2d9c6 | ||
|
|
7cc8d47ecf | ||
|
|
71d6da4530 | ||
|
|
8fc31b402d | ||
|
|
15760d6430 | ||
|
|
ab7033b82e | ||
|
|
b7bde1df04 | ||
|
|
2b98a5460d | ||
|
|
44484afb84 | ||
|
|
963160d165 | ||
|
|
ce10be63f3 | ||
|
|
8217261706 | ||
|
|
053465afa3 | ||
|
|
4f9a6f4ae3 | ||
|
|
9f8ee0d7d2 | ||
|
|
1fbaf4d360 | ||
|
|
485263903f | ||
|
|
00de020b6c | ||
|
|
5f753464fd | ||
|
|
1749056542 | ||
|
|
2ce5c2efe8 | ||
|
|
72799b981d | ||
|
|
eee6339074 | ||
|
|
8f46c5cfd9 | ||
|
|
74bc709684 | ||
|
|
dafa4eeeb3 | ||
|
|
da9a68f084 | ||
|
|
3196c0ac01 | ||
|
|
bd3841b716 | ||
|
|
967829b612 | ||
|
|
08e56a22d8 | ||
|
|
fa1adcf291 | ||
|
|
079c0f947c | ||
|
|
06d6845456 | ||
|
|
219ad11202 | ||
|
|
c6250d2b47 | ||
|
|
0d29c953c1 | ||
|
|
b1aefea526 | ||
|
|
e6e3bc39d4 | ||
|
|
2f7797803c | ||
|
|
877223342e | ||
|
|
0d1f8d795e | ||
|
|
db882f48c4 | ||
|
|
60baf32f73 | ||
|
|
41f7b797e7 | ||
|
|
7c6d335fa1 | ||
|
|
314cfa6c65 | ||
|
|
2357e6ebdd | ||
|
|
b7e5385d65 | ||
|
|
a1acbd9395 | ||
|
|
b2a5d2fca6 | ||
|
|
50d7d6a8dc | ||
|
|
b335164a58 | ||
|
|
0df57bbac5 | ||
|
|
91fffe9743 | ||
|
|
84de0a7efe | ||
|
|
726250507a | ||
|
|
1b13c7150e | ||
|
|
cd7cbfe13b | ||
|
|
95fd4d3859 | ||
|
|
86b59c756f | ||
|
|
c6b45378f3 | ||
|
|
ad7b6130b1 | ||
|
|
f68c8ee1c4 | ||
|
|
0be27ccf99 | ||
|
|
9e76d07cc2 | ||
|
|
a9ad84eb23 | ||
|
|
94aca1edec | ||
|
|
da71acb431 | ||
|
|
52ec0b9b86 | ||
|
|
ac88e17856 | ||
|
|
3eb53406c9 | ||
|
|
b88e308b84 | ||
|
|
5c20ea16a3 | ||
|
|
5a53cf3f99 | ||
|
|
9bb922f5aa | ||
|
|
b1bd092eb8 | ||
|
|
5ab4e6b974 | ||
|
|
952bb209cf | ||
|
|
a8341a42ee | ||
|
|
2f198a4d72 | ||
|
|
0a299466d8 | ||
|
|
c993abe1e9 | ||
|
|
690268362a | ||
|
|
3e418759a1 | ||
|
|
0e3a45cfe2 | ||
|
|
79e1bd82fc | ||
|
|
777d989d34 | ||
|
|
4d8f66ed3a | ||
|
|
47d78cbd98 | ||
|
|
42d6bd98e2 | ||
|
|
3f0acca658 | ||
|
|
17440cdf89 | ||
|
|
73c03285ea | ||
|
|
293d5bd65d | ||
|
|
390fde9bf2 | ||
|
|
1743f95000 | ||
|
|
a567b683af | ||
|
|
2dae2fdc33 | ||
|
|
c09d8b0c2a | ||
|
|
bf212fb973 | ||
|
|
5a05203f2b | ||
|
|
d089fcc769 | ||
|
|
81bbafcff0 | ||
|
|
b955445dea | ||
|
|
a824e101ed | ||
|
|
74b7dd09ea | ||
|
|
9b2ac9beee | ||
|
|
734aa967ac | ||
|
|
91bec42c4b | ||
|
|
e512956444 | ||
|
|
f1fb2758f8 | ||
|
|
ae2a0bf3a1 | ||
|
|
ed0d1b0ac8 | ||
|
|
b4d3b1a580 | ||
|
|
50a642b07b | ||
|
|
a412baad53 | ||
|
|
196d3e9211 | ||
|
|
2dd9d544ee | ||
|
|
e347a780f8 | ||
|
|
466cca5cb4 | ||
|
|
24826a3ea4 | ||
|
|
fe9a8bde1d | ||
|
|
f381c68371 | ||
|
|
4ccd1cb96f | ||
|
|
711a5c5d6b | ||
|
|
eea3a1f8d8 | ||
|
|
fa274bd8cc | ||
|
|
f407723a8c | ||
|
|
52190ff26d | ||
|
|
5ae1632684 | ||
|
|
8c7f4000a9 | ||
|
|
bc88c0590d | ||
|
|
534c9629ea | ||
|
|
3d7408a607 | ||
|
|
d07c32db1d | ||
|
|
af40df2c9c | ||
|
|
ad95a15310 | ||
|
|
c653a174f4 | ||
|
|
21f337cece | ||
|
|
608fae740a | ||
|
|
9a7fb695f9 | ||
|
|
ce6b2139c2 | ||
|
|
dd4474a4cd | ||
|
|
a8d91ee19b | ||
|
|
428314d5bf | ||
|
|
dfbabddd98 | ||
|
|
8441063f0c | ||
|
|
9fdcf07439 | ||
|
|
7a8f9cb9a9 | ||
|
|
3af2787d03 | ||
|
|
fb24e77e42 | ||
|
|
4b8765885b | ||
|
|
35a003e35c | ||
|
|
eba37df4d7 | ||
|
|
1a36f831cc | ||
|
|
a9f7c9b07a | ||
|
|
94403bd634 | ||
|
|
e6b1e02e5f | ||
|
|
ecab3eb22a | ||
|
|
01579ac299 | ||
|
|
9d7d76b04d | ||
|
|
ce531c8d46 | ||
|
|
7bf13b88d2 | ||
|
|
3f8c2b7c01 | ||
|
|
8e476a7a82 | ||
|
|
0e776046b4 | ||
|
|
8f167c85a8 | ||
|
|
689db9eed1 | ||
|
|
d358d95486 | ||
|
|
cc66e96f61 | ||
|
|
e2d22088c1 | ||
|
|
7d22a5328f | ||
|
|
286ab630ea | ||
|
|
29a58daed4 | ||
|
|
e62caa3ddb | ||
|
|
9f2d36b5f5 | ||
|
|
23f8f70c83 | ||
|
|
bc8bc1159d | ||
|
|
43305a2f16 | ||
|
|
0ad50f4b6b | ||
|
|
1f998c2549 | ||
|
|
fa1064daac | ||
|
|
ee019ea767 | ||
|
|
7c2a084b3e | ||
|
|
00e6ef17d6 | ||
|
|
0a3aca7cbc | ||
|
|
6e411b160a | ||
|
|
631dc9a083 | ||
|
|
dfda71053c | ||
|
|
f1cc2ba3f7 | ||
|
|
7fbc7c1302 | ||
|
|
7244433e12 | ||
|
|
3f8cd7937a | ||
|
|
204218c4e7 | ||
|
|
78e7b946ac | ||
|
|
edca7205ef | ||
|
|
0668ed22a7 | ||
|
|
4bb6e8d08c | ||
|
|
1982033ac4 | ||
|
|
f743a79674 | ||
|
|
37680bd25b | ||
|
|
efc085e231 | ||
|
|
6e7b8eea1c | ||
|
|
9911313597 | ||
|
|
3c902aaef5 | ||
|
|
7f918374f4 | ||
|
|
1fe0aa0b96 | ||
|
|
75f8329e8e | ||
|
|
967dd206fa | ||
|
|
7a493d0e9c |
8
.env
Normal file
8
.env
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
SITE_URL=https://with.dwindi.com/
|
||||||
|
VITE_APP_ENV=production
|
||||||
|
VITE_GOOGLE_CLIENT_ID=650232746742-nup9nrp27001n0c6a3vqlc156g4tqfqa.apps.googleusercontent.com
|
||||||
|
VITE_PAKASIR_API_KEY=iP13osgh7lAzWWIPsj7TbW5M3iGEAQMo
|
||||||
|
VITE_PAKASIR_PROJECT_SLUG=withdwindi
|
||||||
|
VITE_SUPABASE_ANON_KEY=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc2NjAzNzEyMCwiZXhwIjo0OTIxNzEwNzIwLCJyb2xlIjoiYW5vbiJ9.Sa-eECy9dgBUQy3O4X5X-3tDPmF01J5zeT-Qtb-koYc
|
||||||
|
VITE_SUPABASE_EDGE_URL=https://lovable.backoffice.biz.id/functions/v1
|
||||||
|
VITE_SUPABASE_URL=https://lovable.backoffice.biz.id/
|
||||||
@@ -6,6 +6,10 @@ VITE_SUPABASE_EDGE_URL=your_supabase_url_here/functions/v1
|
|||||||
# Application Configuration
|
# Application Configuration
|
||||||
VITE_APP_NAME=Access Hub
|
VITE_APP_NAME=Access Hub
|
||||||
VITE_APP_ENV=production
|
VITE_APP_ENV=production
|
||||||
|
SITE_URL=https://with.dwindi.com/
|
||||||
|
|
||||||
|
# Google Integration
|
||||||
|
VITE_GOOGLE_CLIENT_ID=your_google_oauth_client_id_here
|
||||||
|
|
||||||
# Third-party Integrations
|
# Third-party Integrations
|
||||||
VITE_PAKASIR_API_KEY=your_pakasir_api_key_here
|
VITE_PAKASIR_API_KEY=your_pakasir_api_key_here
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.env
|
||||||
|
|||||||
444
CALENDAR_INTEGRATION.md
Normal file
444
CALENDAR_INTEGRATION.md
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
# Calendar Event Management - Complete Implementation
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
✅ **Google Calendar integration is now fully bidirectional:**
|
||||||
|
- ✅ Creates events when sessions are booked
|
||||||
|
- ✅ Stores Google Calendar event ID for tracking
|
||||||
|
- ✅ Deletes events when sessions are cancelled
|
||||||
|
- ✅ Members can add events to their own calendar with one click
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Fixed
|
||||||
|
|
||||||
|
### 1. ✅ `create-google-meet-event` Updated to Use `consulting_sessions`
|
||||||
|
**File**: `supabase/functions/create-google-meet-event/index.ts`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Removed old `consulting_slots` queries (lines 317-334, 355-373)
|
||||||
|
- Now updates `consulting_sessions` table instead
|
||||||
|
- Stores both `meet_link` AND `calendar_event_id` in the session
|
||||||
|
- Much simpler - just update one row per session
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```typescript
|
||||||
|
// Had to check order_id and update multiple slots
|
||||||
|
const { data: slotData } = await supabase
|
||||||
|
.from("consulting_slots")
|
||||||
|
.select("order_id")
|
||||||
|
.eq("id", body.slot_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (slotData?.order_id) {
|
||||||
|
await supabase
|
||||||
|
.from("consulting_slots")
|
||||||
|
.update({ meet_link: meetLink })
|
||||||
|
.eq("order_id", slotData.order_id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```typescript
|
||||||
|
// Just update the session directly
|
||||||
|
await supabase
|
||||||
|
.from("consulting_sessions")
|
||||||
|
.update({
|
||||||
|
meet_link: meetLink,
|
||||||
|
calendar_event_id: eventDataResult.id // ← NEW: Store event ID!
|
||||||
|
})
|
||||||
|
.eq("id", body.slot_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. ✅ Database Migration - Add `calendar_event_id` Column
|
||||||
|
**File**: `supabase/migrations/20241228_add_calendar_event_id.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Add column to store Google Calendar event ID
|
||||||
|
ALTER TABLE consulting_sessions
|
||||||
|
ADD COLUMN calendar_event_id TEXT;
|
||||||
|
|
||||||
|
-- Index for faster lookups
|
||||||
|
CREATE INDEX idx_consulting_sessions_calendar_event
|
||||||
|
ON consulting_sessions(calendar_event_id);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN consulting_sessions.calendar_event_id
|
||||||
|
IS 'Google Calendar event ID - used to delete events when sessions are cancelled/refunded';
|
||||||
|
```
|
||||||
|
|
||||||
|
**What this does:**
|
||||||
|
- Stores the Google Calendar event ID for each consulting session
|
||||||
|
- Allows us to delete the event later when session is cancelled/refunded
|
||||||
|
- No more orphaned calendar events!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. ✅ New Edge Function: `delete-calendar-event`
|
||||||
|
**File**: `supabase/functions/delete-calendar-event/index.ts`
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. Takes a `session_id` as input
|
||||||
|
2. Retrieves the session's `calendar_event_id`
|
||||||
|
3. Uses Google Calendar API to DELETE the event
|
||||||
|
4. Clears the `calendar_event_id` from the database
|
||||||
|
|
||||||
|
**API Usage:**
|
||||||
|
```typescript
|
||||||
|
await supabase.functions.invoke('delete-calendar-event', {
|
||||||
|
body: { session_id: 'session-uuid-here' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Google Calendar API Call:**
|
||||||
|
```http
|
||||||
|
DELETE https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events/{eventId}
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling:**
|
||||||
|
- If event already deleted (410 Gone): Logs and continues
|
||||||
|
- If calendar not configured: Returns success (graceful degradation)
|
||||||
|
- If deletion fails: Logs error but doesn't block the operation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. ✅ Admin Panel Integration - Auto-Delete on Cancel
|
||||||
|
**File**: `src/pages/admin/AdminConsulting.tsx`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Added `calendar_event_id` to `ConsultingSession` interface
|
||||||
|
- Updated `updateSessionStatus()` to call `delete-calendar-event` before cancelling
|
||||||
|
- Calendar events are automatically deleted when admin cancels a session
|
||||||
|
|
||||||
|
**Code:**
|
||||||
|
```typescript
|
||||||
|
const updateSessionStatus = async (sessionId: string, newStatus: string) => {
|
||||||
|
// If cancelling and session has a calendar event, delete it first
|
||||||
|
if (newStatus === 'cancelled') {
|
||||||
|
const session = sessions.find(s => s.id === sessionId);
|
||||||
|
if (session?.calendar_event_id) {
|
||||||
|
try {
|
||||||
|
await supabase.functions.invoke('delete-calendar-event', {
|
||||||
|
body: { session_id: sessionId }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Failed to delete calendar event:', err);
|
||||||
|
// Continue with status update even if calendar deletion fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session status
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('consulting_sessions')
|
||||||
|
.update({ status: newStatus })
|
||||||
|
.eq('id', sessionId);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
toast({ title: 'Berhasil', description: `Status diubah ke ${statusLabels[newStatus]?.label || newStatus}` });
|
||||||
|
fetchSessions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. ✅ "Add to Calendar" Button for Members
|
||||||
|
**Files**: `src/pages/member/OrderDetail.tsx`, `src/components/reviews/ConsultingHistory.tsx`
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Allows members to add consulting sessions to their own Google Calendar
|
||||||
|
- Uses Google Calendar's public URL format (no OAuth required)
|
||||||
|
- One-click addition with event details pre-filled
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Generate Google Calendar link
|
||||||
|
const generateCalendarLink = (session: ConsultingSession) => {
|
||||||
|
if (!session.meet_link) return null;
|
||||||
|
|
||||||
|
const startDate = new Date(`${session.session_date}T${session.start_time}`);
|
||||||
|
const endDate = new Date(`${session.session_date}T${session.end_time}`);
|
||||||
|
|
||||||
|
// Format dates for Google Calendar (YYYYMMDDTHHmmssZ)
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toISOString().replace(/-|:|\.\d\d\d/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'TEMPLATE',
|
||||||
|
text: `Konsultasi: ${session.topic_category || 'Sesi Konsultasi'}`,
|
||||||
|
dates: `${formatDate(startDate)}/${formatDate(endDate)}`,
|
||||||
|
details: `Link Meet: ${session.meet_link}`,
|
||||||
|
location: session.meet_link,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://www.google.com/calendar/render?${params.toString()}`;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Implementation:**
|
||||||
|
|
||||||
|
**OrderDetail.tsx** (after meet link):
|
||||||
|
```tsx
|
||||||
|
{consultingSlots[0]?.meet_link && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">Google Meet Link</p>
|
||||||
|
<a href={consultingSlots[0].meet_link} target="_blank">
|
||||||
|
{consultingSlots[0].meet_link.substring(0, 40)}...
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm" className="w-full border-2">
|
||||||
|
<a href={generateCalendarLink(consultingSlots[0]) || '#'} target="_blank">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Tambah ke Kalender
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ConsultingHistory.tsx** (upcoming sessions):
|
||||||
|
```tsx
|
||||||
|
{session.meet_link && (
|
||||||
|
<>
|
||||||
|
<Button asChild size="sm" variant="outline" className="border-2">
|
||||||
|
<a href={session.meet_link} target="_blank">Join</a>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="outline" className="border-2">
|
||||||
|
<a href={generateCalendarLink(session) || '#'} target="_blank" title="Tambah ke Kalender">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Google Calendar URL Format:**
|
||||||
|
|
||||||
|
```
|
||||||
|
https://www.google.com/calendar/render?action=TEMPLATE&text=Title&dates=StartDate/EndDate&details=Description&location=Location
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ No OAuth required for users
|
||||||
|
- ✅ Works with any calendar app that supports Google Calendar links
|
||||||
|
- ✅ Pre-fills all event details (title, time, description, location)
|
||||||
|
- ✅ Opens in user's default calendar app
|
||||||
|
- ✅ One-click addition
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Flow
|
||||||
|
|
||||||
|
### Booking Flow (Create)
|
||||||
|
```
|
||||||
|
User books consulting
|
||||||
|
↓
|
||||||
|
ConsultingBooking.tsx creates session in DB
|
||||||
|
↓
|
||||||
|
handle-order-paid edge function triggered
|
||||||
|
↓
|
||||||
|
Calls create-google-meet-event
|
||||||
|
↓
|
||||||
|
Creates event in Google Calendar
|
||||||
|
↓
|
||||||
|
Returns meet_link + event_id
|
||||||
|
↓
|
||||||
|
Updates consulting_sessions:
|
||||||
|
- meet_link = "https://meet.google.com/xxx-xxx"
|
||||||
|
- calendar_event_id = "event_id_from_google"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancellation Flow (Delete)
|
||||||
|
```
|
||||||
|
Admin cancels session in AdminConsulting.tsx
|
||||||
|
↓
|
||||||
|
Calls delete-calendar-event edge function
|
||||||
|
↓
|
||||||
|
Retrieves calendar_event_id from consulting_sessions
|
||||||
|
↓
|
||||||
|
Calls Google Calendar API to DELETE event
|
||||||
|
↓
|
||||||
|
Clears calendar_event_id from database
|
||||||
|
↓
|
||||||
|
Updates session status to 'cancelled'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Google Calendar API Response
|
||||||
|
|
||||||
|
When an event is created, Google returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "a1b2c3d4e5f6g7h8i9j0", // ← Calendar event ID
|
||||||
|
"status": "confirmed",
|
||||||
|
"htmlLink": "https://www.google.com/calendar/event?eid=a1b2c3d4...",
|
||||||
|
"created": "2024-12-28T10:00:00.000Z",
|
||||||
|
"updated": "2024-12-28T10:00:00.000Z",
|
||||||
|
"summary": "Konsultasi: Career Guidance - John Doe",
|
||||||
|
"description": "Client: john@example.com\n\nNotes: ...\n\nSlot ID: uuid-here",
|
||||||
|
"start": {
|
||||||
|
"dateTime": "2025-01-15T09:00:00+07:00",
|
||||||
|
"timeZone": "Asia/Jakarta"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"dateTime": "2025-01-15T12:00:00+07:00",
|
||||||
|
"timeZone": "Asia/Jakarta"
|
||||||
|
},
|
||||||
|
"conferenceData": {
|
||||||
|
"entryPoints": [
|
||||||
|
{
|
||||||
|
"entryPointType": "video",
|
||||||
|
"uri": "https://meet.google.com/abc-defg-hij", // ← Meet link
|
||||||
|
"label": "meet.google.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important fields:**
|
||||||
|
- `id` - Event ID (stored in `calendar_event_id`)
|
||||||
|
- `conferenceData.entryPoints[0].uri` - Meet link (stored in `meet_link`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### ✅ Test Event Creation
|
||||||
|
- [ ] Book a consulting session
|
||||||
|
- [ ] Verify Google Calendar event is created
|
||||||
|
- [ ] Verify `meet_link` is saved to `consulting_sessions`
|
||||||
|
- [ ] Verify `calendar_event_id` is saved to `consulting_sessions`
|
||||||
|
|
||||||
|
### ✅ Test Event Deletion
|
||||||
|
- [ ] Cancel a session in admin panel
|
||||||
|
- [ ] Verify Google Calendar event is deleted
|
||||||
|
- [ ] Verify `calendar_event_id` is cleared from database
|
||||||
|
- [ ] Verify session status is set to 'cancelled'
|
||||||
|
|
||||||
|
### ✅ Test Edge Cases
|
||||||
|
- [ ] Cancel session without calendar event (should not fail)
|
||||||
|
- [ ] Cancel session when Google Calendar not configured (should not fail)
|
||||||
|
- [ ] Delete already-deleted event (410 Gone - should handle gracefully)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SQL Migration Steps
|
||||||
|
|
||||||
|
Run this migration to add the `calendar_event_id` column:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to your Supabase database
|
||||||
|
psql -h db.xxx.supabase.co -U postgres -d postgres
|
||||||
|
|
||||||
|
# Or use Supabase Dashboard:
|
||||||
|
# SQL Editor → Paste and Run
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Add calendar_event_id column
|
||||||
|
ALTER TABLE consulting_sessions
|
||||||
|
ADD COLUMN calendar_event_id TEXT;
|
||||||
|
|
||||||
|
-- Create index
|
||||||
|
CREATE INDEX idx_consulting_sessions_calendar_event
|
||||||
|
ON consulting_sessions(calendar_event_id);
|
||||||
|
|
||||||
|
-- Verify
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
session_date,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
meet_link,
|
||||||
|
calendar_event_id
|
||||||
|
FROM consulting_sessions;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy Edge Functions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy the updated create-google-meet-event function
|
||||||
|
supabase functions deploy create-google-meet-event
|
||||||
|
|
||||||
|
# Deploy the new delete-calendar-event function
|
||||||
|
supabase functions deploy delete-calendar-event
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the Supabase Dashboard:
|
||||||
|
- Edge Functions → Select function → Deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Option 1: Auto-reschedule
|
||||||
|
If session date/time changes:
|
||||||
|
- Delete old event
|
||||||
|
- Create new event with updated time
|
||||||
|
- Update `calendar_event_id` in database
|
||||||
|
|
||||||
|
### Option 2: Batch Delete
|
||||||
|
If multiple sessions are cancelled (e.g., order refund):
|
||||||
|
- Get all `calendar_event_id`s for the order
|
||||||
|
- Delete all events in batch
|
||||||
|
- Clear all `calendar_event_id`s
|
||||||
|
|
||||||
|
### Option 3: Event Sync
|
||||||
|
Periodic sync to ensure database and calendar are in sync:
|
||||||
|
- Check all upcoming sessions
|
||||||
|
- Verify events exist in Google Calendar
|
||||||
|
- Recreate if missing (with warning)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Event not deleted when session cancelled
|
||||||
|
**Check:**
|
||||||
|
1. Does the session have `calendar_event_id`?
|
||||||
|
```sql
|
||||||
|
SELECT id, calendar_event_id FROM consulting_sessions WHERE id = 'session-uuid';
|
||||||
|
```
|
||||||
|
2. Are the OAuth credentials valid?
|
||||||
|
```sql
|
||||||
|
SELECT google_oauth_config FROM platform_settings;
|
||||||
|
```
|
||||||
|
3. Check the edge function logs:
|
||||||
|
```bash
|
||||||
|
supabase functions logs delete-calendar-event
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: "Token exchange failed"
|
||||||
|
**Solution:** Refresh OAuth credentials in settings
|
||||||
|
- Go to: Admin → Settings → Integrations
|
||||||
|
- Update `google_oauth_config` with new `refresh_token`
|
||||||
|
|
||||||
|
### Issue: Event already deleted (410 Gone)
|
||||||
|
**This is normal!** The function handles this gracefully and continues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. ✅ `supabase/functions/create-google-meet-event/index.ts` - Use consulting_sessions, store calendar_event_id
|
||||||
|
2. ✅ `supabase/migrations/20241228_add_calendar_event_id.sql` - Add calendar_event_id column
|
||||||
|
3. ✅ `supabase/functions/delete-calendar-event/index.ts` - NEW: Delete calendar events
|
||||||
|
4. ✅ `src/pages/admin/AdminConsulting.tsx` - Auto-delete on cancel, add calendar_event_id to interface
|
||||||
|
5. ✅ `src/pages/member/OrderDetail.tsx` - Add "Tambah ke Kalender" button
|
||||||
|
6. ✅ `src/components/reviews/ConsultingHistory.tsx` - Add "Tambah ke Kalender" button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**All set!** 🎉
|
||||||
|
Your consulting sessions now have full calendar lifecycle management.
|
||||||
142
CURRENT-STATUS.md
Normal file
142
CURRENT-STATUS.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Current Status & Remaining Work
|
||||||
|
|
||||||
|
## ✅ Completed
|
||||||
|
|
||||||
|
### 1. Code Duplication Fixed
|
||||||
|
- **Created**: `supabase/shared/email-template-renderer.ts`
|
||||||
|
- **Updated**: `send-auth-otp` imports from shared file (eliminates 260 lines of duplicate code)
|
||||||
|
- **Benefit**: Single source of truth for email master template
|
||||||
|
|
||||||
|
### 2. Unconfirmed Email Login Detection
|
||||||
|
- **Added**: `isResendOTP` state to track existing users
|
||||||
|
- **Updated**: Login error handler detects "Email not confirmed" error
|
||||||
|
- **Result**: Shows helpful message when user tries to login with unconfirmed email
|
||||||
|
|
||||||
|
## ⚠️ Remaining Work
|
||||||
|
|
||||||
|
### Issue: Unconfirmed Email User Flow
|
||||||
|
|
||||||
|
**Problem**: User registers → Closes tab → Tries to login → Gets error "Email not confirmed" → **What next?**
|
||||||
|
|
||||||
|
**Current Behavior**:
|
||||||
|
```
|
||||||
|
User tries to login → Error: "Email not confirmed"
|
||||||
|
→ Shows toast message
|
||||||
|
→ Sets isResendOTP = true
|
||||||
|
→ Shows OTP form
|
||||||
|
```
|
||||||
|
|
||||||
|
**Missing Pieces**:
|
||||||
|
1. ✅ Detection of unconfirmed email
|
||||||
|
2. ❌ **Need user_id to send OTP** (we only have email at this point)
|
||||||
|
3. ❌ **Need button to "Request OTP"** for existing users
|
||||||
|
4. ❌ **Need to fetch user_id from database** using email
|
||||||
|
|
||||||
|
### Proposed Solution
|
||||||
|
|
||||||
|
Add a new edge function or database query to get user_id by email:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In useAuth hook
|
||||||
|
getUserIdByEmail: (email: string) => Promise<string | null>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the auth page flow:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (error.message.includes('Email not confirmed')) {
|
||||||
|
// Fetch user_id from database
|
||||||
|
const userId = await getUserIdByEmail(email);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
setPendingUserId(userId);
|
||||||
|
setIsResendOTP(true);
|
||||||
|
setShowOTP(true);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Email Belum Dikonfirmasi',
|
||||||
|
description: 'Silakan verifikasi email Anda. Kirim ulang kode OTP?',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-send OTP
|
||||||
|
const result = await sendAuthOTP(userId, email);
|
||||||
|
if (result.success) {
|
||||||
|
setResendCountdown(60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Fix for Now (Manual)
|
||||||
|
|
||||||
|
For immediate testing, you can:
|
||||||
|
|
||||||
|
1. **Get user_id manually from database**:
|
||||||
|
```sql
|
||||||
|
SELECT id, email, email_confirmed_at
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'user@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test OTP with curl**:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://lovable.backoffice.biz.id/functions/v1/send-auth-otp \
|
||||||
|
-H "Authorization: Bearer YOUR_SERVICE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user_id":"USER_ID_FROM_STEP_1","email":"user@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **User receives OTP** and can verify
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Registration Flow ✅
|
||||||
|
- [x] Register new user
|
||||||
|
- [x] Receive OTP email with master template
|
||||||
|
- [x] Enter OTP code
|
||||||
|
- [x] Email confirmed
|
||||||
|
- [x] Can login
|
||||||
|
|
||||||
|
### Unconfirmed Email Login ⚠️
|
||||||
|
- [x] Login fails with "Email not confirmed" error
|
||||||
|
- [ ] User can request new OTP
|
||||||
|
- [ ] User receives new OTP
|
||||||
|
- [ ] User can verify and login
|
||||||
|
|
||||||
|
## Files Changed in This Session
|
||||||
|
|
||||||
|
1. **supabase/shared/email-template-renderer.ts** (NEW)
|
||||||
|
- Extracted master template from src/lib
|
||||||
|
- Can be imported by edge functions
|
||||||
|
|
||||||
|
2. **supabase/functions/send-auth-otp/index.ts**
|
||||||
|
- Removed 260 lines of duplicate EmailTemplateRenderer class
|
||||||
|
- Now imports from `../shared/email-template-renderer.ts`
|
||||||
|
|
||||||
|
3. **src/pages/auth.tsx**
|
||||||
|
- Added `isResendOTP` state
|
||||||
|
- Updated login error handler
|
||||||
|
- Shows helpful message for unconfirmed email
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Option 1: Quick Fix (5 minutes)
|
||||||
|
Add a "Request OTP" button that appears when login fails. User clicks button → enters email → we fetch user_id from database → send OTP.
|
||||||
|
|
||||||
|
### Option 2: Complete Solution (15 minutes)
|
||||||
|
1. Create `get-user-by-email` edge function
|
||||||
|
2. Add `getUserIdByEmail` to useAuth hook
|
||||||
|
3. Auto-send OTP on login failure
|
||||||
|
4. Show "OTP sent" message
|
||||||
|
5. User enters OTP → verified → can login
|
||||||
|
|
||||||
|
## For Now
|
||||||
|
|
||||||
|
**Users who register but don't verify email**:
|
||||||
|
- Can't login (shows error)
|
||||||
|
- Need to register again with new email OR
|
||||||
|
- Manually verify via database query
|
||||||
|
|
||||||
|
**This is acceptable for testing** but should be fixed before production use.
|
||||||
|
|
||||||
|
Would you like me to implement the complete solution now?
|
||||||
201
DEPLOY-CHECKLIST.md
Normal file
201
DEPLOY-CHECKLIST.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# 🚀 Quick Deploy Checklist
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
- ✅ Auth page registration works in production
|
||||||
|
- ✅ Email is being sent
|
||||||
|
- ❌ Email missing master template wrapper (needs deployment)
|
||||||
|
|
||||||
|
## What You Need to Do
|
||||||
|
|
||||||
|
### Step 1: Deploy Updated Edge Function (CRITICAL)
|
||||||
|
|
||||||
|
The email is sending but without the master template. You need to deploy the updated `send-auth-otp` function.
|
||||||
|
|
||||||
|
**Option A: If you have Supabase CLI access**
|
||||||
|
```bash
|
||||||
|
ssh root@lovable.backoffice.biz.id
|
||||||
|
cd /path/to/supabase
|
||||||
|
supabase functions deploy send-auth-otp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Manual deployment**
|
||||||
|
```bash
|
||||||
|
ssh root@lovable.backoffice.biz.id
|
||||||
|
|
||||||
|
# Find the edge functions directory
|
||||||
|
cd /path/to/supabase/functions
|
||||||
|
|
||||||
|
# Backup current version
|
||||||
|
cp send-auth-otp/index.ts send-auth-otp/index.ts.backup
|
||||||
|
|
||||||
|
# Copy new version from your local machine
|
||||||
|
# (On your local machine)
|
||||||
|
scp supabase/functions/send-auth-otp/index.ts root@lovable.backoffice.biz.id:/path/to/supabase/functions/send-auth-otp/
|
||||||
|
|
||||||
|
# Restart edge function container
|
||||||
|
docker restart $(docker ps -q --filter 'name=supabase_edge_runtime')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option C: Git pull + restart**
|
||||||
|
```bash
|
||||||
|
ssh root@lovable.backoffice.biz.id
|
||||||
|
cd /path/to/project
|
||||||
|
git pull origin main
|
||||||
|
cp supabase/functions/send-auth-otp/index.ts /path/to/supabase/functions/send-auth-otp/
|
||||||
|
docker restart $(docker ps -q --filter 'name=supabase_edge_runtime')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Verify Deployment
|
||||||
|
|
||||||
|
After deployment, test the registration:
|
||||||
|
|
||||||
|
1. Go to https://with.dwindi.com/auth
|
||||||
|
2. Register with a NEW email address
|
||||||
|
3. Check your email inbox
|
||||||
|
|
||||||
|
**Expected Result:**
|
||||||
|
- ✅ Email has black header with "ACCESS HUB" logo
|
||||||
|
- ✅ Email has proper brutalist styling
|
||||||
|
- ✅ OTP code is large and centered
|
||||||
|
- ✅ Email has footer with unsubscribe links
|
||||||
|
|
||||||
|
### Step 3: Confirm Checkout Flow
|
||||||
|
|
||||||
|
The checkout page already redirects to auth page for registration, so **no changes needed**.
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
1. Add product to cart
|
||||||
|
2. Go to checkout
|
||||||
|
3. If not logged in, redirects to `/auth`
|
||||||
|
4. Register new account
|
||||||
|
5. Receive OTP email with proper styling ✅
|
||||||
|
6. Verify email
|
||||||
|
7. Login
|
||||||
|
8. Complete checkout
|
||||||
|
|
||||||
|
## What Was Fixed
|
||||||
|
|
||||||
|
### Before
|
||||||
|
```html
|
||||||
|
<!-- Email was just the content without wrapper -->
|
||||||
|
<h1>🔐 Verifikasi Email</h1>
|
||||||
|
<p>Halo {nama},</p>
|
||||||
|
<div class="otp-box">{otp_code}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (With Master Template)
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>...</head>
|
||||||
|
<body>
|
||||||
|
<table>
|
||||||
|
<!-- Header with ACCESS HUB branding -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: #000; padding: 25px 40px;">
|
||||||
|
ACCESS HUB | NOTIF #123456
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main content with OTP -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="email-content">
|
||||||
|
<h1>🔐 Verifikasi Email</h1>
|
||||||
|
<p>Halo {nama},</p>
|
||||||
|
<div class="otp-box">{otp_code}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
ACCESS HUB
|
||||||
|
Email ini dikirim otomatis
|
||||||
|
Unsubscribe
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
Only ONE file needs to be deployed:
|
||||||
|
- `supabase/functions/send-auth-otp/index.ts`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Added `EmailTemplateRenderer` class (260 lines)
|
||||||
|
- Updated email body processing to use master template
|
||||||
|
- No database changes needed
|
||||||
|
- No frontend changes needed
|
||||||
|
|
||||||
|
## Testing After Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Register new user
|
||||||
|
# Go to /auth and fill registration form
|
||||||
|
|
||||||
|
# 2. Check OTP was created
|
||||||
|
# In Supabase SQL Editor:
|
||||||
|
SELECT * FROM auth_otps ORDER BY created_at DESC LIMIT 1;
|
||||||
|
|
||||||
|
# 3. Check email received
|
||||||
|
# Should have:
|
||||||
|
# - Black header with "ACCESS HUB"
|
||||||
|
# - Notification ID (NOTIF #XXXXXX)
|
||||||
|
# - Large OTP code in dashed box
|
||||||
|
# - Gray footer with unsubscribe links
|
||||||
|
|
||||||
|
# 4. Verify OTP works
|
||||||
|
# Enter code from email
|
||||||
|
# Should see: "Verifikasi Berhasil"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
✅ Email has professional brutalist design
|
||||||
|
✅ ACCESS HUB branding in header
|
||||||
|
✅ Notification ID visible
|
||||||
|
✅ OTP code prominently displayed
|
||||||
|
✅ Footer with unsubscribe links
|
||||||
|
✅ Responsive on mobile
|
||||||
|
✅ Works in all email clients
|
||||||
|
|
||||||
|
## Rollback Plan (If Something Breaks)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If deployment fails, restore backup
|
||||||
|
ssh root@lovable.backoffice.biz.id
|
||||||
|
cd /path/to/supabase/functions/send-auth-otp
|
||||||
|
cp index.ts.backup index.ts
|
||||||
|
docker restart $(docker ps -q --filter 'name=supabase_edge_runtime')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
Check logs:
|
||||||
|
```bash
|
||||||
|
docker logs $(docker ps -q --filter 'name=supabase_edge_runtime') | tail -100
|
||||||
|
```
|
||||||
|
|
||||||
|
Test edge function directly:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://lovable.backoffice.biz.id/functions/v1/send-auth-otp \
|
||||||
|
-H "Authorization: Bearer YOUR_SERVICE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user_id":"TEST_ID","email":"test@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Status:** Ready to deploy
|
||||||
|
**Files to deploy:** 1 (send-auth-otp edge function)
|
||||||
|
**Risk:** Low (email improvement only)
|
||||||
|
**Time to deploy:** ~5 minutes
|
||||||
|
|
||||||
|
After deployment, test registration with a new email to confirm the email has proper styling!
|
||||||
156
DEPLOY-OTP-FIX.md
Normal file
156
DEPLOY-OTP-FIX.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Deploy OTP Email Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
The `send-auth-otp` edge function was trying to insert into `notification_logs` table which doesn't exist, causing the function to crash AFTER sending the email. This meant:
|
||||||
|
- ✅ Email was sent by Mailketing API
|
||||||
|
- ❌ Function crashed before returning success
|
||||||
|
- ❌ Frontend might have shown error
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Removed all references to `notification_logs` table from the edge function.
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### 1. SSH into your server
|
||||||
|
```bash
|
||||||
|
ssh root@lovable.backoffice.biz.id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Navigate to the project directory
|
||||||
|
```bash
|
||||||
|
cd /path/to/your/project
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Pull the latest changes
|
||||||
|
```bash
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Deploy the edge function
|
||||||
|
```bash
|
||||||
|
# Option A: If using Supabase CLI
|
||||||
|
supabase functions deploy send-auth-otp
|
||||||
|
|
||||||
|
# Option B: If manually copying files
|
||||||
|
cp supabase/functions/send-auth-otp/index.ts /path/to/supabase/functions/send-auth-otp/index.ts
|
||||||
|
|
||||||
|
# Then restart the edge function container
|
||||||
|
docker-compose restart edge-functions
|
||||||
|
# or
|
||||||
|
docker restart $(docker ps -q --filter 'name=supabase_edge_runtime')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verify deployment
|
||||||
|
```bash
|
||||||
|
# Check if function is loaded
|
||||||
|
supabase functions list
|
||||||
|
|
||||||
|
# Should show:
|
||||||
|
# send-auth-otp ...
|
||||||
|
# verify-auth-otp ...
|
||||||
|
# send-email-v2 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Test the fix
|
||||||
|
```bash
|
||||||
|
# Test with curl
|
||||||
|
curl -X POST https://lovable.backoffice.biz.id/functions/v1/send-auth-otp \
|
||||||
|
-H "Authorization: Bearer YOUR_SERVICE_ROLE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user_id":"TEST_USER_ID","email":"test@example.com"}'
|
||||||
|
|
||||||
|
# Expected response:
|
||||||
|
# {"success":true,"message":"OTP sent successfully"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Test full registration flow
|
||||||
|
1. Open browser to https://with.dwindi.com/auth
|
||||||
|
2. Register with new email
|
||||||
|
3. Check email inbox
|
||||||
|
4. Should receive OTP code
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
### File: `supabase/functions/send-auth-otp/index.ts`
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```typescript
|
||||||
|
// Log notification
|
||||||
|
await supabase
|
||||||
|
.from('notification_logs')
|
||||||
|
.insert({
|
||||||
|
user_id,
|
||||||
|
email: email,
|
||||||
|
notification_type: 'auth_email_verification',
|
||||||
|
status: 'sent',
|
||||||
|
provider: 'mailketing',
|
||||||
|
error_message: null,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```typescript
|
||||||
|
// Note: notification_logs table doesn't exist, skipping logging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### If email still not received:
|
||||||
|
|
||||||
|
1. **Check edge function logs:**
|
||||||
|
```bash
|
||||||
|
docker logs $(docker ps -q --filter 'name=supabase_edge_runtime') | tail -50
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check if OTP was created:**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM auth_otps ORDER BY created_at DESC LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check notification settings:**
|
||||||
|
```sql
|
||||||
|
SELECT platform_name, from_name, from_email, api_token
|
||||||
|
FROM notification_settings
|
||||||
|
LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Verify email template:**
|
||||||
|
```sql
|
||||||
|
SELECT key, name, is_active, LENGTH(email_body_html) as html_length
|
||||||
|
FROM notification_templates
|
||||||
|
WHERE key = 'auth_email_verification';
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Test email sending directly:**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://lovable.backoffice.biz.id/functions/v1/send-email-v2 \
|
||||||
|
-H "Authorization: Bearer YOUR_SERVICE_ROLE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"to": "your@email.com",
|
||||||
|
"api_token": "YOUR_MAILKETING_TOKEN",
|
||||||
|
"from_name": "Test",
|
||||||
|
"from_email": "test@with.dwindi.com",
|
||||||
|
"subject": "Test Email",
|
||||||
|
"html_body": "<h1>Test</h1>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
✅ Edge function returns `{"success":true}`
|
||||||
|
✅ No crashes in edge function logs
|
||||||
|
✅ OTP created in database
|
||||||
|
✅ Email received with OTP code
|
||||||
|
✅ OTP verification works
|
||||||
|
✅ User can login after verification
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After successful deployment:
|
||||||
|
1. Test registration with multiple email addresses
|
||||||
|
2. Test OTP verification flow
|
||||||
|
3. Test login after verification
|
||||||
|
4. Test "resend OTP" functionality
|
||||||
|
5. Test expired OTP (wait 15 minutes)
|
||||||
|
6. Test wrong OTP code
|
||||||
359
DEPLOYMENT_GUIDE.md
Normal file
359
DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
# Deployment Guide - Post-Implementation Refinements
|
||||||
|
|
||||||
|
This guide covers the necessary steps to deploy the new features implemented in the post-implementation refinements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Edge Function Deployment
|
||||||
|
|
||||||
|
### Deploy Pakasir Webhook (if not already deployed)
|
||||||
|
|
||||||
|
The webhook function receives payment notifications from Pakasir and updates order statuses.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to supabase functions directory
|
||||||
|
cd supabase/functions
|
||||||
|
|
||||||
|
# Deploy the webhook function
|
||||||
|
supabase functions deploy pakasir-webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment Variables Required:**
|
||||||
|
- `SUPABASE_URL` - Automatically set by Supabase
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY` - Automatically set by Supabase
|
||||||
|
- `PAKASIR_WEBHOOK_SECRET` - Optional (Pakasir doesn't use secrets, but you can set one for future compatibility)
|
||||||
|
|
||||||
|
### Create Expired Orders Checker (Optional - Recommended)
|
||||||
|
|
||||||
|
For production, you may want to create a cron job or scheduled edge function to automatically cancel expired consulting orders. However, the current implementation handles this on the frontend when users view their expired orders.
|
||||||
|
|
||||||
|
If you want to implement automatic expiry checking:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create new edge function
|
||||||
|
mkdir -p supabase/functions/check-expired-orders
|
||||||
|
|
||||||
|
# Copy the implementation (not included in this PR)
|
||||||
|
# Deploy
|
||||||
|
supabase functions deploy check-expired-orders
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Database Schema Changes
|
||||||
|
|
||||||
|
### Add QR Regeneration Tracking (Optional)
|
||||||
|
|
||||||
|
The current implementation doesn't require this, but if you want to track how many times a QR has been regenerated:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Add column to track QR regeneration count
|
||||||
|
ALTER TABLE orders
|
||||||
|
ADD COLUMN qr_regeneration_count INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Add index for efficient expiry checks
|
||||||
|
CREATE INDEX idx_orders_qr_expires_at ON orders(qr_expires_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Storage Bucket Exists
|
||||||
|
|
||||||
|
The logo/favicon upload feature uses the existing `content` bucket. Verify it exists:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check if bucket exists
|
||||||
|
SELECT * FROM storage.buckets WHERE name = 'content';
|
||||||
|
```
|
||||||
|
|
||||||
|
If it doesn't exist, create it:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Create storage bucket for brand assets
|
||||||
|
INSERT INTO storage.buckets (id, name, public)
|
||||||
|
VALUES ('content', 'content', true);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Storage Folder Structure:**
|
||||||
|
```
|
||||||
|
content/
|
||||||
|
├── brand-assets/
|
||||||
|
│ ├── logo/
|
||||||
|
│ │ └── logo-current.{ext}
|
||||||
|
│ └── favicon/
|
||||||
|
│ └── favicon-current.{ext}
|
||||||
|
└── editor-images/
|
||||||
|
└── {existing files}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Environment Variables
|
||||||
|
|
||||||
|
### Supabase Dashboard Settings
|
||||||
|
|
||||||
|
Navigate to your Supabase project → Settings → Edge Functions → Environment Variables:
|
||||||
|
|
||||||
|
**Required Variables:**
|
||||||
|
```
|
||||||
|
SUPABASE_URL={your-supabase-url}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY={your-service-role-key}
|
||||||
|
PAKASIR_WEBHOOK_SECRET={optional-leave-empty}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pakasir Configuration
|
||||||
|
|
||||||
|
**1. Configure Webhook URL in Pakasir Dashboard:**
|
||||||
|
|
||||||
|
1. Login to your Pakasir account
|
||||||
|
2. Go to your Project detail page
|
||||||
|
3. Edit Project → Find "Webhook URL" field
|
||||||
|
4. Enter: `https://lovable.backoffice.biz.id/functions/v1/pakasir-webhook`
|
||||||
|
5. Save changes
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- Pakasir does NOT use webhook secrets
|
||||||
|
- They simply send POST notifications to the URL
|
||||||
|
- The webhook verifies `order_id` and `amount` for security
|
||||||
|
- Webhook payload format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"amount": 22000,
|
||||||
|
"order_id": "240910HDE7C9",
|
||||||
|
"project": "depodomain",
|
||||||
|
"status": "completed",
|
||||||
|
"payment_method": "qris",
|
||||||
|
"completed_at": "2024-09-10T08:07:02.819+07:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Frontend Deployment
|
||||||
|
|
||||||
|
The frontend changes are already pushed to git. Your CI/CD pipeline should automatically deploy them.
|
||||||
|
|
||||||
|
**Manual deployment (if needed):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest changes
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Build and deploy (depending on your hosting)
|
||||||
|
npm run build
|
||||||
|
# Then deploy dist/ folder to your hosting provider
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Post-Deployment Checklist
|
||||||
|
|
||||||
|
### Testing Steps
|
||||||
|
|
||||||
|
**1. Test Logo/Favicon Upload:**
|
||||||
|
- [ ] Go to Admin → Settings → Branding tab
|
||||||
|
- [ ] Upload a logo file (PNG, SVG, JPG, or WebP, max 2MB)
|
||||||
|
- [ ] Verify logo preview appears
|
||||||
|
- [ ] Upload a different logo (should delete the old one)
|
||||||
|
- [ ] Check Supabase Storage: `content/brand-assets/logo/` should only have one `logo-current.{ext}` file
|
||||||
|
- [ ] Repeat for favicon upload
|
||||||
|
|
||||||
|
**2. Test Dynamic Badge Colors:**
|
||||||
|
- [ ] Go to Admin → Settings → Branding tab
|
||||||
|
- [ ] Change "Warna Aksen / Tombol" to a different color (e.g., #FF5733)
|
||||||
|
- [ ] Save settings
|
||||||
|
- [ ] View any order detail page
|
||||||
|
- [ ] Verify "Lunas" badge shows the new accent color
|
||||||
|
|
||||||
|
**3. Test Page Title:**
|
||||||
|
- [ ] Go to Admin → Settings → Branding tab
|
||||||
|
- [ ] Change "Nama Platform" to a custom name
|
||||||
|
- [ ] Save settings
|
||||||
|
- [ ] Refresh browser
|
||||||
|
- [ ] Verify browser tab shows custom name
|
||||||
|
|
||||||
|
**4. Test Status Badge Wording:**
|
||||||
|
- [ ] View any order with "pending" status
|
||||||
|
- [ ] Verify badge shows "Pending" (not "Menunggu Pembayaran")
|
||||||
|
|
||||||
|
**5. Test Expired QR Handling - Product Order:**
|
||||||
|
- [ ] Create a test product order with QRIS payment
|
||||||
|
- [ ] Wait for QR to expire (or manually update `qr_expires_at` in database to past time)
|
||||||
|
- [ ] View the order detail page
|
||||||
|
- [ ] Verify "Regenerate QR" button appears (not "Buat Booking Baru")
|
||||||
|
- [ ] Click "Regenerate QR"
|
||||||
|
- [ ] Verify new QR code appears
|
||||||
|
|
||||||
|
**6. Test Expired QR Handling - Consulting Order:**
|
||||||
|
- [ ] Create a test consulting order with QRIS payment
|
||||||
|
- [ ] Wait for QR to expire
|
||||||
|
- [ ] View the order detail page
|
||||||
|
- [ ] Verify "Buat Booking Baru" button appears (not "Regenerate QR")
|
||||||
|
- [ ] Verify alert message says "Waktu pembayaran telah habis. Slot konsultasi telah dilepaskan."
|
||||||
|
|
||||||
|
**7. Test Webhook:**
|
||||||
|
- [ ] Create a test order via Pakasir
|
||||||
|
- [ ] Complete payment in Pakasir dashboard
|
||||||
|
- [ ] Wait a few seconds for webhook to fire
|
||||||
|
- [ ] Check order status in database: `payment_status` should be "paid"
|
||||||
|
- [ ] Verify `qr_string` and `qr_expires_at` are cleared (null)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
### Logo Upload Fails
|
||||||
|
|
||||||
|
**Issue:** Upload fails with error
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Verify `content` bucket exists and is public
|
||||||
|
- Check RLS (Row Level Security) policies on storage.objects
|
||||||
|
- User should have INSERT and DELETE permissions on `brand-assets/*` path
|
||||||
|
|
||||||
|
**Required RLS Policy:**
|
||||||
|
```sql
|
||||||
|
-- Allow authenticated users to upload brand assets
|
||||||
|
CREATE POLICY "Authenticated users can upload brand assets"
|
||||||
|
ON storage.objects FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (bucket_id = 'content' AND name LIKE 'brand-assets/%');
|
||||||
|
|
||||||
|
-- Allow authenticated users to delete brand assets
|
||||||
|
CREATE POLICY "Authenticated users can delete brand assets"
|
||||||
|
ON storage.objects FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (bucket_id = 'content' AND name LIKE 'brand-assets/%');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge Colors Not Updating
|
||||||
|
|
||||||
|
**Issue:** Badge colors still showing old color
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Hard refresh browser (Ctrl+Shift+R or Cmd+Shift+R)
|
||||||
|
- Check browser console for CSS variable errors
|
||||||
|
- Verify `brand_accent_color` is saved in `platform_settings` table
|
||||||
|
- Check `useBranding.tsx` is setting `--brand-accent` CSS variable
|
||||||
|
|
||||||
|
### Page Title Not Updating
|
||||||
|
|
||||||
|
**Issue:** Browser tab still shows old title
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Hard refresh browser
|
||||||
|
- Check `useBranding.tsx` is updating `document.title`
|
||||||
|
- Verify `brand_name` is saved in `platform_settings` table
|
||||||
|
|
||||||
|
### Webhook Not Receiving Payments
|
||||||
|
|
||||||
|
**Issue:** Orders stay in "pending" status after payment
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Verify webhook URL is correctly set in Pakasir dashboard
|
||||||
|
- Check Supabase logs: Edge Functions → pakasir-webhook → Logs
|
||||||
|
- Verify webhook is deployed: `supabase functions list`
|
||||||
|
- Check `orders` table has proper `payment_reference` matching Pakasir `order_id`
|
||||||
|
|
||||||
|
### QR Regeneration Fails
|
||||||
|
|
||||||
|
**Issue:** "Regenerate QR" button doesn't work
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Verify `create-payment` edge function is deployed
|
||||||
|
- Check browser console for error messages
|
||||||
|
- Verify order is a product order (not consulting)
|
||||||
|
- Check order status is still "pending"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Feature Rollback
|
||||||
|
|
||||||
|
If you need to rollback any feature:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Revert to previous commit
|
||||||
|
git revert HEAD
|
||||||
|
|
||||||
|
# Or reset to specific commit
|
||||||
|
git reset --hard <commit-hash>
|
||||||
|
git push origin main --force
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Performance Considerations
|
||||||
|
|
||||||
|
### Storage Cleanup
|
||||||
|
|
||||||
|
The logo/favicon upload auto-deletes old files, but you may want to periodically clean up:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check for orphaned files in storage
|
||||||
|
SELECT name, created_at
|
||||||
|
FROM storage.objects
|
||||||
|
WHERE bucket_id = 'content'
|
||||||
|
AND name LIKE 'brand-assets/%'
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Indexes
|
||||||
|
|
||||||
|
The implementation uses existing indexes. No new indexes are required unless you added the optional `qr_regeneration_count` tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Security Notes
|
||||||
|
|
||||||
|
### Webhook Security
|
||||||
|
|
||||||
|
- The webhook verifies `order_id` exists in your database
|
||||||
|
- **TODO:** Add amount verification to prevent fraudulent payments
|
||||||
|
- Consider adding IP whitelist for Pakasir webhooks (if they provide static IPs)
|
||||||
|
|
||||||
|
### File Upload Security
|
||||||
|
|
||||||
|
- File types are restricted to images only (PNG, SVG, JPG, WebP, ICO)
|
||||||
|
- File size limits: Logo (2MB), Favicon (1MB)
|
||||||
|
- Files are stored in Supabase Storage with RLS policies
|
||||||
|
- Always sanitize file names before storage (already implemented)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Next Steps (Optional Improvements)
|
||||||
|
|
||||||
|
Not included in this PR, but consider for future:
|
||||||
|
|
||||||
|
1. **Add amount verification in webhook:**
|
||||||
|
```typescript
|
||||||
|
// In pakasir-webhook/index.ts
|
||||||
|
if (payload.amount !== order.total_amount) {
|
||||||
|
return new Response(JSON.stringify({ error: "Amount mismatch" }), { status: 400 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Implement scheduled expiry checker:**
|
||||||
|
- Create cron job to automatically cancel expired consulting orders
|
||||||
|
- Release slots back to available pool
|
||||||
|
- Send notification emails to users
|
||||||
|
|
||||||
|
3. **Add email notifications:**
|
||||||
|
- QR code expiry warning (15 min before)
|
||||||
|
- Payment confirmation email
|
||||||
|
- Consultation booking reminder
|
||||||
|
|
||||||
|
4. **Add analytics:**
|
||||||
|
- Track QR regeneration rate
|
||||||
|
- Monitor expired vs paid order ratio
|
||||||
|
- Identify problematic payment flows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This deployment requires:
|
||||||
|
- ✅ Edge function deployment (1 function: `pakasir-webhook`)
|
||||||
|
- ✅ Verify storage bucket exists (`content`)
|
||||||
|
- ✅ Configure Pakasir webhook URL
|
||||||
|
- ✅ No database schema changes required (optional improvements only)
|
||||||
|
- ✅ Frontend automatically deploys via CI/CD
|
||||||
|
|
||||||
|
All features are backward compatible and safe to deploy to production.
|
||||||
44
Dockerfile
44
Dockerfile
@@ -7,41 +7,41 @@ WORKDIR /app
|
|||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies (including dev dependencies for build)
|
# Install dependencies with clean install
|
||||||
RUN npm ci
|
RUN npm ci --prefer-offline --no-audit --force
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application
|
# Clean any previous build artifacts and node_modules cache, then build
|
||||||
RUN npm run build
|
RUN rm -rf dist node_modules/.cache && npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage - Use a simple server that works with Coolify
|
||||||
FROM nginx:alpine AS production
|
FROM node:18-alpine AS production
|
||||||
|
|
||||||
# Copy custom nginx configuration
|
# Install curl and serve package
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
RUN apk add --no-cache curl && npm install -g serve
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy built assets from builder stage
|
# Copy built assets from builder stage
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Create non-root user (optional but recommended for security)
|
# Create non-root user
|
||||||
RUN addgroup -g 1001 -S nodejs
|
RUN addgroup -g 1001 -S nodejs
|
||||||
RUN adduser -S nextjs -u 1001
|
RUN adduser -S nextjs -u 1001
|
||||||
|
|
||||||
# Change ownership of the nginx directory
|
# Change ownership
|
||||||
RUN chown -R nextjs:nodejs /usr/share/nginx/html
|
RUN chown -R nextjs:nodejs /app
|
||||||
RUN chown -R nextjs:nodejs /var/cache/nginx
|
|
||||||
RUN chown -R nextjs:nodejs /var/log/nginx
|
|
||||||
RUN chown -R nextjs:nodejs /etc/nginx/conf.d
|
|
||||||
RUN touch /var/run/nginx.pid
|
|
||||||
RUN chown -R nextjs:nodejs /var/run/nginx.pid
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80 (to match Caddy configuration)
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Start nginx
|
# Add healthcheck
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:80/ || exit 1
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
CMD ["serve", "-s", "dist", "-l", "80"]
|
||||||
358
EMAIL-TEMPLATE-SYSTEM.md
Normal file
358
EMAIL-TEMPLATE-SYSTEM.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# Unified Email Template System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
All emails now use a **single master template** for consistent branding and design. The master template wraps content-only HTML from database templates.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Database Template (content only)
|
||||||
|
↓
|
||||||
|
Process Shortcodes ({nama}, {platform_name}, etc.)
|
||||||
|
↓
|
||||||
|
EmailTemplateRenderer.render() - wraps with master template
|
||||||
|
↓
|
||||||
|
Complete HTML Email sent via provider
|
||||||
|
```
|
||||||
|
|
||||||
|
## Master Template
|
||||||
|
|
||||||
|
**Location:** `supabase/shared/email-template-renderer.ts`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Brutalist design (black borders, hard shadows)
|
||||||
|
- Responsive layout (600px max width)
|
||||||
|
- `.tiptap-content` wrapper for auto-styling
|
||||||
|
- Header with brand name + notification ID
|
||||||
|
- Footer with unsubscribe links
|
||||||
|
- All CSS included (no external dependencies)
|
||||||
|
|
||||||
|
**CSS Classes for Content:**
|
||||||
|
- `.tiptap-content h1, h2, h3` - Headings
|
||||||
|
- `.tiptap-content p` - Paragraphs
|
||||||
|
- `.tiptap-content a` - Links (underlined, bold)
|
||||||
|
- `.tiptap-content ul, ol` - Lists
|
||||||
|
- `.tiptap-content table` - Tables with brutalist borders
|
||||||
|
- `.btn` - Buttons with hard shadow
|
||||||
|
- `.otp-box` - OTP codes with dashed border
|
||||||
|
- `.alert-success, .alert-danger, .alert-info` - Colored alert boxes
|
||||||
|
|
||||||
|
## Database Templates
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
**CORRECT** (content-only):
|
||||||
|
```html
|
||||||
|
<h1>Payment Successful!</h1>
|
||||||
|
<p>Hello <strong>{nama}</strong>, your payment has been confirmed.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Parameter</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Order ID</td>
|
||||||
|
<td>{order_id}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
```
|
||||||
|
|
||||||
|
**WRONG** (full HTML):
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>...</head>
|
||||||
|
<body>
|
||||||
|
<h1>Payment Successful!</h1>
|
||||||
|
...
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Content-Only?
|
||||||
|
|
||||||
|
The master template provides:
|
||||||
|
- Email client compatibility (resets, Outlook fixes)
|
||||||
|
- Consistent header/footer
|
||||||
|
- Responsive wrapper
|
||||||
|
- Brutalist styling
|
||||||
|
|
||||||
|
Your content just needs the **body HTML** - no `<html>`, `<head>`, or `<body>` tags.
|
||||||
|
|
||||||
|
## Usage in Edge Functions
|
||||||
|
|
||||||
|
### Auth OTP (`send-auth-otp`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
|
||||||
|
|
||||||
|
// Fetch template from database
|
||||||
|
const template = await supabase
|
||||||
|
.from("notification_templates")
|
||||||
|
.select("*")
|
||||||
|
.eq("key", "auth_email_verification")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// Process shortcodes
|
||||||
|
let htmlContent = template.email_body_html;
|
||||||
|
Object.entries(templateVars).forEach(([key, value]) => {
|
||||||
|
htmlContent = htmlContent.replace(new RegExp(`{${key}}`, 'g'), value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap with master template
|
||||||
|
const htmlBody = EmailTemplateRenderer.render({
|
||||||
|
subject: template.email_subject,
|
||||||
|
content: htmlContent,
|
||||||
|
brandName: settings.platform_name || 'ACCESS HUB',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send via send-email-v2
|
||||||
|
await fetch(`${supabaseUrl}/functions/v1/send-email-v2`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: email,
|
||||||
|
html_body: htmlBody,
|
||||||
|
// ... other fields
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other Notifications (`send-notification`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
|
||||||
|
|
||||||
|
// Fetch template and process shortcodes
|
||||||
|
const htmlContent = replaceVariables(template.body_html || template.body_text, allVariables);
|
||||||
|
|
||||||
|
// Wrap with master template
|
||||||
|
const htmlBody = EmailTemplateRenderer.render({
|
||||||
|
subject: subject,
|
||||||
|
content: htmlContent,
|
||||||
|
brandName: settings.brand_name || "ACCESS HUB",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send via provider (SMTP, Resend, etc.)
|
||||||
|
await sendViaSMTP({ html: htmlBody, ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Shortcodes
|
||||||
|
|
||||||
|
See `ShortcodeProcessor.DEFAULT_DATA` in `supabase/shared/email-template-renderer.ts`:
|
||||||
|
|
||||||
|
**User:**
|
||||||
|
- `{nama}` - User name
|
||||||
|
- `{email}` - User email
|
||||||
|
|
||||||
|
**Order:**
|
||||||
|
- `{order_id}` - Order ID
|
||||||
|
- `{tanggal_pesanan}` - Order date
|
||||||
|
- `{total}` - Total amount
|
||||||
|
- `{metode_pembayaran}` - Payment method
|
||||||
|
|
||||||
|
**Product:**
|
||||||
|
- `{produk}` - Product name
|
||||||
|
- `{kategori_produk}` - Product category
|
||||||
|
|
||||||
|
**Access:**
|
||||||
|
- `{link_akses}` - Access link
|
||||||
|
- `{username_akses}` - Access username
|
||||||
|
- `{password_akses}` - Access password
|
||||||
|
|
||||||
|
**Consulting:**
|
||||||
|
- `{tanggal_konsultasi}` - Consultation date
|
||||||
|
- `{jam_konsultasi}` - Consultation time
|
||||||
|
- `{link_meet}` - Meeting link
|
||||||
|
|
||||||
|
**And many more...**
|
||||||
|
|
||||||
|
## Creating New Templates
|
||||||
|
|
||||||
|
### 1. Design Content-Only HTML
|
||||||
|
|
||||||
|
Use brutalist components:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h1>Welcome!</h1>
|
||||||
|
<p>Hello <strong>{nama}</strong>, welcome to <strong>{platform_name}</strong>!</p>
|
||||||
|
|
||||||
|
<h2>Your Details</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Email</td>
|
||||||
|
<td>{email}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Plan</td>
|
||||||
|
<td>{plan_name}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
<a href="{dashboard_link}" class="btn btn-full">
|
||||||
|
Go to Dashboard
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote class="alert-success">
|
||||||
|
<strong>Success!</strong> Your account is ready to use.
|
||||||
|
</blockquote>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add to Database
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO notification_templates (
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
is_active,
|
||||||
|
email_subject,
|
||||||
|
email_body_html
|
||||||
|
) VALUES (
|
||||||
|
'welcome_email',
|
||||||
|
'Welcome Email',
|
||||||
|
true,
|
||||||
|
'Welcome to {platform_name}!',
|
||||||
|
'---<h1>Welcome!</h1>...---'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use in Edge Function
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const template = await getTemplate('welcome_email');
|
||||||
|
const htmlContent = processShortcodes(template.body_html, {
|
||||||
|
nama: user.name,
|
||||||
|
platform_name: settings.brand_name,
|
||||||
|
email: user.email,
|
||||||
|
plan_name: user.plan,
|
||||||
|
dashboard_link: 'https://...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const htmlBody = EmailTemplateRenderer.render({
|
||||||
|
subject: template.subject,
|
||||||
|
content: htmlContent,
|
||||||
|
brandName: settings.brand_name,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
### Old Templates (Self-Contained HTML)
|
||||||
|
|
||||||
|
If you have old templates with full HTML:
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial; }
|
||||||
|
.container { max-width: 600px; }
|
||||||
|
h1 { color: #0066cc; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Welcome!</h1>
|
||||||
|
<p>Hello...</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (Content-Only):**
|
||||||
|
```html
|
||||||
|
<h1>Welcome!</h1>
|
||||||
|
<p>Hello...</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove:
|
||||||
|
- `<!DOCTYPE html>`
|
||||||
|
- `<html>`, `<head>`, `<body>` tags
|
||||||
|
- `<style>` blocks
|
||||||
|
- Container `<div>` wrappers
|
||||||
|
- Header/footer HTML
|
||||||
|
|
||||||
|
Keep:
|
||||||
|
- Content HTML only
|
||||||
|
- Shortcode placeholders `{variable}`
|
||||||
|
- Inline styles for special cases (rare)
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✅ **Consistent Branding** - All emails have same header/footer
|
||||||
|
✅ **Single Source of Truth** - One master template controls design
|
||||||
|
✅ **Easy Updates** - Change design in one place
|
||||||
|
✅ **Email Client Compatible** - Master template has all the fixes
|
||||||
|
✅ **Less Duplication** - No more reinventing styles per template
|
||||||
|
✅ **Auto-Styling** - `.tiptap-content` CSS makes content look good
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `supabase/shared/email-template-renderer.ts` - Master template + renderer
|
||||||
|
- `supabase/functions/send-auth-otp/index.ts` - Uses master template
|
||||||
|
- `supabase/functions/send-notification/index.ts` - Uses master template
|
||||||
|
- `supabase/migrations/20250102000005_fix_auth_email_template_content_only.sql` - Auth template update
|
||||||
|
- `email-master-template.html` - Visual reference of master template
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Master Template
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test with curl
|
||||||
|
curl -X POST https://lovable.backoffice.biz.id/functions/v1/send-auth-otp \
|
||||||
|
-H "Authorization: Bearer YOUR_SERVICE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user_id":"TEST_ID","email":"test@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Result
|
||||||
|
|
||||||
|
Email should have:
|
||||||
|
- Black header with "ACCESS HUB" logo
|
||||||
|
- Notification ID (NOTIF #XXXXXX)
|
||||||
|
- Content styled with brutalist design
|
||||||
|
- Gray footer with unsubscribe links
|
||||||
|
- Responsive on mobile
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Email Has No Styling
|
||||||
|
|
||||||
|
**Cause:** Template has full HTML, getting double-wrapped
|
||||||
|
|
||||||
|
**Fix:** Remove `<html>`, `<head>`, `<body>` from database template
|
||||||
|
|
||||||
|
### Styling Not Applied
|
||||||
|
|
||||||
|
**Cause:** Content not in `.tiptap-content` wrapper
|
||||||
|
|
||||||
|
**Fix:** Master template automatically wraps `{{content}}` in `.tiptap-content` div
|
||||||
|
|
||||||
|
### Broken Layout
|
||||||
|
|
||||||
|
**Cause:** Old template has container divs/wrappers
|
||||||
|
|
||||||
|
**Fix:** Remove container divs, keep only content HTML
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** ✅ Unified system active
|
||||||
|
**Last Updated:** 2025-01-02
|
||||||
227
MIGRATION_GUIDE.md
Normal file
227
MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Consulting Slots Migration - Code Updates Summary
|
||||||
|
|
||||||
|
## ✅ Completed Files
|
||||||
|
|
||||||
|
### 1. src/pages/ConsultingBooking.tsx ✅
|
||||||
|
- Updated interface: `ConfirmedSlot` → `ConfirmedSession` with `session_date` field
|
||||||
|
- Updated `fetchConfirmedSlots()` to query `consulting_sessions` table
|
||||||
|
- Updated slot creation logic to:
|
||||||
|
- Create ONE `consulting_sessions` row with session-level data
|
||||||
|
- Create MULTIPLE `consulting_time_slots` rows for each 45-min block
|
||||||
|
- Conflict checking logic already compatible (uses `start_time`/`end_time` fields)
|
||||||
|
|
||||||
|
### 2. supabase/functions/create-meet-link/index.ts ✅
|
||||||
|
- Changed update query from `consulting_slots` to `consulting_sessions`
|
||||||
|
- Updates meet_link once per session instead of once per slot
|
||||||
|
|
||||||
|
## ⏳ In Progress
|
||||||
|
|
||||||
|
### 3. src/pages/admin/AdminConsulting.tsx (PARTIAL)
|
||||||
|
**Updated:**
|
||||||
|
- Interface: `ConsultingSlot` → `ConsultingSession`
|
||||||
|
- State: `slots` → `sessions`, `selectedSlot` → `selectedSession`
|
||||||
|
- `fetchSessions()` - now queries `consulting_sessions` with profiles join
|
||||||
|
- `openMeetDialog()` - uses session parameter
|
||||||
|
- `saveMeetLink()` - updates `consulting_sessions` table
|
||||||
|
- `createMeetLink()` - uses session fields (`session_date`, etc.)
|
||||||
|
- `updateSessionStatus()` - renamed from `updateSlotStatus()`
|
||||||
|
- Filtering logic - simplified (no grouping needed)
|
||||||
|
- Stats sections - use `sessions` arrays
|
||||||
|
- Today's Sessions Alert - uses `todaySessions` array
|
||||||
|
|
||||||
|
**Still Needs Manual Update:**
|
||||||
|
Replace all remaining references in the table rendering sections (lines ~428-end):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FIND AND REPLACE THESE PATTERNS:
|
||||||
|
|
||||||
|
// 1. Tabs list:
|
||||||
|
<TabsTrigger value="upcoming">Mendatang ({upcomingOrders.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="past">Riwayat ({pastOrders.length})</TabsTrigger>
|
||||||
|
// CHANGE TO:
|
||||||
|
<TabsTrigger value="upcoming">Mendatang ({upcomingSessions.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="past">Riwayat ({pastSessions.length})</TabsTrigger>
|
||||||
|
|
||||||
|
// 2. Desktop table - upcoming:
|
||||||
|
{upcomingOrders.map((order) => {
|
||||||
|
const firstSlot = order.slots[0];
|
||||||
|
const lastSlot = order.slots[order.slots.length - 1];
|
||||||
|
const sessionCount = order.slots.length;
|
||||||
|
return (
|
||||||
|
<TableRow key={order.orderId || 'no-order'}>
|
||||||
|
// CHANGE TO:
|
||||||
|
{upcomingSessions.map((session) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={session.id}>
|
||||||
|
|
||||||
|
// 3. Date cell:
|
||||||
|
{format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })}
|
||||||
|
{isToday(parseISO(firstSlot.date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
|
||||||
|
{isTomorrow(parseISO(firstSlot.date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
|
||||||
|
// CHANGE TO:
|
||||||
|
{format(parseISO(session.session_date), 'd MMM yyyy', { locale: id })}
|
||||||
|
{isToday(parseISO(session.session_date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
|
||||||
|
{isTomorrow(parseISO(session.session_date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
|
||||||
|
|
||||||
|
// 4. Time cell:
|
||||||
|
<div>{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}</div>
|
||||||
|
{sessionCount > 1 && (
|
||||||
|
<div className="text-xs text-muted-foreground">{sessionCount} sesi</div>
|
||||||
|
)}
|
||||||
|
// CHANGE TO:
|
||||||
|
<div>{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}</div>
|
||||||
|
{session.total_blocks > 1 && (
|
||||||
|
<div className="text-xs text-muted-foreground">{session.total_blocks} blok</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
// 5. Client cell:
|
||||||
|
<p className="font-medium">{order.profile?.name || '-'}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{order.profile?.email}</p>
|
||||||
|
// CHANGE TO:
|
||||||
|
<p className="font-medium">{session.profiles?.name || '-'}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{session.profiles?.email}</p>
|
||||||
|
|
||||||
|
// 6. Category cell:
|
||||||
|
<Badge variant="outline">{firstSlot.topic_category}</Badge>
|
||||||
|
// CHANGE TO:
|
||||||
|
<Badge variant="outline">{session.topic_category}</Badge>
|
||||||
|
|
||||||
|
// 7. Status cell:
|
||||||
|
<Badge variant={statusLabels[firstSlot.status]?.variant || 'secondary'}>
|
||||||
|
{statusLabels[firstSlot.status]?.label || firstSlot.status}
|
||||||
|
</Badge>
|
||||||
|
// CHANGE TO:
|
||||||
|
<Badge variant={statusLabels[session.status]?.variant || 'secondary'}>
|
||||||
|
{statusLabels[session.status]?.label || session.status}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
// 8. Meet link cell:
|
||||||
|
{order.meetLink ? (
|
||||||
|
<a href={order.meetLink} ...>
|
||||||
|
// CHANGE TO:
|
||||||
|
{session.meet_link ? (
|
||||||
|
<a href={session.meet_link} ...>
|
||||||
|
|
||||||
|
// 9. Action buttons:
|
||||||
|
onClick={() => openMeetDialog(firstSlot)}
|
||||||
|
onClick={() => updateSlotStatus(firstSlot.id, 'completed')}
|
||||||
|
onClick={() => updateSlotStatus(firstSlot.id, 'cancelled')}
|
||||||
|
// CHANGE TO:
|
||||||
|
onClick={() => openMeetDialog(session)}
|
||||||
|
onClick={() => updateSessionStatus(session.id, 'completed')}
|
||||||
|
onClick={() => updateSessionStatus(session.id, 'cancelled')}
|
||||||
|
|
||||||
|
// 10. Empty state:
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
|
Tidak ada jadwal mendatang
|
||||||
|
</TableCell>
|
||||||
|
// CHANGE TO (same colSpan):
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
|
Tidak ada jadwal mendatang
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
// 11. Mobile card layout - same pattern as desktop:
|
||||||
|
{upcomingOrders.map((order) => {
|
||||||
|
const firstSlot = order.slots[0];
|
||||||
|
// CHANGE TO:
|
||||||
|
{upcomingSessions.map((session) => {
|
||||||
|
|
||||||
|
// Then replace all:
|
||||||
|
// order.orderId → session.id
|
||||||
|
// order.slots[0] / firstSlot → session
|
||||||
|
// order.slots[order.slots.length - 1] / lastSlot → session
|
||||||
|
// order.profile → session.profiles
|
||||||
|
// order.meetLink → session.meet_link
|
||||||
|
// sessionCount → session.total_blocks
|
||||||
|
|
||||||
|
// 12. Past sessions tab - same pattern:
|
||||||
|
{pastOrders.slice(0, 20).map((order) => {
|
||||||
|
// CHANGE TO:
|
||||||
|
{pastSessions.slice(0, 20).map((session) => {
|
||||||
|
|
||||||
|
// 13. Dialog - selectedSlot references:
|
||||||
|
{selectedSlot && (
|
||||||
|
<div className="p-3 bg-muted rounded-lg text-sm space-y-1">
|
||||||
|
<p><strong>Tanggal:</strong> {format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id })}</p>
|
||||||
|
<p><strong>Waktu:</strong> {selectedSlot.start_time.substring(0, 5)} - {selectedSlot.end_time.substring(0, 5)}</p>
|
||||||
|
<p><strong>Klien:</strong> {selectedSlot.profiles?.name}</p>
|
||||||
|
<p><strong>Topik:</strong> {selectedSlot.topic_category}</p>
|
||||||
|
{selectedSlot.notes && <p><strong>Catatan:</strong> {selectedSlot.notes}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
// CHANGE TO:
|
||||||
|
{selectedSession && (
|
||||||
|
<div className="p-3 bg-muted rounded-lg text-sm space-y-1">
|
||||||
|
<p><strong>Tanggal:</strong> {format(parseISO(selectedSession.session_date), 'd MMMM yyyy', { locale: id })}</p>
|
||||||
|
<p><strong>Waktu:</strong> {selectedSession.start_time.substring(0, 5)} - {selectedSession.end_time.substring(0, 5)}</p>
|
||||||
|
<p><strong>Klien:</strong> {selectedSession.profiles?.name}</p>
|
||||||
|
<p><strong>Topik:</strong> {selectedSession.topic_category}</p>
|
||||||
|
{selectedSession.notes && <p><strong>Catatan:</strong> {selectedSession.notes}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Remaining Files to Update
|
||||||
|
|
||||||
|
### 4. src/components/reviews/ConsultingHistory.tsx
|
||||||
|
**Changes needed:**
|
||||||
|
- Change query from `consulting_slots` to `consulting_sessions`
|
||||||
|
- Remove grouping logic (no longer needed)
|
||||||
|
- Update interface to use `ConsultingSession` with fields:
|
||||||
|
- `session_date` (instead of `date`)
|
||||||
|
- `total_duration_minutes`
|
||||||
|
- `total_blocks`
|
||||||
|
- `total_price`
|
||||||
|
- Update all field references in rendering
|
||||||
|
|
||||||
|
### 5. src/pages/member/OrderDetail.tsx
|
||||||
|
**Changes needed:**
|
||||||
|
- Find consulting_slots query and change to consulting_sessions
|
||||||
|
- Update join to include session data
|
||||||
|
- Update field names in rendering (date → session_date, etc.)
|
||||||
|
|
||||||
|
### 6. supabase/functions/handle-order-paid/index.ts
|
||||||
|
**Changes needed:**
|
||||||
|
- Change status update from `consulting_slots` to `consulting_sessions`
|
||||||
|
- Update logic to set `status = 'confirmed'` for session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: Field Name Changes
|
||||||
|
|
||||||
|
| Old (consulting_slots) | New (consulting_sessions) |
|
||||||
|
|------------------------|---------------------------|
|
||||||
|
| `date` | `session_date` |
|
||||||
|
| `slots` array | Single `session` object |
|
||||||
|
| `slots[0]` / `firstSlot` | `session` |
|
||||||
|
| `slots[length-1]` / `lastSlot` | `session` |
|
||||||
|
| `order_id` (for grouping) | `id` (session ID) |
|
||||||
|
| `meet_link` (per slot) | `meet_link` (per session) |
|
||||||
|
| Row count × 45min | `total_duration_minutes` |
|
||||||
|
| Row count | `total_blocks` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
After migration:
|
||||||
|
- [ ] Test booking flow - creates session + time slots
|
||||||
|
- [ ] Test availability checking - uses sessions table
|
||||||
|
- [ ] Test meet link creation - updates session
|
||||||
|
- [ ] Test admin consulting page - displays sessions
|
||||||
|
- [ ] Test user consulting history - displays sessions
|
||||||
|
- [ ] Test order detail - shows consulting session info
|
||||||
|
- [ ] Test payment confirmation - updates session status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan (if needed)
|
||||||
|
|
||||||
|
If issues arise:
|
||||||
|
1. Restore old table: `ALTER TABLE consulting_slots RENAME TO consulting_slots_backup;`
|
||||||
|
2. Create view: `CREATE VIEW consulting_slots AS SELECT ... FROM consulting_sessions JOIN consulting_time_slots;`
|
||||||
|
3. Revert code changes from git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note:** All SQL tables should already be created. This document covers code changes only.
|
||||||
198
OTP-IMPLEMENTATION-SUMMARY.md
Normal file
198
OTP-IMPLEMENTATION-SUMMARY.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# OTP Email Verification - Implementation Summary
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
A complete OTP-based email verification system for self-hosted Supabase without SMTP configuration.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
✅ 6-digit OTP codes with 15-minute expiration
|
||||||
|
✅ Email verification via Mailketing API
|
||||||
|
✅ Master template wrapper with brutalist design
|
||||||
|
✅ OTP resend functionality (60 second cooldown)
|
||||||
|
✅ Email confirmation via admin API
|
||||||
|
✅ Auto-login after verification (user must still login manually per your security preference)
|
||||||
|
|
||||||
|
## Components Created
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
1. **`20250102000001_auth_otp.sql`** - Creates `auth_otps` table
|
||||||
|
2. **`20250102000002_auth_email_template.sql`** - Inserts email template
|
||||||
|
3. **`20250102000003_fix_auth_otps_fk.sql`** - Removes FK constraint for unconfirmed users
|
||||||
|
4. **`20250102000004_fix_auth_email_template.sql`** - Fixes template YAML delimiters
|
||||||
|
|
||||||
|
### Edge Functions
|
||||||
|
1. **`send-auth-otp`** - Generates OTP and sends email
|
||||||
|
2. **`verify-auth-otp`** - Validates OTP and confirms email
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **`src/pages/auth.tsx`** - Added OTP input UI with resend functionality
|
||||||
|
- **`src/hooks/useAuth.tsx`** - Added `sendAuthOTP` and `verifyAuthOTP` functions
|
||||||
|
|
||||||
|
### Email Template
|
||||||
|
- **Master Template** - Professional brutalist design with header/footer
|
||||||
|
- **OTP Content** - Clear instructions with large OTP code display
|
||||||
|
- **Responsive** - Mobile-friendly layout
|
||||||
|
- **Branded** - ACCESS HUB header and styling
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Registration Flow
|
||||||
|
```
|
||||||
|
User fills form → Supabase Auth creates user
|
||||||
|
→ send-auth-otp generates 6-digit code
|
||||||
|
→ Stores in auth_otps table (15 min expiry)
|
||||||
|
→ Fetches email template
|
||||||
|
→ Wraps content in master template
|
||||||
|
→ Sends via Mailketing API
|
||||||
|
→ Shows OTP input form
|
||||||
|
→ User enters code from email
|
||||||
|
→ verify-auth-otp validates code
|
||||||
|
→ Confirms email in Supabase Auth
|
||||||
|
→ User can now login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
- **No SMTP required** - Uses existing Mailketing API
|
||||||
|
- **Instant delivery** - No queue, no cron jobs
|
||||||
|
- **Reusable** - Same system can be used for password reset
|
||||||
|
- **Secure** - One-time use, expiration, no token leakage
|
||||||
|
- **Observable** - Logs and database records for debugging
|
||||||
|
|
||||||
|
## Deployment Checklist
|
||||||
|
|
||||||
|
### 1. Deploy Database Migrations
|
||||||
|
All migrations should already be applied. Verify:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM auth_otps LIMIT 1;
|
||||||
|
SELECT * FROM notification_templates WHERE key = 'auth_email_verification';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Deploy Edge Functions
|
||||||
|
```bash
|
||||||
|
# SSH into your server
|
||||||
|
ssh root@lovable.backoffice.biz.id
|
||||||
|
|
||||||
|
# Pull latest code
|
||||||
|
cd /path/to/project
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Deploy functions (method depends on your setup)
|
||||||
|
supabase functions deploy send-auth-otp
|
||||||
|
supabase functions deploy verify-auth-otp
|
||||||
|
|
||||||
|
# Or restart edge function container
|
||||||
|
docker restart $(docker ps -q --filter 'name=supabase_edge_runtime')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verify Environment Variables
|
||||||
|
Ensure `.env` file exists locally (for development):
|
||||||
|
```bash
|
||||||
|
VITE_SUPABASE_URL=https://lovable.backoffice.biz.id/
|
||||||
|
VITE_SUPABASE_ANON_KEY=your_anon_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test the Flow
|
||||||
|
1. Go to `/auth` page
|
||||||
|
2. Switch to registration form
|
||||||
|
3. Register with new email
|
||||||
|
4. Check email for OTP code
|
||||||
|
5. Enter OTP code
|
||||||
|
6. Verify email is confirmed
|
||||||
|
7. Login with credentials
|
||||||
|
|
||||||
|
## Files to Deploy to Production
|
||||||
|
|
||||||
|
### Edge Functions (Must Deploy)
|
||||||
|
- `supabase/functions/send-auth-otp/index.ts`
|
||||||
|
- `supabase/functions/verify-auth-otp/index.ts`
|
||||||
|
|
||||||
|
### Already Deployed (No Action Needed)
|
||||||
|
- `src/pages/auth.tsx` - Frontend changes
|
||||||
|
- `src/hooks/useAuth.tsx` - Auth hook changes
|
||||||
|
- Database migrations - Should already be applied
|
||||||
|
|
||||||
|
## Common Issues & Solutions
|
||||||
|
|
||||||
|
### Issue: Email Not Received
|
||||||
|
**Check:**
|
||||||
|
1. `auth_otps` table has new row? → OTP was generated
|
||||||
|
2. Edge function logs for errors
|
||||||
|
3. Mailketing API token is valid
|
||||||
|
4. `from_email` in notification_settings is real domain
|
||||||
|
|
||||||
|
### Issue: Email Has No Styling
|
||||||
|
**Solution:** Deploy the updated `send-auth-otp` function with master template wrapper.
|
||||||
|
|
||||||
|
### Issue: "Email Already Registered"
|
||||||
|
**Cause:** Supabase keeps deleted users in recycle bin
|
||||||
|
**Solution:** Permanently delete from Supabase Dashboard or use different email
|
||||||
|
|
||||||
|
### Issue: OTP Verification Fails
|
||||||
|
**Check:**
|
||||||
|
1. OTP code matches exactly (6 digits)
|
||||||
|
2. Not expired (15 minute limit)
|
||||||
|
3. Not already used
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Register new user
|
||||||
|
- [ ] Receive OTP email
|
||||||
|
- [ ] Email has proper styling (header, footer, brutalist design)
|
||||||
|
- [ ] OTP code is visible and clear
|
||||||
|
- [ ] Enter OTP code successfully
|
||||||
|
- [ ] Email confirmed in database
|
||||||
|
- [ ] Can login with credentials
|
||||||
|
- [ ] Resend OTP works (60 second countdown)
|
||||||
|
- [ ] Expired OTP rejected (wait 15 minutes)
|
||||||
|
- [ ] Wrong OTP rejected
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
✅ 6-digit random OTP (100000-999999)
|
||||||
|
✅ 15-minute expiration
|
||||||
|
✅ One-time use (marked as used after verification)
|
||||||
|
✅ No token leakage in logs
|
||||||
|
✅ Rate limiting ready (can be added)
|
||||||
|
✅ No email enumeration (generic errors)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Optional improvements for later:
|
||||||
|
1. **Rate Limiting** - Limit OTP generation attempts
|
||||||
|
2. **Password Reset** - Use same OTP system
|
||||||
|
3. **Admin Bypass** - Manually verify users
|
||||||
|
4. **Multiple Templates** - Different email styles
|
||||||
|
5. **SMS OTP** - Alternative to email
|
||||||
|
6. **Analytics** - Track email delivery rates
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
✅ User registers → Receives email within seconds
|
||||||
|
✅ Email has professional design with master template
|
||||||
|
✅ OTP code is clearly displayed
|
||||||
|
✅ Verification works reliably
|
||||||
|
✅ User can login after verification
|
||||||
|
✅ System works without SMTP
|
||||||
|
✅ Easy to debug and maintain
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [DEPLOY-OTP-FIX.md](DEPLOY-OTP-FIX.md) - Deployment guide
|
||||||
|
- [otp-testing-guide.md](otp-testing-guide.md) - Testing instructions
|
||||||
|
- [test-otp-flow.sh](test-otp-flow.sh) - Test script
|
||||||
|
- [cleanup-user.sql](cleanup-user.sql) - Clean up test users
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If issues occur:
|
||||||
|
1. Check browser console for errors
|
||||||
|
2. Check edge function logs
|
||||||
|
3. Verify database tables have data
|
||||||
|
4. Test edge function with curl
|
||||||
|
5. Check Mailketing API status
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
✅ **COMPLETE** - System is ready for production use
|
||||||
|
|
||||||
|
Last updated: 2025-01-02
|
||||||
77
PLATFORM_SETTINGS_RLS_FIX.sql
Normal file
77
PLATFORM_SETTINGS_RLS_FIX.sql
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- RLS POLICIES FOR platform_settings TABLE
|
||||||
|
-- =====================================================
|
||||||
|
-- This fixes the empty JSON response when non-admin users
|
||||||
|
-- try to access branding settings (logo, favicon, colors)
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Step 1: Enable RLS on platform_settings (if not already enabled)
|
||||||
|
ALTER TABLE platform_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Step 2: Drop existing policies (if any)
|
||||||
|
DROP POLICY IF EXISTS "Public can view platform settings" ON platform_settings;
|
||||||
|
DROP POLICY IF EXISTS "Authenticated can view platform settings" ON platform_settings;
|
||||||
|
DROP POLICY IF EXISTS "Admins can update platform settings" ON platform_settings;
|
||||||
|
DROP POLICY IF EXISTS "Admins can insert platform settings" ON platform_settings;
|
||||||
|
DROP POLICY IF EXISTS "Admins can delete platform settings" ON platform_settings;
|
||||||
|
|
||||||
|
-- Step 3: Create policies
|
||||||
|
|
||||||
|
-- Policy 1: Allow ANYONE (including public) to SELECT platform_settings
|
||||||
|
-- This is needed for branding to work on public pages
|
||||||
|
CREATE POLICY "Public can view platform settings"
|
||||||
|
ON platform_settings FOR SELECT
|
||||||
|
TO public
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
-- Policy 2: Allow authenticated users to UPDATE platform_settings
|
||||||
|
-- (Simplified - all authenticated users can update for now)
|
||||||
|
CREATE POLICY "Authenticated can update platform settings"
|
||||||
|
ON platform_settings FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Policy 3: Allow authenticated users to INSERT platform_settings
|
||||||
|
CREATE POLICY "Authenticated can insert platform settings"
|
||||||
|
ON platform_settings FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Policy 4: Allow authenticated users to DELETE platform_settings
|
||||||
|
CREATE POLICY "Authenticated can delete platform settings"
|
||||||
|
ON platform_settings FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- VERIFICATION
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Test as public (should return data)
|
||||||
|
SELECT * FROM platform_settings;
|
||||||
|
|
||||||
|
-- Check current policies
|
||||||
|
SELECT
|
||||||
|
tablename,
|
||||||
|
policyname,
|
||||||
|
permissive,
|
||||||
|
roles,
|
||||||
|
cmd
|
||||||
|
FROM pg_policies
|
||||||
|
WHERE tablename = 'platform_settings';
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- TROUBLESHOOTING
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Check if RLS is enabled
|
||||||
|
SELECT tablename, rowsecurity
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename = 'platform_settings';
|
||||||
|
|
||||||
|
-- Check if table has data
|
||||||
|
SELECT COUNT(*) as row_count FROM platform_settings;
|
||||||
|
|
||||||
|
-- Check current user
|
||||||
|
SELECT auth.uid();
|
||||||
109
STORAGE_RLS_FIX.sql
Normal file
109
STORAGE_RLS_FIX.sql
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- STORAGE RLS POLICIES FOR LOGO/FAVICON UPLOAD
|
||||||
|
-- =====================================================
|
||||||
|
-- This fixes the "new row violates row-level security policy" error
|
||||||
|
-- when uploading logo/favicon to Supabase Storage
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Step 1: Verify the 'content' bucket exists
|
||||||
|
SELECT * FROM storage.buckets WHERE name = 'content';
|
||||||
|
|
||||||
|
-- If no rows returned, create the bucket:
|
||||||
|
-- INSERT INTO storage.buckets (id, name, public)
|
||||||
|
-- VALUES ('content', 'content', true);
|
||||||
|
|
||||||
|
-- Step 2: Drop ALL existing policies first to avoid conflicts
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can upload brand assets" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can update brand assets" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can delete brand assets" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Public can view brand assets" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can list brand assets" ON storage.objects;
|
||||||
|
|
||||||
|
-- Step 3: Create policies for brand-assets upload
|
||||||
|
|
||||||
|
-- Policy 1: Allow authenticated users to INSERT (upload) files to brand-assets folder
|
||||||
|
CREATE POLICY "Authenticated users can upload brand assets"
|
||||||
|
ON storage.objects FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND (name LIKE 'brand-assets/logo/%' OR name LIKE 'brand-assets/favicon/%')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Policy 2: Allow authenticated users to UPDATE (replace) files in brand-assets folder
|
||||||
|
CREATE POLICY "Authenticated users can update brand assets"
|
||||||
|
ON storage.objects FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND (name LIKE 'brand-assets/logo/%' OR name LIKE 'brand-assets/favicon/%')
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND (name LIKE 'brand-assets/logo/%' OR name LIKE 'brand-assets/favicon/%')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Policy 3: Allow authenticated users to DELETE files in brand-assets folder
|
||||||
|
CREATE POLICY "Authenticated users can delete brand assets"
|
||||||
|
ON storage.objects FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND (name LIKE 'brand-assets/logo/%' OR name LIKE 'brand-assets/favicon/%')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Policy 4: Allow public SELECT (view) on brand-assets (for displaying images)
|
||||||
|
CREATE POLICY "Public can view brand assets"
|
||||||
|
ON storage.objects FOR SELECT
|
||||||
|
TO public
|
||||||
|
USING (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND (name LIKE 'brand-assets/logo/%' OR name LIKE 'brand-assets/favicon/%')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Policy 5: Allow LIST operation for authenticated users (needed for auto-delete)
|
||||||
|
CREATE POLICY "Authenticated users can list brand assets"
|
||||||
|
ON storage.objects FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND (name LIKE 'brand-assets/logo%' OR name LIKE 'brand-assets/favicon%')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- VERIFICATION QUERIES
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Check all policies on storage.objects
|
||||||
|
SELECT
|
||||||
|
schemaname,
|
||||||
|
tablename,
|
||||||
|
policyname,
|
||||||
|
permissive,
|
||||||
|
roles,
|
||||||
|
cmd
|
||||||
|
FROM pg_policies
|
||||||
|
WHERE tablename = 'objects'
|
||||||
|
AND schemaname = 'storage'
|
||||||
|
AND policyname LIKE '%brand assets%';
|
||||||
|
|
||||||
|
-- Test if you can access the bucket
|
||||||
|
SELECT * FROM storage.objects WHERE bucket_id = 'content' LIMIT 5;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- TROUBLESHOOTING
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- If still getting RLS errors, check:
|
||||||
|
|
||||||
|
-- 1. Are you logged in?
|
||||||
|
SELECT auth.uid();
|
||||||
|
|
||||||
|
-- 2. Check RLS is enabled
|
||||||
|
SELECT tablename, rowsecurity
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'storage'
|
||||||
|
AND tablename = 'objects';
|
||||||
|
|
||||||
|
-- 3. Check bucket is public
|
||||||
|
SELECT * FROM storage.buckets WHERE name = 'content';
|
||||||
11
add-google-oauth-config.sql
Normal file
11
add-google-oauth-config.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add google_oauth_config column to platform_settings table
|
||||||
|
-- This replaces google_service_account_json for personal Gmail accounts
|
||||||
|
|
||||||
|
ALTER TABLE platform_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS google_oauth_config jsonb;
|
||||||
|
|
||||||
|
-- Add comment
|
||||||
|
COMMENT ON COLUMN platform_settings.google_oauth_config IS 'OAuth2 configuration for Google Calendar API (for personal Gmail accounts). Format: {"client_id": "...", "client_secret": "...", "refresh_token": "..."}';
|
||||||
|
|
||||||
|
-- Note: The old google_service_account_json column can be dropped later if no longer needed
|
||||||
|
-- ALTER TABLE platform_settings DROP COLUMN IF EXISTS google_service_account_json;
|
||||||
6
add-google-service-account-column.sql
Normal file
6
add-google-service-account-column.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add google_service_account_json column to platform_settings
|
||||||
|
ALTER TABLE platform_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS google_service_account_json TEXT;
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN platform_settings.google_service_account_json IS 'Google Service Account JSON for Calendar API integration (use service account to avoid OAuth)';
|
||||||
6
add-n8n-test-mode.sql
Normal file
6
add-n8n-test-mode.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add integration_n8n_test_mode column to platform_settings table
|
||||||
|
ALTER TABLE platform_settings
|
||||||
|
ADD COLUMN integration_n8n_test_mode BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Add a comment for documentation
|
||||||
|
COMMENT ON COLUMN platform_settings.integration_n8n_test_mode IS 'Toggle for n8n webhook test mode - uses /webhook-test/ when true, /webhook/ when false';
|
||||||
372
adilo-ai-agent-quick-ref.md
Normal file
372
adilo-ai-agent-quick-ref.md
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
# Adilo Video Player - Quick AI Agent Reference
|
||||||
|
|
||||||
|
## For Your Windsurf/IDE AI Agent
|
||||||
|
|
||||||
|
Copy this into your `.codebase` instructions or share with AI agent:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project: LearnHub - Adilo M3U8 Video Player with Custom Chapters
|
||||||
|
|
||||||
|
### Problem Statement
|
||||||
|
Build a React video player that:
|
||||||
|
- Streams video from Adilo using M3U8 (HLS) direct URL
|
||||||
|
- Displays custom chapter navigation
|
||||||
|
- Allows click-to-jump to chapters
|
||||||
|
- Tracks user progress
|
||||||
|
- Saves completion data to Supabase
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **React 18+** (Hooks, Context)
|
||||||
|
- **HLS.js** - for M3U8 streaming
|
||||||
|
- **Supabase** - for progress tracking
|
||||||
|
- **HTML5 Video API** - native controls
|
||||||
|
- **CSS Modules** - styling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Command Reference
|
||||||
|
|
||||||
|
### Install Dependencies
|
||||||
|
```bash
|
||||||
|
npm install hls.js @supabase/supabase-js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure to Create
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── AdiloVideoPlayer.jsx # Main component
|
||||||
|
│ ├── ChapterNavigation.jsx # Chapter sidebar
|
||||||
|
│ └── ProgressBar.jsx # Progress indicator
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAdiloPlayer.js # HLS streaming logic
|
||||||
|
│ └── useChapterTracking.js # Chapter tracking
|
||||||
|
├── services/
|
||||||
|
│ ├── adiloService.js # Adilo API calls
|
||||||
|
│ └── progressService.js # Supabase progress
|
||||||
|
├── styles/
|
||||||
|
│ └── AdiloVideoPlayer.module.css
|
||||||
|
└── types/
|
||||||
|
└── video.types.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases (In Order)
|
||||||
|
|
||||||
|
### ⭐ PHASE 1: useAdiloPlayer Hook
|
||||||
|
**Goal**: Get HLS.js working with M3U8 URL
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
- React hook that initializes HLS.js instance
|
||||||
|
- Return: videoRef, isReady, isPlaying, currentTime, duration
|
||||||
|
- Handle browser compatibility (Safari vs HLS.js)
|
||||||
|
- Clean up HLS instance on unmount
|
||||||
|
- Emit callbacks: onTimeUpdate, onEnded, onError
|
||||||
|
|
||||||
|
**Test with:**
|
||||||
|
```javascript
|
||||||
|
const { videoRef, currentTime, isReady } = useAdiloPlayer({
|
||||||
|
m3u8Url: "https://adilo.bigcommand.com/m3u8/...",
|
||||||
|
autoplay: false,
|
||||||
|
onTimeUpdate: (time) => console.log(time)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 2: useChapterTracking Hook
|
||||||
|
**Goal**: Determine which chapter is currently active
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
- React hook that tracks active chapter
|
||||||
|
- Input: chapters array, currentTime
|
||||||
|
- Return: activeChapter, activeChapterId, chapterProgress
|
||||||
|
- Detect chapter transitions
|
||||||
|
- Calculate progress percentage
|
||||||
|
|
||||||
|
**Chapter data structure:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: "ch1",
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 120,
|
||||||
|
title: "Introduction",
|
||||||
|
description: "Welcome to the course"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test with:**
|
||||||
|
```javascript
|
||||||
|
const { activeChapter, chapterProgress } = useChapterTracking({
|
||||||
|
chapters: [...],
|
||||||
|
currentTime: 45
|
||||||
|
});
|
||||||
|
// activeChapter should be chapter with startTime ≤ 45 < endTime
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 3: AdiloVideoPlayer Component
|
||||||
|
**Goal**: Main player combining both hooks
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
- Component that uses both hooks
|
||||||
|
- Renders: <video> element + video controls
|
||||||
|
- Props: m3u8Url, videoId, chapters, autoplay, showChapters
|
||||||
|
- Methods: jumpToChapter(), play(), pause()
|
||||||
|
- Callbacks: onChapterChange, onVideoComplete, onProgressUpdate
|
||||||
|
|
||||||
|
**Usage example:**
|
||||||
|
```jsx
|
||||||
|
<AdiloVideoPlayer
|
||||||
|
m3u8Url="https://adilo.bigcommand.com/m3u8/..."
|
||||||
|
chapters={[{id: "1", startTime: 0, endTime: 120, title: "Intro"}]}
|
||||||
|
onVideoComplete={() => markComplete()}
|
||||||
|
onChapterChange={(ch) => console.log(ch.title)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 4: ChapterNavigation Component
|
||||||
|
**Goal**: Display chapters user can click to jump
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
- Sidebar/timeline showing all chapters
|
||||||
|
- Highlight current active chapter
|
||||||
|
- Show time for each chapter
|
||||||
|
- Click handler to jump to chapter
|
||||||
|
- Progress bar for each chapter
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- chapters: Chapter[]
|
||||||
|
- activeChapterId: string
|
||||||
|
- currentTime: number
|
||||||
|
- onChapterClick: (startTime: number) => void
|
||||||
|
- completedChapters: string[]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 5: Supabase Integration
|
||||||
|
**Goal**: Save video progress to database
|
||||||
|
|
||||||
|
**Database schema needed:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE video_progress (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
video_id uuid NOT NULL,
|
||||||
|
last_position int,
|
||||||
|
completed_chapters text[],
|
||||||
|
watched_percentage int,
|
||||||
|
is_completed boolean DEFAULT false,
|
||||||
|
completed_at timestamp,
|
||||||
|
created_at timestamp DEFAULT now(),
|
||||||
|
updated_at timestamp DEFAULT now(),
|
||||||
|
UNIQUE(user_id, video_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Functions to implement:**
|
||||||
|
- `saveProgress(userId, videoId, currentTime, completedChapters)`
|
||||||
|
- `getLastPosition(userId, videoId)` - resume from last position
|
||||||
|
- `markVideoComplete(userId, videoId)`
|
||||||
|
- `getVideoAnalytics(userId, videoId)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 6: Styling
|
||||||
|
**Goal**: Make it look good and responsive
|
||||||
|
|
||||||
|
**Key CSS classes needed:**
|
||||||
|
- `.adilo-player` - main container
|
||||||
|
- `.video-container` - video wrapper
|
||||||
|
- `.chapters-sidebar` - chapter list
|
||||||
|
- `.chapter-item` - individual chapter
|
||||||
|
- `.chapter-item.active` - highlight active
|
||||||
|
- `.progress-bar` - progress visualization
|
||||||
|
|
||||||
|
**Responsive breakpoints:**
|
||||||
|
- Desktop: sidebar on right
|
||||||
|
- Tablet: sidebar below video
|
||||||
|
- Mobile: horizontal timeline under video
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
|
||||||
|
### HLS.js Initialization Pattern
|
||||||
|
```javascript
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls();
|
||||||
|
hls.loadSource(m3u8Url);
|
||||||
|
hls.attachMedia(videoElement);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
videoElement.play();
|
||||||
|
});
|
||||||
|
} else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Safari native HLS support
|
||||||
|
videoElement.src = m3u8Url;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chapter Jump Implementation
|
||||||
|
```javascript
|
||||||
|
const jumpToChapter = (startTime) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = startTime;
|
||||||
|
videoRef.current.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Track Current Chapter Pattern
|
||||||
|
```javascript
|
||||||
|
const current = chapters.find(
|
||||||
|
ch => currentTime >= ch.startTime && currentTime < ch.endTime
|
||||||
|
);
|
||||||
|
setActiveChapter(current?.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounce Progress Saves
|
||||||
|
```javascript
|
||||||
|
// Save progress every 5 seconds, not on every timeupdate
|
||||||
|
const saveProgressDebounced = debounce(
|
||||||
|
(userId, videoId, time) => saveProgress(userId, videoId, time),
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Tasks for AI Agent
|
||||||
|
|
||||||
|
When asking your AI agent to implement:
|
||||||
|
|
||||||
|
### Task: "Create useAdiloPlayer hook"
|
||||||
|
**Should generate:**
|
||||||
|
- Import HLS from 'hls.js'
|
||||||
|
- useRef for video element
|
||||||
|
- useEffect to initialize HLS
|
||||||
|
- useCallback for event handlers
|
||||||
|
- Clean up logic in return
|
||||||
|
|
||||||
|
### Task: "Add chapter jump functionality"
|
||||||
|
**Should implement:**
|
||||||
|
- Button click handler
|
||||||
|
- Call jumpToChapter(startTime)
|
||||||
|
- Update videoRef.current.currentTime
|
||||||
|
- Play the video
|
||||||
|
|
||||||
|
### Task: "Save progress to Supabase"
|
||||||
|
**Should implement:**
|
||||||
|
- Create/update row in video_progress table
|
||||||
|
- Include: user_id, video_id, last_position, completed_chapters
|
||||||
|
- Handle conflicts (UPSERT)
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### Task: "Make chapters responsive"
|
||||||
|
**Should implement:**
|
||||||
|
- CSS Grid for desktop (sidebar)
|
||||||
|
- Flex column for mobile
|
||||||
|
- Media query at 768px breakpoint
|
||||||
|
- Adjust spacing and font sizes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- [ ] useAdiloPlayer hook returns correct refs/values
|
||||||
|
- [ ] useChapterTracking calculates active chapter correctly
|
||||||
|
- [ ] jumpToChapter updates video.currentTime
|
||||||
|
- [ ] Progress saves to Supabase
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- [ ] Video plays when component mounts
|
||||||
|
- [ ] Chapter changes highlight in UI
|
||||||
|
- [ ] Clicking chapter jumps player
|
||||||
|
- [ ] Progress saves on interval
|
||||||
|
- [ ] Completion triggers callback
|
||||||
|
|
||||||
|
### Browser Tests
|
||||||
|
- [ ] Works on Chrome/Edge (HLS.js)
|
||||||
|
- [ ] Works on Firefox (HLS.js)
|
||||||
|
- [ ] Works on Safari (native HLS)
|
||||||
|
- [ ] Works on mobile browsers
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] Bad M3U8 URL shows error
|
||||||
|
- [ ] Network interruption handled
|
||||||
|
- [ ] Video paused mid-chapter
|
||||||
|
- [ ] Page refresh preserves position
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Needed
|
||||||
|
```
|
||||||
|
VITE_SUPABASE_URL=your_supabase_url
|
||||||
|
VITE_SUPABASE_ANON_KEY=your_supabase_key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
### HLS.js not loading?
|
||||||
|
- Check M3U8 URL is correct from Adilo
|
||||||
|
- Verify CORS headers from Adilo
|
||||||
|
- Check browser console for HLS.js errors
|
||||||
|
- Try `hls.on(Hls.Events.ERROR, console.error)`
|
||||||
|
|
||||||
|
### Chapter not highlighting?
|
||||||
|
- Add console.log(currentTime, chapters) to track values
|
||||||
|
- Verify chapter startTime/endTime are correct
|
||||||
|
- Check activeChapter state is updating
|
||||||
|
|
||||||
|
### Progress not saving?
|
||||||
|
- Verify Supabase connection works
|
||||||
|
- Check user_id and video_id are defined
|
||||||
|
- Add error logs to saveProgress function
|
||||||
|
- Check database table schema matches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Optimization Tips
|
||||||
|
|
||||||
|
1. **Memoize chapters list** to prevent re-renders
|
||||||
|
```javascript
|
||||||
|
const chapters = useMemo(() => chaptersData, [chaptersData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Debounce timeupdate events** (fires 60x per second!)
|
||||||
|
```javascript
|
||||||
|
const updateChapter = debounce(() => {...}, 100);
|
||||||
|
video.addEventListener('timeupdate', updateChapter);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Lazy load chapter images/thumbnails**
|
||||||
|
```javascript
|
||||||
|
<img loading="lazy" src={chapter.thumbnail} />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Use React.memo for ChapterNavigation**
|
||||||
|
```javascript
|
||||||
|
export default React.memo(ChapterNavigation);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **HLS.js Docs**: https://github.com/video-dev/hls.js/wiki
|
||||||
|
- **HTML5 Video API**: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video
|
||||||
|
- **Supabase JS Client**: https://supabase.com/docs/reference/javascript/introduction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: Ready to implement! 🚀
|
||||||
|
|
||||||
|
Start with PHASE 1 (useAdiloPlayer hook), then PHASE 2-6 in order.
|
||||||
702
adilo-code-templates-starter.md
Normal file
702
adilo-code-templates-starter.md
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
# Code Templates - Copy & Paste Starting Points
|
||||||
|
|
||||||
|
## File 1: hooks/useAdiloPlayer.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing Adilo video playback via HLS.js
|
||||||
|
* Handles M3U8 URL streaming with browser compatibility
|
||||||
|
*/
|
||||||
|
export function useAdiloPlayer({
|
||||||
|
m3u8Url,
|
||||||
|
autoplay = false,
|
||||||
|
onTimeUpdate = () => {},
|
||||||
|
onEnded = () => {},
|
||||||
|
onError = () => {},
|
||||||
|
} = {}) {
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
const hlsRef = useRef(null);
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Initialize HLS streaming
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !m3u8Url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Safari has native HLS support
|
||||||
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = m3u8Url;
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
|
// Other browsers use HLS.js
|
||||||
|
else if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls({
|
||||||
|
autoStartLoad: true,
|
||||||
|
startPosition: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.loadSource(m3u8Url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hlsRef.current = hls;
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch(err => console.error('Autoplay failed:', err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
|
console.error('HLS Error:', data);
|
||||||
|
setError(data.message || 'HLS streaming error');
|
||||||
|
onError(data);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError('HLS streaming not supported in this browser');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Video initialization error:', err);
|
||||||
|
setError(err.message);
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [m3u8Url, autoplay, onError]);
|
||||||
|
|
||||||
|
// Track video events
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
onTimeUpdate(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setDuration(video.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
onEnded();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
video.addEventListener('play', handlePlay);
|
||||||
|
video.addEventListener('pause', handlePause);
|
||||||
|
video.addEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
video.removeEventListener('play', handlePlay);
|
||||||
|
video.removeEventListener('pause', handlePause);
|
||||||
|
video.removeEventListener('ended', handleEnded);
|
||||||
|
};
|
||||||
|
}, [onTimeUpdate, onEnded]);
|
||||||
|
|
||||||
|
// Control methods
|
||||||
|
const play = useCallback(() => {
|
||||||
|
videoRef.current?.play().catch(err => console.error('Play error:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seek = useCallback((time) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = time;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoRef,
|
||||||
|
isReady,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
error,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File 2: hooks/useChapterTracking.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useMemo, useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for tracking which chapter is currently active
|
||||||
|
* based on video's currentTime
|
||||||
|
*/
|
||||||
|
export function useChapterTracking({
|
||||||
|
chapters = [],
|
||||||
|
currentTime = 0,
|
||||||
|
onChapterChange = () => {},
|
||||||
|
} = {}) {
|
||||||
|
const [activeChapterId, setActiveChapterId] = useState(null);
|
||||||
|
const [completedChapters, setCompletedChapters] = useState([]);
|
||||||
|
|
||||||
|
// Find active chapter from currentTime
|
||||||
|
const activeChapter = useMemo(() => {
|
||||||
|
return chapters.find(
|
||||||
|
ch => currentTime >= ch.startTime && currentTime < ch.endTime
|
||||||
|
) || null;
|
||||||
|
}, [chapters, currentTime]);
|
||||||
|
|
||||||
|
// Detect chapter changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeChapter?.id !== activeChapterId) {
|
||||||
|
setActiveChapterId(activeChapter?.id || null);
|
||||||
|
if (activeChapter) {
|
||||||
|
onChapterChange(activeChapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeChapter, activeChapterId, onChapterChange]);
|
||||||
|
|
||||||
|
// Track completed chapters
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeChapter?.id && !completedChapters.includes(activeChapter.id)) {
|
||||||
|
// Mark chapter as visited (not necessarily completed)
|
||||||
|
setCompletedChapters(prev => [...prev, activeChapter.id]);
|
||||||
|
}
|
||||||
|
}, [activeChapter?.id, completedChapters]);
|
||||||
|
|
||||||
|
// Calculate current chapter progress
|
||||||
|
const chapterProgress = useMemo(() => {
|
||||||
|
if (!activeChapter) return 0;
|
||||||
|
|
||||||
|
const chapterDuration = activeChapter.endTime - activeChapter.startTime;
|
||||||
|
const timeInChapter = currentTime - activeChapter.startTime;
|
||||||
|
return Math.round((timeInChapter / chapterDuration) * 100);
|
||||||
|
}, [activeChapter, currentTime]);
|
||||||
|
|
||||||
|
// Get overall video progress
|
||||||
|
const overallProgress = useMemo(() => {
|
||||||
|
if (!chapters.length) return 0;
|
||||||
|
const lastChapter = chapters[chapters.length - 1];
|
||||||
|
return Math.round((currentTime / lastChapter.endTime) * 100);
|
||||||
|
}, [chapters, currentTime]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeChapter,
|
||||||
|
activeChapterId,
|
||||||
|
chapterProgress, // 0-100 within current chapter
|
||||||
|
overallProgress, // 0-100 for entire video
|
||||||
|
completedChapters, // Array of visited chapter IDs
|
||||||
|
isVideoComplete: overallProgress >= 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File 3: components/AdiloVideoPlayer.jsx
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useAdiloPlayer } from '@/hooks/useAdiloPlayer';
|
||||||
|
import { useChapterTracking } from '@/hooks/useChapterTracking';
|
||||||
|
import ChapterNavigation from './ChapterNavigation';
|
||||||
|
import styles from './AdiloVideoPlayer.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Adilo video player component with chapter support
|
||||||
|
*/
|
||||||
|
export default function AdiloVideoPlayer({
|
||||||
|
m3u8Url,
|
||||||
|
videoId,
|
||||||
|
chapters = [],
|
||||||
|
autoplay = false,
|
||||||
|
showChapters = true,
|
||||||
|
onVideoComplete = () => {},
|
||||||
|
onChapterChange = () => {},
|
||||||
|
onProgressUpdate = () => {},
|
||||||
|
}) {
|
||||||
|
const [lastSaveTime, setLastSaveTime] = useState(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
videoRef,
|
||||||
|
isReady,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
error,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
} = useAdiloPlayer({
|
||||||
|
m3u8Url,
|
||||||
|
autoplay,
|
||||||
|
onTimeUpdate: handleTimeUpdate,
|
||||||
|
onEnded: handleVideoEnded,
|
||||||
|
onError: (err) => console.error('Player error:', err),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeChapter,
|
||||||
|
activeChapterId,
|
||||||
|
chapterProgress,
|
||||||
|
overallProgress,
|
||||||
|
completedChapters,
|
||||||
|
isVideoComplete,
|
||||||
|
} = useChapterTracking({
|
||||||
|
chapters,
|
||||||
|
currentTime,
|
||||||
|
onChapterChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save progress periodically (every 5 seconds)
|
||||||
|
function handleTimeUpdate(time) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastSaveTime > 5000) {
|
||||||
|
onProgressUpdate({
|
||||||
|
videoId,
|
||||||
|
currentTime: time,
|
||||||
|
duration,
|
||||||
|
progress: overallProgress,
|
||||||
|
activeChapterId,
|
||||||
|
completedChapters,
|
||||||
|
});
|
||||||
|
setLastSaveTime(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVideoEnded() {
|
||||||
|
onVideoComplete({
|
||||||
|
videoId,
|
||||||
|
completedChapters,
|
||||||
|
totalWatched: duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChapterClick = useCallback((startTime) => {
|
||||||
|
seek(startTime);
|
||||||
|
play();
|
||||||
|
}, [seek, play]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Main Video Player */}
|
||||||
|
<div className={styles.playerWrapper}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className={styles.video}
|
||||||
|
controls
|
||||||
|
controlsList="nodownload"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading Indicator */}
|
||||||
|
{!isReady && (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<p>Loading video...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<p>⚠️ Error: {error}</p>
|
||||||
|
<p className={styles.errorSmall}>
|
||||||
|
Make sure the M3U8 URL is valid and accessible
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className={styles.progressContainer}>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
{chapters.map((chapter, idx) => (
|
||||||
|
<div
|
||||||
|
key={chapter.id}
|
||||||
|
className={`${styles.progressSegment} ${
|
||||||
|
completedChapters.includes(chapter.id) ? styles.completed : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
flex: chapter.endTime - chapter.startTime,
|
||||||
|
opacity: activeChapterId === chapter.id ? 1 : 0.7,
|
||||||
|
}}
|
||||||
|
onClick={() => handleChapterClick(chapter.startTime)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.timeInfo}>
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapter Navigation */}
|
||||||
|
{showChapters && (
|
||||||
|
<ChapterNavigation
|
||||||
|
chapters={chapters}
|
||||||
|
activeChapterId={activeChapterId}
|
||||||
|
currentTime={currentTime}
|
||||||
|
completedChapters={completedChapters}
|
||||||
|
onChapterClick={handleChapterClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Info */}
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<span>Playing: {activeChapter?.title || 'Video'}</span>
|
||||||
|
<span className={styles.progress}>{overallProgress}% watched</span>
|
||||||
|
{isVideoComplete && <span className={styles.complete}>✓ Completed</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function
|
||||||
|
function formatTime(seconds) {
|
||||||
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File 4: components/ChapterNavigation.jsx
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './ChapterNavigation.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter navigation sidebar component
|
||||||
|
*/
|
||||||
|
export default function ChapterNavigation({
|
||||||
|
chapters = [],
|
||||||
|
activeChapterId,
|
||||||
|
currentTime = 0,
|
||||||
|
completedChapters = [],
|
||||||
|
onChapterClick = () => {},
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<h3 className={styles.title}>Chapters</h3>
|
||||||
|
|
||||||
|
<div className={styles.chaptersList}>
|
||||||
|
{chapters.map((chapter) => {
|
||||||
|
const isActive = chapter.id === activeChapterId;
|
||||||
|
const isCompleted = completedChapters.includes(chapter.id);
|
||||||
|
const timeRemaining = chapter.endTime - currentTime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={chapter.id}
|
||||||
|
className={`${styles.chapterItem} ${
|
||||||
|
isActive ? styles.active : ''
|
||||||
|
} ${isCompleted ? styles.completed : ''}`}
|
||||||
|
onClick={() => onChapterClick(chapter.startTime)}
|
||||||
|
title={chapter.description || chapter.title}
|
||||||
|
>
|
||||||
|
<div className={styles.time}>
|
||||||
|
{formatTime(chapter.startTime)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.title}>{chapter.title}</div>
|
||||||
|
{chapter.description && (
|
||||||
|
<p className={styles.description}>{chapter.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<span className={styles.badge}>✓</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File 5: services/progressService.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.VITE_SUPABASE_URL,
|
||||||
|
process.env.VITE_SUPABASE_ANON_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save video progress to Supabase
|
||||||
|
*/
|
||||||
|
export async function saveProgress(userId, videoId, currentTime, completedChapters) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('video_progress')
|
||||||
|
.upsert({
|
||||||
|
user_id: userId,
|
||||||
|
video_id: videoId,
|
||||||
|
last_position: Math.round(currentTime),
|
||||||
|
completed_chapters: completedChapters,
|
||||||
|
updated_at: new Date(),
|
||||||
|
}, {
|
||||||
|
onConflict: 'user_id,video_id'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving progress:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's last position for a video
|
||||||
|
*/
|
||||||
|
export async function getLastPosition(userId, videoId) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('video_progress')
|
||||||
|
.select('last_position, completed_chapters')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('video_id', videoId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error && error.code !== 'PGRST116') throw error; // 116 = no rows
|
||||||
|
return data || { last_position: 0, completed_chapters: [] };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching progress:', error);
|
||||||
|
return { last_position: 0, completed_chapters: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark video as completed
|
||||||
|
*/
|
||||||
|
export async function markVideoComplete(userId, videoId) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('video_progress')
|
||||||
|
.update({
|
||||||
|
is_completed: true,
|
||||||
|
completed_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('video_id', videoId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking complete:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video analytics
|
||||||
|
*/
|
||||||
|
export async function getVideoAnalytics(userId, videoId) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('video_progress')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('video_id', videoId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error && error.code !== 'PGRST116') throw error;
|
||||||
|
return data || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching analytics:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File 6: styles/AdiloVideoPlayer.module.css
|
||||||
|
|
||||||
|
```css
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerWrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(220, 38, 38, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorSmall {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressContainer {
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
height: 6px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressSegment {
|
||||||
|
flex: 1;
|
||||||
|
background: #0ea5e9;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressSegment.completed {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeInfo {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBar {
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerWrapper {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressContainer {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBar {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready to start? Copy these files into your project and follow the implementation plan!**
|
||||||
557
adilo-player-impl-plan.md
Normal file
557
adilo-player-impl-plan.md
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
# Adilo Custom Video Player with Chapter System - Implementation Plan
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Build a React video player component that uses Adilo's M3U8 streaming URL with custom chapter navigation system for LearnHub LMS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components Structure
|
||||||
|
```
|
||||||
|
VideoLesson/
|
||||||
|
├── AdiloVideoPlayer.jsx (Main player component)
|
||||||
|
├── ChapterNavigation.jsx (Chapter sidebar/timeline)
|
||||||
|
├── VideoControls.jsx (Custom controls - optional)
|
||||||
|
└── hooks/
|
||||||
|
├── useAdiloPlayer.js (HLS player logic)
|
||||||
|
└── useChapterTracking.js (Chapter progress tracking)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
```
|
||||||
|
Adilo M3U8 URL
|
||||||
|
↓
|
||||||
|
HLS.js (streaming)
|
||||||
|
↓
|
||||||
|
HTML5 <video> element
|
||||||
|
↓
|
||||||
|
currentTime tracking
|
||||||
|
↓
|
||||||
|
Chapter UI sync
|
||||||
|
↓
|
||||||
|
Supabase (save progress)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-Step Implementation
|
||||||
|
|
||||||
|
### PHASE 1: Dependencies Setup
|
||||||
|
|
||||||
|
**Install required packages:**
|
||||||
|
```bash
|
||||||
|
npm install hls.js
|
||||||
|
npm install @supabase/supabase-js # For progress tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
**No additional UI library needed** - use native HTML5 video + your own CSS for chapters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 2: Core Hook - useAdiloPlayer
|
||||||
|
|
||||||
|
**File: `hooks/useAdiloPlayer.js`**
|
||||||
|
|
||||||
|
**Purpose:** Handle HLS streaming with HLS.js library
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Initialize HLS instance
|
||||||
|
- Load M3U8 URL
|
||||||
|
- Handle browser compatibility (Safari native HLS vs HLS.js)
|
||||||
|
- Expose video element ref for external control
|
||||||
|
- Emit events (play, pause, ended, timeupdate)
|
||||||
|
|
||||||
|
**Function signature:**
|
||||||
|
```javascript
|
||||||
|
const {
|
||||||
|
videoRef,
|
||||||
|
isReady,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
error
|
||||||
|
} = useAdiloPlayer({
|
||||||
|
m3u8Url: string,
|
||||||
|
autoplay: boolean,
|
||||||
|
onTimeUpdate: (time: number) => void,
|
||||||
|
onEnded: () => void,
|
||||||
|
onError: (error) => void
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
- Auto-dispose HLS instance on unmount
|
||||||
|
- Handle loading states
|
||||||
|
- Error boundary for failed streams
|
||||||
|
- Track play/pause states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 3: Core Hook - useChapterTracking
|
||||||
|
|
||||||
|
**File: `hooks/useChapterTracking.js`**
|
||||||
|
|
||||||
|
**Purpose:** Track which chapter user is currently viewing
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Determine active chapter from currentTime
|
||||||
|
- Calculate chapter progress percentage
|
||||||
|
- Detect chapter transitions
|
||||||
|
- Export chapter completion data
|
||||||
|
|
||||||
|
**Function signature:**
|
||||||
|
```javascript
|
||||||
|
const {
|
||||||
|
activeChapter,
|
||||||
|
activeChapterId,
|
||||||
|
chapterProgress, // 0-100%
|
||||||
|
completedChapters,
|
||||||
|
chapterTimeline // for progress bar
|
||||||
|
} = useChapterTracking({
|
||||||
|
chapters: Chapter[],
|
||||||
|
currentTime: number,
|
||||||
|
onChapterChange: (chapter) => void
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chapter object structure:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
startTime: number, // in seconds
|
||||||
|
endTime: number, // in seconds
|
||||||
|
title: string,
|
||||||
|
description?: string, // optional
|
||||||
|
thumbnail?: string // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 4: Main Component - AdiloVideoPlayer
|
||||||
|
|
||||||
|
**File: `components/AdiloVideoPlayer.jsx`**
|
||||||
|
|
||||||
|
**Purpose:** Main video player component that combines HLS streaming + chapter tracking
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
m3u8Url: string, // From Adilo dashboard
|
||||||
|
videoId: string, // For database tracking
|
||||||
|
chapters: Chapter[], // Your chapter data
|
||||||
|
autoplay: boolean, // Default: false
|
||||||
|
showChapters: boolean, // Default: true
|
||||||
|
onVideoComplete: (data) => void, // Callback when video ends
|
||||||
|
onChapterChange: (chapter) => void,
|
||||||
|
onProgressUpdate: (progress) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component structure:**
|
||||||
|
```jsx
|
||||||
|
<div className="adilo-player">
|
||||||
|
{/* Video container */}
|
||||||
|
<div className="video-container">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
controls
|
||||||
|
controlsList="nodownload"
|
||||||
|
/>
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{!isReady && <LoadingSpinner />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapter Navigation */}
|
||||||
|
{showChapters && (
|
||||||
|
<ChapterNavigation
|
||||||
|
chapters={chapters}
|
||||||
|
activeChapterId={activeChapterId}
|
||||||
|
currentTime={currentTime}
|
||||||
|
onChapterClick={jumpToChapter}
|
||||||
|
completedChapters={completedChapters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar (optional) */}
|
||||||
|
<ProgressBar
|
||||||
|
chapters={chapters}
|
||||||
|
currentTime={currentTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key methods:**
|
||||||
|
- `jumpToChapter(startTime)` - Seek to chapter
|
||||||
|
- `play()` / `pause()` - Control playback
|
||||||
|
- `getCurrentProgress()` - Get session progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 5: Chapter Navigation Component
|
||||||
|
|
||||||
|
**File: `components/ChapterNavigation.jsx`**
|
||||||
|
|
||||||
|
**Purpose:** Display chapters as sidebar/timeline with click-to-jump
|
||||||
|
|
||||||
|
**Layout options:**
|
||||||
|
1. **Sidebar** - Vertical list on side (desktop)
|
||||||
|
2. **Horizontal** - Timeline below video (mobile)
|
||||||
|
3. **Collapsible** - Toggle on mobile
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Show current/upcoming chapters
|
||||||
|
- Highlight active chapter
|
||||||
|
- Show time remaining for current chapter
|
||||||
|
- Progress indicators
|
||||||
|
- Drag-to-seek on timeline (optional)
|
||||||
|
|
||||||
|
**Chapter item structure:**
|
||||||
|
```jsx
|
||||||
|
<div className="chapter-item">
|
||||||
|
<div className="chapter-time">{formatTime(startTime)}</div>
|
||||||
|
<div className="chapter-title">{title}</div>
|
||||||
|
<div className="chapter-progress">{progressBar}</div>
|
||||||
|
<button onClick={() => jumpToChapter(startTime)}>
|
||||||
|
Jump to Chapter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 6: Supabase Integration (Optional)
|
||||||
|
|
||||||
|
**File: `services/progressService.js`**
|
||||||
|
|
||||||
|
**Purpose:** Save video progress to database
|
||||||
|
|
||||||
|
**Database table structure:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE video_progress (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
video_id uuid NOT NULL,
|
||||||
|
last_position int, -- seconds
|
||||||
|
completed_chapters text[], -- array of chapter IDs
|
||||||
|
watched_percentage int, -- 0-100
|
||||||
|
is_completed boolean,
|
||||||
|
completed_at timestamp,
|
||||||
|
created_at timestamp,
|
||||||
|
updated_at timestamp,
|
||||||
|
UNIQUE(user_id, video_id)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Functions to implement:**
|
||||||
|
```javascript
|
||||||
|
// Save current progress
|
||||||
|
saveProgress(userId, videoId, currentTime, completedChapters)
|
||||||
|
|
||||||
|
// Resume from last position
|
||||||
|
getLastPosition(userId, videoId)
|
||||||
|
|
||||||
|
// Mark video as complete
|
||||||
|
markVideoComplete(userId, videoId)
|
||||||
|
|
||||||
|
// Get completion analytics
|
||||||
|
getVideoAnalytics(userId, videoId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 7: Styling
|
||||||
|
|
||||||
|
**File: `styles/AdiloVideoPlayer.module.css` or your preferred CSS approach**
|
||||||
|
|
||||||
|
**Key styles needed:**
|
||||||
|
```css
|
||||||
|
/* Video container */
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chapter sidebar */
|
||||||
|
.chapters-sidebar {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 16px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item.active {
|
||||||
|
background: #e0f2fe;
|
||||||
|
border-left: 4px solid #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-time {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.progress-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
height: 4px;
|
||||||
|
background: #e5e5e5;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-segment {
|
||||||
|
background: #0ea5e9;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-segment.completed {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chapters-sidebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Setup Phase
|
||||||
|
- [ ] Install dependencies (hls.js, @supabase/supabase-js)
|
||||||
|
- [ ] Set up folder structure
|
||||||
|
- [ ] Configure Supabase client (if using progress tracking)
|
||||||
|
|
||||||
|
### Core Development
|
||||||
|
- [ ] Implement `useAdiloPlayer` hook
|
||||||
|
- [ ] Implement `useChapterTracking` hook
|
||||||
|
- [ ] Create `AdiloVideoPlayer` component
|
||||||
|
- [ ] Create `ChapterNavigation` component
|
||||||
|
- [ ] Add styling/CSS
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- [ ] Implement Supabase progress service
|
||||||
|
- [ ] Add error handling & loading states
|
||||||
|
- [ ] Add accessibility features (ARIA labels, keyboard navigation)
|
||||||
|
- [ ] Test HLS streaming on different browsers
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Test video playback (Chrome, Firefox, Safari, mobile)
|
||||||
|
- [ ] Test chapter navigation
|
||||||
|
- [ ] Test progress saving to Supabase
|
||||||
|
- [ ] Test responsive design
|
||||||
|
- [ ] Test error scenarios (bad M3U8 URL, network issues)
|
||||||
|
|
||||||
|
### Optional Enhancements
|
||||||
|
- [ ] Add playback speed control
|
||||||
|
- [ ] Add quality selector (if HLS variants available)
|
||||||
|
- [ ] Add full-screen mode
|
||||||
|
- [ ] Add picture-in-picture
|
||||||
|
- [ ] Add watch history
|
||||||
|
- [ ] Add completion badges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Example Template
|
||||||
|
|
||||||
|
### Main Usage in LearnHub
|
||||||
|
```jsx
|
||||||
|
import AdiloVideoPlayer from '@/components/AdiloVideoPlayer';
|
||||||
|
|
||||||
|
function LessonPage({ lessonId }) {
|
||||||
|
const [lesson, setLesson] = useState(null);
|
||||||
|
const [userProgress, setUserProgress] = useState(null);
|
||||||
|
const user = useAuth().user;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch lesson with chapters
|
||||||
|
fetchLessonData(lessonId).then(setLesson);
|
||||||
|
|
||||||
|
// Get user's last progress
|
||||||
|
getLastPosition(user.id, lessonId).then(setUserProgress);
|
||||||
|
}, [lessonId]);
|
||||||
|
|
||||||
|
const handleChapterChange = (chapter) => {
|
||||||
|
console.log(`Chapter changed: ${chapter.title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoComplete = (data) => {
|
||||||
|
// Mark as complete in Supabase
|
||||||
|
markVideoComplete(user.id, lessonId);
|
||||||
|
|
||||||
|
// Show completion message
|
||||||
|
toast.success('Lesson completed! 🎉');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressUpdate = (progress) => {
|
||||||
|
// Save progress every 10 seconds
|
||||||
|
if (progress.currentTime % 10 === 0) {
|
||||||
|
saveProgress(user.id, lessonId, progress.currentTime, progress.completedChapters);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!lesson) return <LoadingPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lesson-container">
|
||||||
|
<h1>{lesson.title}</h1>
|
||||||
|
|
||||||
|
<AdiloVideoPlayer
|
||||||
|
m3u8Url={lesson.m3u8Url}
|
||||||
|
videoId={lesson.id}
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
autoplay={false}
|
||||||
|
showChapters={true}
|
||||||
|
onChapterChange={handleChapterChange}
|
||||||
|
onVideoComplete={handleVideoComplete}
|
||||||
|
onProgressUpdate={handleProgressUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="lesson-content">
|
||||||
|
<h2>Lesson Details</h2>
|
||||||
|
<p>{lesson.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LessonPage;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
### Video URL Storage
|
||||||
|
Store the M3U8 URL in your Supabase `videos` or `lessons` table:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE lessons (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
title text,
|
||||||
|
description text,
|
||||||
|
m3u8_url text, -- Store the Adilo M3U8 URL here
|
||||||
|
chapters jsonb, -- Store chapters as JSON array
|
||||||
|
created_at timestamp
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
- ✅ **Don't expose M3U8 URL in frontend code** - fetch from backend
|
||||||
|
- ✅ **Validate M3U8 URLs** - only allow Adilo domains
|
||||||
|
- ✅ **Use CORS headers** - ensure Adilo allows cross-origin requests
|
||||||
|
- ✅ **Log access** - track who watches which videos
|
||||||
|
|
||||||
|
### Browser Compatibility
|
||||||
|
- ✅ **Chrome/Edge**: HLS.js library
|
||||||
|
- ✅ **Firefox**: HLS.js library
|
||||||
|
- ✅ **Safari**: Native HLS support (no library needed)
|
||||||
|
- ✅ **Mobile browsers**: Auto-detects capability
|
||||||
|
|
||||||
|
### Performance Tips
|
||||||
|
- 🚀 Lazy load chapter data
|
||||||
|
- 🚀 Debounce progress updates
|
||||||
|
- 🚀 Memoize chapter calculations
|
||||||
|
- 🚀 Use video preload="metadata"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls to Avoid
|
||||||
|
|
||||||
|
1. **❌ Don't forget to dispose HLS instance** - Memory leak
|
||||||
|
- ✅ Do: Clean up in useEffect return
|
||||||
|
|
||||||
|
2. **❌ Don't update state on every timeupdate** - Performance issue
|
||||||
|
- ✅ Do: Debounce or throttle updates
|
||||||
|
|
||||||
|
3. **❌ Don't hardcode M3U8 URLs in component** - Security issue
|
||||||
|
- ✅ Do: Fetch from backend API
|
||||||
|
|
||||||
|
4. **❌ Don't assume HLS.js works everywhere** - Safari native support exists
|
||||||
|
- ✅ Do: Check `Hls.isSupported()` and fallback
|
||||||
|
|
||||||
|
5. **❌ Don't forget CORS headers** - Cross-origin requests fail
|
||||||
|
- ✅ Do: Verify Adilo allows your domain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dev dependencies
|
||||||
|
npm install --save-dev @testing-library/react @testing-library/jest-dom
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Check bundle size
|
||||||
|
npm run analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Get M3U8 URL** from Adilo dashboard ✅ (You found it!)
|
||||||
|
2. **Store URL** in your Supabase lessons table
|
||||||
|
3. **Create the hooks** (start with `useAdiloPlayer`)
|
||||||
|
4. **Build the component** (AdiloVideoPlayer)
|
||||||
|
5. **Integrate chapters** (ChapterNavigation)
|
||||||
|
6. **Add progress tracking** (Supabase integration)
|
||||||
|
7. **Style and polish** (CSS/responsive design)
|
||||||
|
8. **Test thoroughly** (all browsers, mobile, edge cases)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── AdiloVideoPlayer.jsx
|
||||||
|
│ ├── ChapterNavigation.jsx
|
||||||
|
│ └── ProgressBar.jsx
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAdiloPlayer.js
|
||||||
|
│ └── useChapterTracking.js
|
||||||
|
├── services/
|
||||||
|
│ ├── progressService.js
|
||||||
|
│ └── adiloService.js
|
||||||
|
├── styles/
|
||||||
|
│ └── AdiloVideoPlayer.module.css
|
||||||
|
└── types/
|
||||||
|
└── video.types.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready to implement? Start with Phase 1 & 2 (setup + useAdiloPlayer hook). Let me know if you need help with any specific phase!**
|
||||||
55
bypass-schema-cache.ts
Normal file
55
bypass-schema-cache.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Temporary workaround to bypass PostgREST schema cache
|
||||||
|
// Add this function to IntegrasiTab.tsx
|
||||||
|
|
||||||
|
async function saveGoogleServiceAccountJSON(supabase: any, jsonValue: string) {
|
||||||
|
try {
|
||||||
|
// Use raw SQL to bypass PostgREST schema cache
|
||||||
|
const { data, error } = await supabase.rpc('exec', {
|
||||||
|
sql: `
|
||||||
|
UPDATE platform_settings
|
||||||
|
SET google_service_account_json = $1
|
||||||
|
WHERE id = (SELECT id FROM platform_settings LIMIT 1)
|
||||||
|
`,
|
||||||
|
params: [jsonValue]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving service account:', error);
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: Create a temporary edge function to handle the save
|
||||||
|
// Add to supabase/functions/save-service-account/index.ts
|
||||||
|
|
||||||
|
/*
|
||||||
|
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
serve(async (req: Request) => {
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
const { json_value } = await req.json();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('platform_settings')
|
||||||
|
.update({ google_service_account_json: json_value })
|
||||||
|
.neq('id', '');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, error: error.message }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{ headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
*/
|
||||||
17
check-template.sql
Normal file
17
check-template.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Check if the email template exists and has content
|
||||||
|
SELECT
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
is_active,
|
||||||
|
email_subject,
|
||||||
|
LENGTH(email_body_html) as html_length,
|
||||||
|
SUBSTRING(email_body_html, 1, 500) as html_preview,
|
||||||
|
CASE
|
||||||
|
WHEN email_body_html IS NULL THEN 'NULL - empty template'
|
||||||
|
WHEN LENGTH(email_body_html) < 100 THEN 'TOO SHORT - template incomplete'
|
||||||
|
WHEN email_body_html LIKE '%<html>%' THEN 'Has HTML tag'
|
||||||
|
WHEN email_body_html LIKE '%---%' THEN 'Has YAML delimiters'
|
||||||
|
ELSE 'Unknown format'
|
||||||
|
END as template_status
|
||||||
|
FROM notification_templates
|
||||||
|
WHERE key = 'auth_email_verification';
|
||||||
14
check_email_logs.sql
Normal file
14
check_email_logs.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Check recent notification logs for auth_email_verification
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
email,
|
||||||
|
notification_type,
|
||||||
|
status,
|
||||||
|
provider,
|
||||||
|
error_message,
|
||||||
|
created_at
|
||||||
|
FROM notification_logs
|
||||||
|
WHERE notification_type = 'auth_email_verification'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 5;
|
||||||
20
check_template.sh
Executable file
20
check_template.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Test query to check if template exists and what's in the table
|
||||||
|
# Run this in your Supabase SQL editor or via psql
|
||||||
|
|
||||||
|
echo "=== Check if template exists ==="
|
||||||
|
cat << 'SQL'
|
||||||
|
-- Check if template exists
|
||||||
|
SELECT key, name, is_active
|
||||||
|
FROM notification_templates
|
||||||
|
WHERE key = 'auth_email_verification';
|
||||||
|
|
||||||
|
-- Check all templates
|
||||||
|
SELECT key, name, is_active
|
||||||
|
FROM notification_templates
|
||||||
|
ORDER BY key;
|
||||||
|
|
||||||
|
-- Check table structure
|
||||||
|
\d notification_templates;
|
||||||
|
SQL
|
||||||
30
cleanup-user.sql
Normal file
30
cleanup-user.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Clean Up User from Supabase Auth Completely
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- NOTE: You CANNOT just DELETE from auth.users
|
||||||
|
-- Supabase keeps deleted users in a recycle bin
|
||||||
|
|
||||||
|
-- To completely remove a user, you need to use Supabase Auth Admin API
|
||||||
|
-- OR use a cascade delete from a linked table
|
||||||
|
|
||||||
|
-- Option 1: Delete via cascade (if you have foreign keys)
|
||||||
|
-- This works because auth_otps has ON DELETE CASCADE
|
||||||
|
DELETE FROM auth.users WHERE email = 'your@email.com';
|
||||||
|
|
||||||
|
-- Option 2: Check if user still exists in recycle bin
|
||||||
|
SELECT id, email, deleted_at
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'your@email.com';
|
||||||
|
|
||||||
|
-- If you see deleted_at IS NOT NULL, the user is in recycle bin
|
||||||
|
|
||||||
|
-- To permanently delete from recycle bin, you need to:
|
||||||
|
-- 1. Go to Supabase Dashboard → Authentication → Users
|
||||||
|
-- 2. Find the user
|
||||||
|
-- 3. Click "Permanently delete"
|
||||||
|
|
||||||
|
-- OR use the Auth Admin API from an edge function:
|
||||||
|
/*
|
||||||
|
const { data, error } = await supabase.auth.admin.deleteUser(userId);
|
||||||
|
*/
|
||||||
1497
collaborative-webinar-wallet-implementation.md
Normal file
1497
collaborative-webinar-wallet-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
91
debug-email.sh
Executable file
91
debug-email.sh
Executable file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Test script to debug email sending issue
|
||||||
|
# Run this after registering a user
|
||||||
|
|
||||||
|
echo "🔍 OTP Email Debug Script"
|
||||||
|
echo "==========================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SUPABASE_URL="https://lovable.backoffice.biz.id"
|
||||||
|
SERVICE_KEY="YOUR_SERVICE_ROLE_KEY_HERE" # Replace with actual service role key
|
||||||
|
|
||||||
|
echo "1. Checking recent OTP records..."
|
||||||
|
echo "Run this in Supabase SQL Editor:"
|
||||||
|
echo ""
|
||||||
|
echo "SELECT"
|
||||||
|
echo " id,"
|
||||||
|
echo " user_id,"
|
||||||
|
echo " email,"
|
||||||
|
echo " otp_code,"
|
||||||
|
echo " expires_at,"
|
||||||
|
echo " used_at,"
|
||||||
|
echo " created_at"
|
||||||
|
echo "FROM auth_otps"
|
||||||
|
echo "ORDER BY created_at DESC"
|
||||||
|
echo "LIMIT 1;"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "2. Checking notification logs..."
|
||||||
|
echo "Run this in Supabase SQL Editor:"
|
||||||
|
echo ""
|
||||||
|
echo "SELECT"
|
||||||
|
echo " id,"
|
||||||
|
echo " user_id,"
|
||||||
|
echo " email,"
|
||||||
|
echo " notification_type,"
|
||||||
|
echo " status,"
|
||||||
|
echo " provider,"
|
||||||
|
echo " error_message,"
|
||||||
|
echo " created_at"
|
||||||
|
echo "FROM notification_logs"
|
||||||
|
echo "WHERE notification_type = 'auth_email_verification'"
|
||||||
|
echo "ORDER BY created_at DESC"
|
||||||
|
echo "LIMIT 5;"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "3. Checking notification settings..."
|
||||||
|
echo "Run this in Supabase SQL Editor:"
|
||||||
|
echo ""
|
||||||
|
echo "SELECT"
|
||||||
|
echo " platform_name,"
|
||||||
|
echo " from_name,"
|
||||||
|
echo " from_email,"
|
||||||
|
echo " api_token,"
|
||||||
|
echo " mailketing_api_token"
|
||||||
|
echo "FROM notification_settings"
|
||||||
|
echo "LIMIT 1;"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "4. Checking email template..."
|
||||||
|
echo "Run this in Supabase SQL Editor:"
|
||||||
|
echo ""
|
||||||
|
echo "SELECT"
|
||||||
|
echo " key,"
|
||||||
|
echo " name,"
|
||||||
|
echo " is_active,"
|
||||||
|
echo " email_subject,"
|
||||||
|
echo " LEFT(email_body_html, 200) as email_preview"
|
||||||
|
echo "FROM notification_templates"
|
||||||
|
echo "WHERE key = 'auth_email_verification';"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "5. Testing email sending manually..."
|
||||||
|
echo "Replace USER_ID and EMAIL with actual values from step 1, then run:"
|
||||||
|
echo ""
|
||||||
|
echo "curl -X POST ${SUPABASE_URL}/functions/v1/send-auth-otp \\"
|
||||||
|
echo " -H \"Authorization: Bearer ${SERVICE_KEY}\" \\"
|
||||||
|
echo " -H \"Content-Type: application/json\" \\"
|
||||||
|
echo " -d '{"
|
||||||
|
echo " \"user_id\": \"USER_UUID\","
|
||||||
|
echo " \"email\": \"your@email.com\""
|
||||||
|
echo " }'"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "6. Common issues to check:"
|
||||||
|
echo " ✓ from_email is not 'noreply@example.com' (set real domain)"
|
||||||
|
echo " ✓ api_token or mailketing_api_token is set"
|
||||||
|
echo " ✓ Email template is_active = true"
|
||||||
|
echo " ✓ Mailketing API is accessible from Supabase server"
|
||||||
|
echo " ✓ Check notification_logs.error_message for specific error"
|
||||||
|
echo ""
|
||||||
18
deploy-auth-functions.sh
Executable file
18
deploy-auth-functions.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Deploy Auth OTP Edge Functions to Self-Hosted Supabase
|
||||||
|
|
||||||
|
SUPABASE_URL="https://lovable.backoffice.biz.id"
|
||||||
|
SERVICE_ROLE_KEY="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc2NjAzNzEyMCwiZXhwIjo0OTIxNzEwNzIwLCJyb2xlIjoic2VydmljZV9yb2xlIn0.t6D9VwaukYGq4c_VbW1bkd3ZkKgldpCKRR13nN14XXc"
|
||||||
|
|
||||||
|
echo "Deploying send-auth-otp..."
|
||||||
|
|
||||||
|
curl -X POST "${SUPABASE_URL}/functions/v1/send-auth-otp" \
|
||||||
|
-H "Authorization: Bearer ${SERVICE_ROLE_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user_id":"test","email":"test@test.com"}' \
|
||||||
|
-v
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "If you see a response above, the function is deployed."
|
||||||
|
echo "If you see 404, the function needs to be deployed manually to your Supabase instance."
|
||||||
42
deploy-edge-functions.sh
Executable file
42
deploy-edge-functions.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SUPABASE_URL="https://lovable.backoffice.biz.id"
|
||||||
|
SERVICE_ROLE_KEY="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc2NjAzNzEyMCwiZXhwIjo0OTIxNzEwNzIwLCJyb2xlIjoic2VydmljZV9yb2xlIn0.t6D9VwaukYGq4c_VbW1bkd3ZkKgldpCKRR13nN14XXc"
|
||||||
|
|
||||||
|
# Function to deploy edge function
|
||||||
|
deploy_function() {
|
||||||
|
local func_name=$1
|
||||||
|
echo "Deploying $func_name..."
|
||||||
|
|
||||||
|
# Read the function content
|
||||||
|
if [ -f "supabase/functions/$func_name/index.ts" ]; then
|
||||||
|
FUNCTION_CONTENT=$(cat "supabase/functions/$func_name/index.ts")
|
||||||
|
|
||||||
|
# Create the function via API
|
||||||
|
curl -X POST "$SUPABASE_URL/rest/v1/functions" \
|
||||||
|
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||||
|
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"name\": \"$func_name\",
|
||||||
|
\"verify_jwt\": $(cat supabase/config.toml | grep -A 1 "\\[functions.$func_name\\]" | grep verify_jwt | awk '{print $3}')
|
||||||
|
}"
|
||||||
|
|
||||||
|
echo "Function $func_name created/updated"
|
||||||
|
else
|
||||||
|
echo "Function $func_name not found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deploy all functions
|
||||||
|
deploy_function "pakasir-webhook"
|
||||||
|
deploy_function "send-test-email"
|
||||||
|
deploy_function "create-meet-link" # Includes n8n test mode toggle
|
||||||
|
deploy_function "create-google-meet-event" # Direct Google Calendar API integration
|
||||||
|
deploy_function "send-consultation-reminder"
|
||||||
|
deploy_function "send-notification"
|
||||||
|
deploy_function "send-email-v2"
|
||||||
|
deploy_function "daily-reminders"
|
||||||
|
|
||||||
|
echo "Deployment complete!"
|
||||||
74
deploy-google-meet-function.sh
Executable file
74
deploy-google-meet-function.sh
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Deploy create-google-meet-event function directly
|
||||||
|
|
||||||
|
SUPABASE_URL="https://lovable.backoffice.biz.id"
|
||||||
|
SERVICE_ROLE_KEY="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc2NjAzNzEyMCwiZXhwIjo0OTIxNzEwNzIwLCJyb2xlIjoic2VydmljZV9yb2xlIn0.t6D9VwaukYGq4c_VbW1bkd3ZkKgldpCKRR13nN14XXc"
|
||||||
|
|
||||||
|
FUNCTION_NAME="create-google-meet-event"
|
||||||
|
FUNCTION_PATH="supabase/functions/$FUNCTION_NAME/index.ts"
|
||||||
|
|
||||||
|
echo "🚀 Deploying $FUNCTION_NAME..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if function file exists
|
||||||
|
if [ ! -f "$FUNCTION_PATH" ]; then
|
||||||
|
echo "❌ Error: Function file not found at $FUNCTION_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read function content
|
||||||
|
FUNCTION_CONTENT=$(cat "$FUNCTION_PATH")
|
||||||
|
|
||||||
|
echo "📄 Function file found, size: $(echo "$FUNCTION_CONTENT" | wc -c) bytes"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create/update function via Supabase Management API
|
||||||
|
echo "📤 Uploading to Supabase..."
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-X POST "$SUPABASE_URL/rest/v1/functions" \
|
||||||
|
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||||
|
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"name\": \"$FUNCTION_NAME\",
|
||||||
|
\"verify_jwt\": true
|
||||||
|
}")
|
||||||
|
|
||||||
|
# Extract status code
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
echo "Response: $RESPONSE_BODY"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
|
||||||
|
echo "✅ Function metadata created successfully"
|
||||||
|
|
||||||
|
# Now upload the actual function code
|
||||||
|
echo "📤 Uploading function code..."
|
||||||
|
CODE_RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-X PUT "$SUPABASE_URL/rest/v1/functions/$FUNCTION_NAME/body" \
|
||||||
|
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||||
|
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
--data-binary @"$FUNCTION_PATH")
|
||||||
|
|
||||||
|
CODE_HTTP=$(echo "$CODE_RESPONSE" | tail -n1)
|
||||||
|
|
||||||
|
if [ "$CODE_HTTP" = "200" ] || [ "$CODE_HTTP" = "204" ]; then
|
||||||
|
echo "✅ Function code uploaded successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Function URL: $SUPABASE_URL/functions/v1/$FUNCTION_NAME"
|
||||||
|
else
|
||||||
|
echo "❌ Failed to upload function code (HTTP $CODE_HTTP)"
|
||||||
|
echo "Response: $(echo "$CODE_RESPONSE" | sed '$d')"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ Failed to create function metadata (HTTP $HTTP_CODE)"
|
||||||
|
echo "Response: $RESPONSE_BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✨ Deployment attempt complete!"
|
||||||
24
deploy-with-env.sh
Executable file
24
deploy-with-env.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Deploy to self-hosted Supabase using environment variables
|
||||||
|
|
||||||
|
# Set Supabase environment variables
|
||||||
|
export SUPABASE_URL="https://lovable.backoffice.biz.id"
|
||||||
|
export SUPABASE_SERVICE_ROLE_KEY="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc2NjAzNzEyMCwiZXhwIjo0OTIxNzEwNzIwLCJyb2xlIjoic2VydmljZV9yb2xlIn0.t6D9VwaukYGq4c_VbW1bkd3ZkKgldpCKRR13nN14XXc"
|
||||||
|
|
||||||
|
FUNCTION_NAME="create-google-meet-event"
|
||||||
|
FUNCTION_FILE="supabase/functions/$FUNCTION_NAME/index.ts"
|
||||||
|
|
||||||
|
echo "🚀 Deploying $FUNCTION_NAME to self-hosted Supabase..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ! -f "$FUNCTION_FILE" ]; then
|
||||||
|
echo "❌ Function file not found: $FUNCTION_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📤 Deploying via Supabase CLI..."
|
||||||
|
supabase functions deploy "$FUNCTION_NAME" --project-ref "lovable-backoffice" --verify-jwt
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✨ Deployment complete!"
|
||||||
219
docs/google-calendar-edge-function-setup.md
Normal file
219
docs/google-calendar-edge-function-setup.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Google Calendar Integration with Supabase Edge Functions
|
||||||
|
|
||||||
|
This guide walks you through setting up Google Calendar integration directly in Supabase Edge Functions, without needing n8n or OAuth.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Access Hub App → Supabase Edge Function → Google Calendar API
|
||||||
|
↓
|
||||||
|
JWT Authentication
|
||||||
|
↓
|
||||||
|
Service Account JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Steps
|
||||||
|
|
||||||
|
### 1. Create Google Service Account
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Create a new project or select existing one
|
||||||
|
3. Navigate to **IAM & Admin** → **Service Accounts**
|
||||||
|
4. Click **Create Service Account**
|
||||||
|
5. Fill in details:
|
||||||
|
- Name: `access-hub-calendar`
|
||||||
|
- Description: `Service account for Access Hub calendar integration`
|
||||||
|
6. Click **Create and Continue** (skip granting roles)
|
||||||
|
7. Click **Done**
|
||||||
|
|
||||||
|
### 2. Enable Google Calendar API
|
||||||
|
|
||||||
|
1. In Google Cloud Console, go to **APIs & Services** → **Library**
|
||||||
|
2. Search for "Google Calendar API"
|
||||||
|
3. Click **Enable**
|
||||||
|
|
||||||
|
### 3. Create Service Account Key
|
||||||
|
|
||||||
|
1. Go to your service account page
|
||||||
|
2. Click the **Keys** tab
|
||||||
|
3. Click **Add Key** → **Create New Key**
|
||||||
|
4. Select **JSON** format
|
||||||
|
5. Click **Create** - download the JSON file
|
||||||
|
|
||||||
|
Keep this file secure! It contains your private key.
|
||||||
|
|
||||||
|
### 4. Share Calendar with Service Account
|
||||||
|
|
||||||
|
1. Go to [Google Calendar](https://calendar.google.com/)
|
||||||
|
2. Hover over the calendar you want to use
|
||||||
|
3. Click the **three dots (⋮)** → **Settings and sharing**
|
||||||
|
4. Scroll to **Share with specific people**
|
||||||
|
5. Click **+ Add people**
|
||||||
|
6. Enter the service account email from your JSON: `xxx@xxx.iam.gserviceaccount.com`
|
||||||
|
7. Set permissions to **Make changes to events**
|
||||||
|
8. Click **Send**
|
||||||
|
|
||||||
|
### 5. Add Database Column
|
||||||
|
|
||||||
|
Run this SQL in your Supabase SQL Editor:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE platform_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS google_service_account_json TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Deploy Edge Function
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy the new function
|
||||||
|
supabase functions deploy create-google-meet-event --verify-jwt
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the deployment script:
|
||||||
|
```bash
|
||||||
|
./deploy-edge-functions.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Configure in Admin Panel
|
||||||
|
|
||||||
|
1. Go to **Settings** → **Integrasi**
|
||||||
|
2. Find the **Google Calendar** section
|
||||||
|
3. Enter your **Calendar ID** (e.g., `your-email@gmail.com`)
|
||||||
|
4. Paste the **Service Account JSON** (entire content from the JSON file)
|
||||||
|
5. Click **Simpan Semua Pengaturan**
|
||||||
|
6. Click **Test Google Calendar Connection**
|
||||||
|
|
||||||
|
If successful, you'll see a test event created in your Google Calendar with a Google Meet link.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. Edge Function reads service account JSON
|
||||||
|
2. Creates a JWT signed with the private key
|
||||||
|
3. Exchanges JWT for an access token
|
||||||
|
4. Uses access token to call Google Calendar API
|
||||||
|
|
||||||
|
### Event Creation
|
||||||
|
|
||||||
|
When a consultation slot is confirmed:
|
||||||
|
|
||||||
|
1. `create-google-meet-event` function is called
|
||||||
|
2. Creates a Google Calendar event with Meet link
|
||||||
|
3. Returns the Meet link to be stored in the database
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
POST /functions/v1/create-google-meet-event
|
||||||
|
|
||||||
|
{
|
||||||
|
slot_id: string; // Unique slot identifier
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
start_time: string; // HH:MM:SS
|
||||||
|
end_time: string; // HH:MM:SS
|
||||||
|
client_name: string; // Client's full name
|
||||||
|
client_email: string; // Client's email
|
||||||
|
topic: string; // Consultation topic
|
||||||
|
notes?: string; // Optional notes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true;
|
||||||
|
meet_link: string; // https://meet.google.com/xxx-xxx-xxx
|
||||||
|
event_id: string; // Google Calendar event ID
|
||||||
|
html_link: string; // Link to event in Google Calendar
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test via Admin Panel
|
||||||
|
|
||||||
|
Use the **Test Google Calendar Connection** button in the Integrasi settings.
|
||||||
|
|
||||||
|
### Test via Curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-project.supabase.co/functions/v1/create-google-meet-event \
|
||||||
|
-H "Authorization: Bearer YOUR_ANON_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"slot_id": "test-123",
|
||||||
|
"date": "2025-12-25",
|
||||||
|
"start_time": "14:00:00",
|
||||||
|
"end_time": "15:00:00",
|
||||||
|
"client_name": "Test Client",
|
||||||
|
"client_email": "test@example.com",
|
||||||
|
"topic": "Test Topic"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. **Never commit** the service account JSON to version control
|
||||||
|
2. **Store securely** in database (consider encryption for production)
|
||||||
|
3. **Rotate keys** if compromised
|
||||||
|
4. **Limit permissions** to only Calendar API
|
||||||
|
5. **Use separate service accounts** for different environments
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: "Google Service Account JSON belum dikonfigurasi"
|
||||||
|
- Make sure you've saved the JSON in the admin settings
|
||||||
|
- Check the database column exists: `google_service_account_json`
|
||||||
|
|
||||||
|
### Error: 403 Forbidden
|
||||||
|
- Verify calendar is shared with service account email
|
||||||
|
- Check service account has "Make changes to events" permission
|
||||||
|
|
||||||
|
### Error: 401 Unauthorized
|
||||||
|
- Verify service account JSON is valid
|
||||||
|
- Check Calendar API is enabled in Google Cloud Console
|
||||||
|
|
||||||
|
### Error: "Failed to parse service account JSON"
|
||||||
|
- Make sure you pasted the entire JSON content
|
||||||
|
- Check for any truncation or formatting issues
|
||||||
|
|
||||||
|
### Error: "Gagal membuat event di Google Calendar"
|
||||||
|
- Check the error message for details
|
||||||
|
- Verify Calendar API is enabled
|
||||||
|
- Check service account has correct permissions
|
||||||
|
|
||||||
|
## Comparison: n8n vs Edge Function
|
||||||
|
|
||||||
|
| Feature | n8n Integration | Edge Function |
|
||||||
|
|---------|----------------|---------------|
|
||||||
|
| Setup Complexity | Medium | Low |
|
||||||
|
| OAuth Required | No (Service Account) | No (Service Account) |
|
||||||
|
| External Dependencies | n8n instance | None |
|
||||||
|
| Cost | Requires n8n hosting | Included in Supabase |
|
||||||
|
| Maintenance | n8n updates | Supabase updates |
|
||||||
|
| Performance | Extra hop | Direct API call |
|
||||||
|
| **Recommended** | For complex workflows | ✅ **For simple integrations** |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Create service account
|
||||||
|
2. ✅ Share calendar with service account
|
||||||
|
3. ✅ Run database migration
|
||||||
|
4. ✅ Deploy edge function
|
||||||
|
5. ✅ Configure in admin panel
|
||||||
|
6. ✅ Test connection
|
||||||
|
7. ✅ Integrate with consultation booking flow
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
- `supabase/functions/create-google-meet-event/index.ts` - New edge function
|
||||||
|
- `supabase/migrations/20250323_add_google_service_account.sql` - Database migration
|
||||||
|
- `src/components/admin/settings/IntegrasiTab.tsx` - Admin UI for configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Need Help?** Check the Supabase Edge Functions logs in your dashboard for detailed error messages.
|
||||||
214
docs/google-calendar-service-account-setup.md
Normal file
214
docs/google-calendar-service-account-setup.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Google Calendar Integration with Service Account
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Using a Service Account to integrate Google Calendar API without OAuth user consent.
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Create Service Account in Google Cloud Console
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Create a new project or select existing one
|
||||||
|
3. Navigate to **IAM & Admin** → **Service Accounts**
|
||||||
|
4. Click **Create Service Account**
|
||||||
|
5. Fill in details:
|
||||||
|
- Name: `access-hub-calendar`
|
||||||
|
- Description: `Service account for Access Hub calendar integration`
|
||||||
|
6. Click **Create and Continue**
|
||||||
|
7. Skip granting roles (not needed for Calendar API)
|
||||||
|
8. Click **Done**
|
||||||
|
|
||||||
|
### 2. Enable Google Calendar API
|
||||||
|
|
||||||
|
1. In Google Cloud Console, go to **APIs & Services** → **Library**
|
||||||
|
2. Search for "Google Calendar API"
|
||||||
|
3. Click on it and press **Enable**
|
||||||
|
|
||||||
|
### 3. Create Service Account Key
|
||||||
|
|
||||||
|
1. Go to your service account page
|
||||||
|
2. Click on the **Keys** tab
|
||||||
|
3. Click **Add Key** → **Create New Key**
|
||||||
|
4. Select **JSON** format
|
||||||
|
5. Click **Create** - this will download a JSON file with credentials
|
||||||
|
6. **Keep this file secure** - it contains your private key
|
||||||
|
|
||||||
|
The JSON file looks like:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "your-project-id",
|
||||||
|
"private_key_id": "...",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\n...",
|
||||||
|
"client_email": "access-hub-calendar@your-project-id.iam.gserviceaccount.com",
|
||||||
|
"client_id": "...",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Share Calendar with Service Account
|
||||||
|
|
||||||
|
1. Go to [Google Calendar](https://calendar.google.com/)
|
||||||
|
2. Find the calendar you want to use (e.g., your main calendar)
|
||||||
|
3. Click the **three dots** next to the calendar name
|
||||||
|
4. Select **Settings and sharing**
|
||||||
|
5. Scroll to **Share with specific people**
|
||||||
|
6. Click **+ Add people**
|
||||||
|
7. Enter the service account email: `access-hub-calendar@your-project-id.iam.gserviceaccount.com`
|
||||||
|
8. Set permissions to **Editor** (can make changes to events)
|
||||||
|
9. Click **Send** (ignore the email notification)
|
||||||
|
|
||||||
|
### 5. Get Calendar ID
|
||||||
|
|
||||||
|
- For your primary calendar: `your-email@gmail.com`
|
||||||
|
- For other calendars: Go to Calendar Settings → **Integrate calendar** → **Calendar ID**
|
||||||
|
|
||||||
|
## n8n Workflow Configuration
|
||||||
|
|
||||||
|
### Option A: Using Google Calendar Node
|
||||||
|
|
||||||
|
1. Add a **Google Calendar** node to your workflow
|
||||||
|
2. Select **Service Account** as authentication
|
||||||
|
3. Paste the entire Service Account JSON content
|
||||||
|
4. Select the calendar ID
|
||||||
|
5. Choose operation: **Create Event**
|
||||||
|
|
||||||
|
### Option B: Using HTTP Request Node (More Control)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In n8n Code node or HTTP Request node
|
||||||
|
|
||||||
|
const { GoogleToken } = require('gtoken');
|
||||||
|
const { google } = require('googleapis');
|
||||||
|
|
||||||
|
// Service account credentials
|
||||||
|
const serviceAccount = {
|
||||||
|
type: 'service_account',
|
||||||
|
project_id: 'your-project-id',
|
||||||
|
private_key_id: '...',
|
||||||
|
private_key: '-----BEGIN PRIVATE KEY-----\n...',
|
||||||
|
client_email: 'access-hub-calendar@your-project-id.iam.gserviceaccount.com',
|
||||||
|
client_id: '...',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create JWT client
|
||||||
|
const jwtClient = new google.auth.JWT(
|
||||||
|
serviceAccount.client_email,
|
||||||
|
null,
|
||||||
|
serviceAccount.private_key,
|
||||||
|
['https://www.googleapis.com/auth/calendar']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authorize and create event
|
||||||
|
jwtClient.authorize(async (err, tokens) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('JWT authorization error:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = google.calendar({ version: 'v3', auth: jwtClient });
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
summary: 'Konsultasi: ' + $json.topic + ' - ' + $json.client_name,
|
||||||
|
start: {
|
||||||
|
dateTime: new Date($json.date + 'T' + $json.start_time).toISOString(),
|
||||||
|
timeZone: 'Asia/Jakarta',
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: new Date($json.date + 'T' + $json.end_time).toISOString(),
|
||||||
|
timeZone: 'Asia/Jakarta',
|
||||||
|
},
|
||||||
|
description: 'Client: ' + $json.client_email + '\n\n' + $json.notes,
|
||||||
|
attendees: [
|
||||||
|
{ email: $json.client_email },
|
||||||
|
],
|
||||||
|
conferenceData: {
|
||||||
|
createRequest: {
|
||||||
|
requestId: $json.slot_id,
|
||||||
|
conferenceSolutionKey: { type: 'hangoutsMeet' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await calendar.events.insert({
|
||||||
|
calendarId: $json.calendar_id,
|
||||||
|
resource: event,
|
||||||
|
conferenceDataVersion: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
meet_link: result.data.hangoutLink,
|
||||||
|
event_id: result.data.id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating calendar event:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Incoming Webhook Payload
|
||||||
|
|
||||||
|
Your n8n webhook at `/webhook-test/create-meet` will receive:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot_id": "uuid-of-slot",
|
||||||
|
"date": "2025-12-25",
|
||||||
|
"start_time": "14:00:00",
|
||||||
|
"end_time": "15:00:00",
|
||||||
|
"client_name": "John Doe",
|
||||||
|
"client_email": "john@example.com",
|
||||||
|
"topic": "Business Consulting",
|
||||||
|
"notes": "Discuss project roadmap",
|
||||||
|
"calendar_id": "your-calendar@gmail.com",
|
||||||
|
"brand_name": "Your Brand",
|
||||||
|
"test_mode": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Response
|
||||||
|
|
||||||
|
Your n8n workflow should return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meet_link": "https://meet.google.com/abc-defg-hij",
|
||||||
|
"event_id": "event-id-from-google-calendar"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. **Never commit the service account JSON** to version control
|
||||||
|
2. Store it securely in n8n credentials
|
||||||
|
3. Rotate the key if compromised
|
||||||
|
4. Only grant minimum necessary permissions to the service account
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: 403 Forbidden
|
||||||
|
- Check if the calendar is shared with the service account email
|
||||||
|
- Verify the service account has **Editor** permissions
|
||||||
|
|
||||||
|
### Error: 401 Unauthorized
|
||||||
|
- Verify the service account JSON is correct
|
||||||
|
- Check if Calendar API is enabled in Google Cloud Console
|
||||||
|
|
||||||
|
### Error: 400 Invalid
|
||||||
|
- Check date/time format (should be ISO 8601)
|
||||||
|
- Verify calendar ID is correct
|
||||||
|
- Ensure the service account email format is correct
|
||||||
|
|
||||||
|
## Alternative: Use Google Calendar API Key (Less Secure)
|
||||||
|
|
||||||
|
If you don't want to use service accounts, you can create an API key:
|
||||||
|
|
||||||
|
1. Go to Google Cloud Console → **APIs & Services** → **Credentials**
|
||||||
|
2. Click **Create Credentials** → **API Key**
|
||||||
|
3. Restrict the key to Google Calendar API only
|
||||||
|
4. Use it with HTTP requests
|
||||||
|
|
||||||
|
However, this is **not recommended** for production as it's less secure than service accounts.
|
||||||
310
email-master-template.html
Normal file
310
email-master-template.html
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Notification</title>
|
||||||
|
<style>
|
||||||
|
/* =========================================
|
||||||
|
1. CLIENT RESETS (The Boring Stuff)
|
||||||
|
========================================= */
|
||||||
|
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||||
|
table { border-collapse: collapse !important; }
|
||||||
|
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; background-color: #FFFFFF; }
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
2. MASTER TYPOGRAPHY & VARS
|
||||||
|
========================================= */
|
||||||
|
:root {
|
||||||
|
--color-black: #000000;
|
||||||
|
--color-white: #FFFFFF;
|
||||||
|
--color-gray: #F4F4F5;
|
||||||
|
--color-success: #00A651;
|
||||||
|
--color-danger: #E11D48;
|
||||||
|
--border-thick: 2px solid #000000;
|
||||||
|
--border-thin: 1px solid #000000;
|
||||||
|
--shadow-hard: 4px 4px 0px 0px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
color: #000000;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
3. TIPTAP / DYNAMIC CONTENT POLISH
|
||||||
|
These rules automatically style raw HTML
|
||||||
|
========================================= */
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
.tiptap-content h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.tiptap-content h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 25px 0 15px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.tiptap-content p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standard Links */
|
||||||
|
.tiptap-content a {
|
||||||
|
color: #000000;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 700;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.tiptap-content ul, .tiptap-content ol {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.tiptap-content li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TABLES (Brutalist Style) */
|
||||||
|
.tiptap-content table {
|
||||||
|
width: 100%;
|
||||||
|
border: 2px solid #000;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.tiptap-content th {
|
||||||
|
background-color: #000;
|
||||||
|
color: #FFF;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
.tiptap-content td {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
font-size: 15px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
/* Zebra Striping */
|
||||||
|
.tiptap-content tr:nth-child(even) td {
|
||||||
|
background-color: #F8F8F8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BUTTONS (Class: .btn) */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #000;
|
||||||
|
color: #FFF !important;
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 2px solid #000;
|
||||||
|
box-shadow: 4px 4px 0px 0px #000000; /* Hard Shadow */
|
||||||
|
margin: 10px 0;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: 2px 2px 0px 0px #000000;
|
||||||
|
}
|
||||||
|
.btn-full { width: 100%; text-align: center; box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* CODE BLOCKS & OTP */
|
||||||
|
.tiptap-content pre {
|
||||||
|
background-color: #F4F4F5;
|
||||||
|
border: 2px solid #000;
|
||||||
|
padding: 15px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.tiptap-content code {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #E11D48; /* Highlight color for inline code */
|
||||||
|
background-color: #F4F4F5;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
/* Special OTP Style */
|
||||||
|
.otp-box {
|
||||||
|
background-color: #F4F4F5;
|
||||||
|
border: 2px dashed #000;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BLOCKQUOTES / ALERTS */
|
||||||
|
.tiptap-content blockquote {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-left: 6px solid #000;
|
||||||
|
background-color: #F9F9F9;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
/* Contextual Alerts */
|
||||||
|
.alert-success { background-color: #E6F4EA; border-left-color: #00A651; color: #005A2B; }
|
||||||
|
.alert-danger { background-color: #FFE4E6; border-left-color: #E11D48; color: #881337; }
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
4. RESPONSIVE
|
||||||
|
========================================= */
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.email-container { width: 100% !important; border-left: 0 !important; border-right: 0 !important; }
|
||||||
|
.content-padding { padding: 30px 20px !important; }
|
||||||
|
.stack-mobile { display: block !important; width: 100% !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #FFFFFF;">
|
||||||
|
|
||||||
|
<!-- 100% WRAPPER -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #FFFFFF;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 0;">
|
||||||
|
|
||||||
|
<!-- MAIN CONTAINER (600px) -->
|
||||||
|
<!-- The "Hard Box" Look -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="600" class="email-container" style="background-color: #FFFFFF; border: 2px solid #000000; width: 600px; min-width: 320px;">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="background-color: #000000; padding: 25px 40px; border-bottom: 2px solid #000000;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<!-- LOGO (White text) -->
|
||||||
|
<div style="font-family: 'Helvetica Neue', sans-serif; font-size: 24px; font-weight: 900; color: #FFFFFF; letter-spacing: -1px; text-transform: uppercase;">
|
||||||
|
BRAND<span style="color: #888;">UI</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td align="right">
|
||||||
|
<!-- Optional: Small Date or Tag -->
|
||||||
|
<div style="font-family: monospace; font-size: 12px; color: #888;">
|
||||||
|
NOTIF #2025
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- BODY CONTENT -->
|
||||||
|
<tr>
|
||||||
|
<td class="content-padding" style="padding: 40px 40px 60px 40px;">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
DYNAMIC CONTENT WRAPPER (.tiptap-content)
|
||||||
|
This is where your Tiptap HTML will be injected.
|
||||||
|
-->
|
||||||
|
<div class="tiptap-content">
|
||||||
|
|
||||||
|
<!-- EXAMPLE 1: Standard Typography -->
|
||||||
|
<h1>Verify your login</h1>
|
||||||
|
<p>Halo <strong>Alex</strong>, kami mendeteksi permintaan masuk dari perangkat baru. Gunakan kode di bawah ini untuk menyelesaikan proses login.</p>
|
||||||
|
|
||||||
|
<!-- EXAMPLE 2: OTP / CODE BLOCK -->
|
||||||
|
<div class="otp-box">
|
||||||
|
829-103
|
||||||
|
</div>
|
||||||
|
<p>Kode ini akan kedaluwarsa dalam <strong>5 menit</strong>. Jika ini bukan Anda, abaikan email ini.</p>
|
||||||
|
|
||||||
|
<!-- EXAMPLE 3: TABLE (Auto-styled) -->
|
||||||
|
<h2>Rincian Perangkat</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Parameter</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>IP Address</td>
|
||||||
|
<td><code>192.168.1.1</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Lokasi</td>
|
||||||
|
<td>Jakarta, Indonesia</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Browser</td>
|
||||||
|
<td>Chrome on MacOS</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- EXAMPLE 4: CONTEXTUAL ALERT (Blockquote style) -->
|
||||||
|
<blockquote class="alert-danger">
|
||||||
|
<strong>Penting:</strong> Jangan pernah membagikan kode OTP ini kepada siapa pun, termasuk staf kami.
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<!-- EXAMPLE 5: BUTTON -->
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
<a href="#" class="btn btn-full">
|
||||||
|
Amankan Akun Saya
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- END DYNAMIC CONTENT -->
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 30px 40px; border-top: 2px solid #000000; background-color: #F4F4F5; color: #000;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size: 12px; line-height: 18px; font-family: monospace; color: #555;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-weight: bold;">PT BRUTALIST DIGITAL</p>
|
||||||
|
<p style="margin: 0 0 15px 0;">Menara Karya Lt. 20, Jakarta Selatan</p>
|
||||||
|
|
||||||
|
<p style="margin: 0;">
|
||||||
|
<a href="#" style="color: #000; text-decoration: underline;">Ubah Preferensi</a> |
|
||||||
|
<a href="#" style="color: #000; text-decoration: underline;">Unsubscribe</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<!-- END MAIN CONTAINER -->
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
fix-existing-consulting-orders.sql
Normal file
38
fix-existing-consulting-orders.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- SQL Script to manually fix existing paid consulting orders
|
||||||
|
-- This updates consulting_slots status and can be run in Supabase SQL Editor
|
||||||
|
|
||||||
|
-- Step 1: Check how many consulting orders are affected
|
||||||
|
SELECT
|
||||||
|
o.id as order_id,
|
||||||
|
o.payment_status,
|
||||||
|
COUNT(cs.id) as slot_count,
|
||||||
|
SUM(CASE WHEN cs.status = 'pending_payment' THEN 1 ELSE 0 END) as pending_slots
|
||||||
|
FROM orders o
|
||||||
|
INNER JOIN consulting_slots cs ON cs.order_id = o.id
|
||||||
|
WHERE o.payment_status = 'paid'
|
||||||
|
GROUP BY o.id, o.payment_status
|
||||||
|
HAVING SUM(CASE WHEN cs.status = 'pending_payment' THEN 1 ELSE 0 END) > 0;
|
||||||
|
|
||||||
|
-- Step 2: Update all pending_payment slots for paid orders to 'confirmed'
|
||||||
|
UPDATE consulting_slots
|
||||||
|
SET status = 'confirmed'
|
||||||
|
WHERE order_id IN (
|
||||||
|
SELECT o.id
|
||||||
|
FROM orders o
|
||||||
|
WHERE o.payment_status = 'paid'
|
||||||
|
)
|
||||||
|
AND status = 'pending_payment';
|
||||||
|
|
||||||
|
-- Step 3: Verify the update
|
||||||
|
SELECT
|
||||||
|
o.id as order_id,
|
||||||
|
o.payment_status,
|
||||||
|
cs.status as slot_status,
|
||||||
|
cs.date,
|
||||||
|
cs.start_time,
|
||||||
|
cs.end_time,
|
||||||
|
cs.meet_link
|
||||||
|
FROM orders o
|
||||||
|
INNER JOIN consulting_slots cs ON cs.order_id = o.id
|
||||||
|
WHERE o.payment_status = 'paid'
|
||||||
|
ORDER BY o.created_at DESC;
|
||||||
50
fix-trigger-settings.sql
Normal file
50
fix-trigger-settings.sql
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
-- Fixed version of handle_paid_order with hardcoded URL
|
||||||
|
-- Run this in Supabase SQL Editor
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION handle_paid_order()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
edge_function_url TEXT;
|
||||||
|
order_data JSON;
|
||||||
|
BEGIN
|
||||||
|
-- Only proceed if payment_status changed to 'paid'
|
||||||
|
IF (NEW.payment_status != 'paid' OR OLD.payment_status = 'paid') THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Log the payment event
|
||||||
|
RAISE NOTICE 'Order % payment status changed to paid', NEW.id;
|
||||||
|
|
||||||
|
-- Hardcoded edge function URL
|
||||||
|
edge_function_url := 'https://lovable.backoffice.biz.id/functions/v1/handle-order-paid';
|
||||||
|
|
||||||
|
-- Prepare order data
|
||||||
|
order_data := json_build_object(
|
||||||
|
'order_id', NEW.id,
|
||||||
|
'user_id', NEW.user_id,
|
||||||
|
'total_amount', NEW.total_amount,
|
||||||
|
'payment_method', NEW.payment_method,
|
||||||
|
'payment_provider', NEW.payment_provider
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Call the edge function asynchronously via pg_net
|
||||||
|
PERFORM net.http_post(
|
||||||
|
url := edge_function_url,
|
||||||
|
headers := json_build_object(
|
||||||
|
'Content-Type', 'application/json',
|
||||||
|
'Authorization', 'Bearer ' || current_setting('app.service_role_key', true)
|
||||||
|
),
|
||||||
|
body := order_data,
|
||||||
|
timeout_milliseconds := 10000
|
||||||
|
);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Called handle-order-paid for order %', NEW.id;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
-- Log error but don't fail the transaction
|
||||||
|
RAISE WARNING 'Failed to call handle-order-paid for order %: %', NEW.id, SQLERRM;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
11
force-schema-refresh.sql
Normal file
11
force-schema-refresh.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Force schema cache refresh by selecting from the table
|
||||||
|
SELECT * FROM platform_settings LIMIT 1;
|
||||||
|
|
||||||
|
-- Verify the column exists
|
||||||
|
SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'platform_settings'
|
||||||
|
AND column_name = 'google_service_account_json';
|
||||||
|
|
||||||
|
-- Update the table to trigger cache refresh (safe operation, just sets same value)
|
||||||
|
UPDATE platform_settings SET id = id WHERE 1=1 LIMIT 1;
|
||||||
187
get-google-refresh-token.html
Normal file
187
get-google-refresh-token.html
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Get Google OAuth Refresh Token</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 50px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #4285f4;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 4px solid #4285f4;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f1f1f1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
.input-group {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #4285f4;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #3367d6;
|
||||||
|
}
|
||||||
|
#result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #e8f5e9;
|
||||||
|
border: 1px solid #4caf50;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #ffebee;
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
min-height: 100px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🔑 Generate Google OAuth Refresh Token</h1>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Step 1: Create Google Cloud Project</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a></li>
|
||||||
|
<li>Create a new project or select existing one</li>
|
||||||
|
<li>Go to <strong>APIs & Services > Credentials</strong></li>
|
||||||
|
<li>Click <strong>+ Create Credentials</strong> → <strong>OAuth client ID</strong></li>
|
||||||
|
<li>Application type: <strong>Web application</strong></li>
|
||||||
|
<li>Add authorized redirect URI: <code>https://developers.google.com/oauthplayground</code></li>
|
||||||
|
<li>Copy the <strong>Client ID</strong> and <strong>Client Secret</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Step 2: Configure OAuth Playground</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <a href="https://developers.google.com/oauthplayground/" target="_blank">OAuth 2.0 Playground</a></li>
|
||||||
|
<li>Click the gear icon (⚙️) in the top right</li>
|
||||||
|
<li>Check <strong>Use your own OAuth credentials</strong></li>
|
||||||
|
<li>Enter your <strong>Client ID</strong> and <strong>Client Secret</strong> from Step 1</li>
|
||||||
|
<li>Click <strong>Close</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Step 3: Get Refresh Token</h3>
|
||||||
|
<ol>
|
||||||
|
<li>In the left panel, select:
|
||||||
|
<ul>
|
||||||
|
<li>☑️ Google Calendar API v3</li>
|
||||||
|
<li>Click <strong>Authorize APIs</strong></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Sign in with your Google account and grant permissions</li>
|
||||||
|
<li>Click <strong>Exchange authorization code for tokens</strong></li>
|
||||||
|
<li>Copy the <strong>Refresh Token</strong> from the right panel</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Step 4: Generate Configuration</h3>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="clientId">Client ID:</label>
|
||||||
|
<input type="text" id="clientId" placeholder="Enter your Client ID">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="clientSecret">Client Secret:</label>
|
||||||
|
<input type="text" id="clientSecret" placeholder="Enter your Client Secret">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="refreshToken">Refresh Token:</label>
|
||||||
|
<input type="text" id="refreshToken" placeholder="Enter your Refresh Token">
|
||||||
|
</div>
|
||||||
|
<button onclick="generateConfig()">Generate Configuration</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function generateConfig() {
|
||||||
|
const clientId = document.getElementById('clientId').value.trim();
|
||||||
|
const clientSecret = document.getElementById('clientSecret').value.trim();
|
||||||
|
const refreshToken = document.getElementById('refreshToken').value.trim();
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret || !refreshToken) {
|
||||||
|
const resultDiv = document.getElementById('result');
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
resultDiv.className = 'error';
|
||||||
|
resultDiv.innerHTML = '<strong>Error:</strong> Please fill in all fields.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: refreshToken
|
||||||
|
};
|
||||||
|
|
||||||
|
const configJson = JSON.stringify(config, null, 2);
|
||||||
|
|
||||||
|
const resultDiv = document.getElementById('result');
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
resultDiv.className = '';
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<h4>✅ Configuration Generated!</h4>
|
||||||
|
<p>Copy this JSON and paste it into the <strong>Google OAuth Config</strong> field in your admin panel:</p>
|
||||||
|
<textarea readonly onclick="this.select()">${configJson}</textarea>
|
||||||
|
<p><strong>Important:</strong> Keep these credentials secure. Never share them publicly!</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
320
get-google-token-local.html
Normal file
320
get-google-token-local.html
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Google OAuth - Get Refresh Token</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-top: 0;
|
||||||
|
border-bottom: 3px solid #4285f4;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
margin: 25px 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 5px solid #4285f4;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.step h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #4285f4;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f1f1f1;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #d63384;
|
||||||
|
}
|
||||||
|
.input-group {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4285f4;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #4285f4;
|
||||||
|
color: white;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 15px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #3367d6;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
#result {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #e8f5e9;
|
||||||
|
border: 2px solid #4caf50;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #ffebee;
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
min-height: 120px;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 2px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.warning strong {
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
.auth-button {
|
||||||
|
background: #34a853;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 16px 32px;
|
||||||
|
display: block;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
.auth-button:hover {
|
||||||
|
background: #2d9247;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🔑 Google OAuth Setup for Personal Gmail</h1>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>⚠️ Important:</strong> This tool runs entirely in your browser. No data is sent to any server - it goes directly to Google's OAuth servers.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Step 1: Create Google Cloud Project</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a></li>
|
||||||
|
<li>Create a new project (or select existing)</li>
|
||||||
|
<li>Navigate to <strong>APIs & Services > Library</strong></li>
|
||||||
|
<li>Search for and enable <strong>Google Calendar API</strong></li>
|
||||||
|
<li>Go to <strong>APIs & Services > Credentials</strong></li>
|
||||||
|
<li>Click <strong>+ Create Credentials</strong> → <strong>OAuth client ID</strong></li>
|
||||||
|
<li>Application type: <strong>Web application</strong></li>
|
||||||
|
<li>Add <strong>Authorized redirect URI</strong>: <code id="redirectUri"></code></li>
|
||||||
|
<li>Click <strong>Create</strong> and copy the <strong>Client ID</strong></li>
|
||||||
|
<li>Copy the <strong>Client Secret</strong> from the credentials page</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Step 2: Enter Your Credentials</h3>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="clientId">Client ID:</label>
|
||||||
|
<input type="text" id="clientId" placeholder="Enter your Google OAuth Client ID">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="clientSecret">Client Secret:</label>
|
||||||
|
<input type="text" id="clientSecret" placeholder="Enter your Google OAuth Client Secret">
|
||||||
|
</div>
|
||||||
|
<button onclick="showAuthUrl()">Generate Authorization URL</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="authUrlStep" class="step hidden">
|
||||||
|
<h3>Step 3: Authorize Your Google Account</h3>
|
||||||
|
<p>Click the button below and sign in with the Google account that owns the calendar you want to use:</p>
|
||||||
|
<button class="auth-button" onclick="openGoogleAuth()">🔐 Authorize with Google</button>
|
||||||
|
<p><strong>Important:</strong> After authorization, you'll be redirected back. Copy the authorization code from the URL.</p>
|
||||||
|
<input type="text" id="authCode" placeholder="Paste authorization code from redirect URL here" style="margin-top: 15px;">
|
||||||
|
<button onclick="exchangeCodeForTokens()">Get Refresh Token</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Show current URL as redirect URI hint
|
||||||
|
document.getElementById('redirectUri').textContent = window.location.origin + window.location.pathname;
|
||||||
|
|
||||||
|
function showAuthUrl() {
|
||||||
|
const clientId = document.getElementById('clientId').value.trim();
|
||||||
|
const clientSecret = document.getElementById('clientSecret').value.trim();
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
alert('Please enter both Client ID and Client Secret');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store credentials in sessionStorage
|
||||||
|
sessionStorage.setItem('google_client_id', clientId);
|
||||||
|
sessionStorage.setItem('google_client_secret', clientSecret);
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
const redirectUri = window.location.origin + window.location.pathname;
|
||||||
|
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
||||||
|
`client_id=${encodeURIComponent(clientId)}&` +
|
||||||
|
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||||
|
`response_type=code&` +
|
||||||
|
`scope=${encodeURIComponent('https://www.googleapis.com/auth/calendar')}&` +
|
||||||
|
`access_type=offline&` +
|
||||||
|
`prompt=consent`;
|
||||||
|
|
||||||
|
document.getElementById('authUrlStep').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Store auth URL for the button
|
||||||
|
window.openAuthUrl = authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGoogleAuth() {
|
||||||
|
if (window.openAuthUrl) {
|
||||||
|
window.open(window.openAuthUrl, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have an auth code in the URL (from redirect)
|
||||||
|
window.onload = function() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const code = urlParams.get('code');
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
document.getElementById('authCode').value = code;
|
||||||
|
// Clear the code from URL
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore credentials if they exist
|
||||||
|
const clientId = sessionStorage.getItem('google_client_id');
|
||||||
|
const clientSecret = sessionStorage.getItem('google_client_secret');
|
||||||
|
if (clientId) document.getElementById('clientId').value = clientId;
|
||||||
|
if (clientSecret) document.getElementById('clientSecret').value = clientSecret;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function exchangeCodeForTokens() {
|
||||||
|
const clientId = document.getElementById('clientId').value.trim();
|
||||||
|
const clientSecret = document.getElementById('clientSecret').value.trim();
|
||||||
|
const authCode = document.getElementById('authCode').value.trim();
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret || !authCode) {
|
||||||
|
alert('Please fill in all fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = window.location.origin + window.location.pathname;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code: authCode,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error_description || data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.refresh_token) {
|
||||||
|
throw new Error('No refresh token received. Make sure you used "prompt=consent" in the authorization URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: data.refresh_token
|
||||||
|
};
|
||||||
|
|
||||||
|
const configJson = JSON.stringify(config, null, 2);
|
||||||
|
|
||||||
|
const resultDiv = document.getElementById('result');
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
resultDiv.className = '';
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<h4>✅ Success! Here's your OAuth Config:</h4>
|
||||||
|
<p>Copy this JSON and paste it into the <strong>Google OAuth Config</strong> field in your admin panel:</p>
|
||||||
|
<textarea readonly onclick="this.select()">${configJson}</textarea>
|
||||||
|
<p><strong>Access Token (for testing):</strong></p>
|
||||||
|
<code><pre>${data.access_token}</pre></code>
|
||||||
|
<p><strong>Important:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Save this config securely - you'll need it to generate meet links</li>
|
||||||
|
<li>The refresh token is long-lasting and won't expire unless you revoke access</li>
|
||||||
|
<li>Keep your Client Secret safe!</li>
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Clear stored credentials
|
||||||
|
sessionStorage.removeItem('google_client_id');
|
||||||
|
sessionStorage.removeItem('google_client_secret');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const resultDiv = document.getElementById('result');
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
resultDiv.className = 'error';
|
||||||
|
resultDiv.innerHTML = `<strong>Error:</strong> ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
index.html
24
index.html
@@ -3,20 +3,24 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- TODO: Set the document title to the name of your application -->
|
<!-- Title will be dynamically updated from branding settings -->
|
||||||
<title>Lovable App</title>
|
<title>Loading...</title>
|
||||||
<meta name="description" content="Lovable Generated Project" />
|
<meta name="description" content="Learn. Grow. Succeed." />
|
||||||
<meta name="author" content="Lovable" />
|
<meta name="author" content="WithDwindi" />
|
||||||
|
|
||||||
<!-- TODO: Update og:title to match your application name -->
|
<meta property="og:title" content="WithDwindi" />
|
||||||
<meta property="og:title" content="Lovable App" />
|
<meta property="og:description" content="Learn. Grow. Succeed." />
|
||||||
<meta property="og:description" content="Lovable Generated Project" />
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
<meta property="og:image" content="https://with.dwindi.com/opengraph.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="629" />
|
||||||
|
<meta property="og:image:alt" content="WithDwindi" />
|
||||||
|
<meta property="og:url" content="https://with.dwindi.com" />
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:site" content="@Lovable" />
|
<meta name="twitter:site" content="@dwindown" />
|
||||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
<meta name="twitter:image" content="https://with.dwindi.com/opengraph.png" />
|
||||||
|
<meta name="twitter:image:alt" content="WithDwindi" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
114
manual-deploy-instructions.md
Normal file
114
manual-deploy-instructions.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Manual Deployment Instructions for Self-Hosted Supabase
|
||||||
|
|
||||||
|
Since you're using self-hosted Supabase, here's how to deploy the edge function:
|
||||||
|
|
||||||
|
## Option 1: Via Supabase CLI (Recommended)
|
||||||
|
|
||||||
|
If you have Supabase CLI installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Link to your self-hosted instance
|
||||||
|
supabase link --project-ref your-project-id
|
||||||
|
|
||||||
|
# Deploy the function
|
||||||
|
supabase functions deploy create-google-meet-event --verify-jwt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option 2: Direct File Upload to Supabase Container
|
||||||
|
|
||||||
|
For self-hosted Supabase, you need to:
|
||||||
|
|
||||||
|
1. **SSH into your Supabase container** or access via dashboard
|
||||||
|
|
||||||
|
2. **Copy the function file** to the correct location:
|
||||||
|
```bash
|
||||||
|
# Path in Supabase: /var/lib/postgresql/functions/create-google-meet-event/index.ts
|
||||||
|
# Or wherever your functions are stored
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Restart the Supabase functions service**
|
||||||
|
|
||||||
|
## Option 3: Use Docker Exec (If running in Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the Supabase container
|
||||||
|
docker ps | grep supabase
|
||||||
|
|
||||||
|
# Copy function file into container
|
||||||
|
docker cp supabase/functions/create-google-meet-event/index.ts \
|
||||||
|
<container-id>:/home/deno/functions/create-google-meet-event/index.ts
|
||||||
|
|
||||||
|
# Restart the functions service
|
||||||
|
docker exec <container-id> supervisorctl restart functions:*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option 4: Update via Supabase Dashboard (If Available)
|
||||||
|
|
||||||
|
1. Access your Supabase dashboard at `https://lovable.backoffice.biz.id`
|
||||||
|
2. Navigate to **Edge Functions**
|
||||||
|
3. Click **New Function** or edit existing
|
||||||
|
4. Paste the code from `supabase/functions/create-google-meet-event/index.ts`
|
||||||
|
5. Set **Verify JWT** to `true`
|
||||||
|
6. Save
|
||||||
|
|
||||||
|
## Quick Test to Verify Function Exists
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://lovable.backoffice.biz.id/functions/v1/create-google-meet-event" \
|
||||||
|
-H "Authorization: Bearer YOUR_ANON_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Should get a method not allowed error (which means function exists)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Cache Fix
|
||||||
|
|
||||||
|
For the schema cache issue with the `google_service_account_json` column:
|
||||||
|
|
||||||
|
### Run this in Supabase SQL Editor:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 1: Verify column exists
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'platform_settings'
|
||||||
|
AND column_name = 'google_service_account_json';
|
||||||
|
|
||||||
|
-- Step 2: Force cache refresh
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
|
|
||||||
|
-- Step 3: Test query
|
||||||
|
SELECT google_service_account_json FROM platform_settings;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Or restart PostgREST:
|
||||||
|
|
||||||
|
If you have access:
|
||||||
|
```bash
|
||||||
|
# In Supabase container/system
|
||||||
|
supervisorctl restart postgrest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend workaround:
|
||||||
|
|
||||||
|
If the schema cache persists, you can bypass the type-safe client and use raw SQL:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In IntegrasiTab.tsx, temporary bypass
|
||||||
|
const { data } = await supabase
|
||||||
|
.rpc('exec_sql', {
|
||||||
|
sql: `UPDATE platform_settings SET google_service_account_json = '${JSON.stringify(settings.integration_google_service_account_json)}'::jsonb`
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Deploy the function using one of the methods above
|
||||||
|
2. Run the schema refresh SQL
|
||||||
|
3. Try saving the settings again
|
||||||
|
4. Test the connection
|
||||||
|
|
||||||
|
Let me know which deployment method works for your setup!
|
||||||
187
n8n-workflows/README.md
Normal file
187
n8n-workflows/README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# n8n Workflows for Access Hub
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
### 1. Create Google Meet Event (Simple)
|
||||||
|
**File:** `create-google-meet-event.json`
|
||||||
|
|
||||||
|
A simple 3-node workflow that:
|
||||||
|
1. Receives webhook POST from Supabase Edge Function
|
||||||
|
2. Creates event in Google Calendar using Google Calendar node
|
||||||
|
3. Returns the meet link
|
||||||
|
|
||||||
|
**Best for:** Quick setup with minimal configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Create Google Meet Event (Advanced)
|
||||||
|
**File:** `create-google-meet-event-advanced.json`
|
||||||
|
|
||||||
|
An advanced workflow with more control:
|
||||||
|
1. Receives webhook POST from Supabase Edge Function
|
||||||
|
2. Prepares event data with Code node (custom formatting)
|
||||||
|
3. Creates event using Google Calendar API directly
|
||||||
|
4. Returns the meet link
|
||||||
|
|
||||||
|
**Best for:** More customization, error handling, and control
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import Instructions
|
||||||
|
|
||||||
|
### Option 1: Import from File
|
||||||
|
1. In n8n, click **+ Import from File**
|
||||||
|
2. Select the JSON file
|
||||||
|
3. Click **Import**
|
||||||
|
|
||||||
|
### Option 2: Copy-Paste
|
||||||
|
1. In n8n, click **+ New Workflow**
|
||||||
|
2. Click **...** (menu) → **Import from URL**
|
||||||
|
3. Paste the JSON content
|
||||||
|
4. Click **Import**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Configure Webhook
|
||||||
|
- **Path**: `create-meet` (already set)
|
||||||
|
- **Method**: POST
|
||||||
|
- **Production URL**: Will be auto-generated when you activate the workflow
|
||||||
|
|
||||||
|
### 2. Configure Google Calendar Credentials
|
||||||
|
|
||||||
|
#### For Simple Workflow:
|
||||||
|
1. Click on the **Google Calendar** node
|
||||||
|
2. Click **Create New Credential**
|
||||||
|
3. Select **Service Account** authentication
|
||||||
|
4. Paste the entire JSON content from your service account file
|
||||||
|
5. Give it a name: "Google Calendar (Service Account)"
|
||||||
|
6. Click **Create**
|
||||||
|
|
||||||
|
#### For Advanced Workflow:
|
||||||
|
1. Click on the **Google Calendar API** node
|
||||||
|
2. Click **Create New Credential**
|
||||||
|
3. Select **Service Account** authentication for Google API
|
||||||
|
4. Paste the service account JSON
|
||||||
|
5. Give it a name: "Google Calendar API (Service Account)"
|
||||||
|
6. Click **Create**
|
||||||
|
|
||||||
|
### 3. Activate Workflow
|
||||||
|
1. Click **Active** toggle in top right
|
||||||
|
2. n8n will generate your webhook URL
|
||||||
|
3. Your webhook URL will be: `https://api.backoffice.biz.id/webhook-test/create-meet`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test the Workflow
|
||||||
|
|
||||||
|
### Manual Test with Curl:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.backoffice.biz.id/webhook-test/create-meet \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"slot_id": "test-123",
|
||||||
|
"date": "2025-12-25",
|
||||||
|
"start_time": "14:00:00",
|
||||||
|
"end_time": "15:00:00",
|
||||||
|
"client_name": "Test Client",
|
||||||
|
"client_email": "test@example.com",
|
||||||
|
"topic": "Test Topic",
|
||||||
|
"notes": "Test notes",
|
||||||
|
"calendar_id": "your-email@gmail.com",
|
||||||
|
"brand_name": "Your Brand",
|
||||||
|
"test_mode": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meet_link": "https://meet.google.com/abc-defg-hij",
|
||||||
|
"event_id": "event-id-from-google-calendar",
|
||||||
|
"html_link": "https://www.google.com/calendar/event?eid=..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Variables
|
||||||
|
|
||||||
|
The webhook receives these fields from your Supabase Edge Function:
|
||||||
|
|
||||||
|
| Field | Description | Example |
|
||||||
|
|-------|-------------|---------|
|
||||||
|
| `slot_id` | Unique slot identifier | `uuid-here` |
|
||||||
|
| `date` | Event date (YYYY-MM-DD) | `2025-12-25` |
|
||||||
|
| `start_time` | Start time (HH:MM:SS) | `14:00:00` |
|
||||||
|
| `end_time` | End time (HH:MM:SS) | `15:00:00` |
|
||||||
|
| `client_name` | Client's full name | `John Doe` |
|
||||||
|
| `client_email` | Client's email | `john@example.com` |
|
||||||
|
| `topic` | Consultation topic | `Business Consulting` |
|
||||||
|
| `notes` | Additional notes | `Discuss project roadmap` |
|
||||||
|
| `calendar_id` | Google Calendar ID | `your-email@gmail.com` |
|
||||||
|
| `brand_name` | Your brand name | `Access Hub` |
|
||||||
|
| `test_mode` | Test mode flag | `true` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: 403 Forbidden
|
||||||
|
- Make sure calendar is shared with service account email
|
||||||
|
- Service account email format: `xxx@project-id.iam.gserviceaccount.com`
|
||||||
|
- Calendar permissions: "Make changes to events"
|
||||||
|
|
||||||
|
### Error: 401 Unauthorized
|
||||||
|
- Check service account JSON is correct
|
||||||
|
- Verify Calendar API is enabled in Google Cloud Console
|
||||||
|
|
||||||
|
### Error: 400 Invalid
|
||||||
|
- Check date format (YYYY-MM-DD)
|
||||||
|
- Check time format (HH:MM:SS)
|
||||||
|
- Verify calendar ID is correct
|
||||||
|
|
||||||
|
### Webhook not triggering
|
||||||
|
- Make sure workflow is **Active**
|
||||||
|
- Check webhook URL matches: `/webhook-test/create-meet`
|
||||||
|
- Verify webhook method is **POST** not GET
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Calendar ID
|
||||||
|
|
||||||
|
To find your Calendar ID:
|
||||||
|
1. Go to Google Calendar Settings
|
||||||
|
2. Scroll to **Integrate calendar**
|
||||||
|
3. Copy the **Calendar ID**
|
||||||
|
4. For primary calendar: your Gmail address
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production vs Test
|
||||||
|
|
||||||
|
- **Test Mode**: Uses `/webhook-test/` path
|
||||||
|
- **Production**: Uses `/webhook/` path
|
||||||
|
- Toggle in Admin Settings → Integrasi → Mode Test n8n
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Import workflow JSON
|
||||||
|
2. Set up Google Calendar credentials with service account
|
||||||
|
3. Activate workflow
|
||||||
|
4. Test with curl command above
|
||||||
|
5. Check your Google Calendar for the event
|
||||||
|
6. Verify meet link is returned
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you need help:
|
||||||
|
- Check n8n workflow execution logs
|
||||||
|
- Check Google Calendar API logs
|
||||||
|
- Verify service account permissions
|
||||||
|
- Check calendar sharing settings
|
||||||
127
n8n-workflows/create-google-meet-event-advanced.json
Normal file
127
n8n-workflows/create-google-meet-event-advanced.json
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"name": "Create Google Meet Event - Access Hub (Advanced)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "create-meet",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "webhook-trigger",
|
||||||
|
"name": "Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [250, 300],
|
||||||
|
"webhookId": "create-meet-webhook"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const items = $input.all();\nconst data = items[0].json;\n\n// Parse date and time\nconst startDate = new Date(`${data.date}T${data.start_time}`);\nconst endDate = new Date(`${data.date}T${data.end_time}`);\n\n// Format for Google Calendar API (ISO 8601 with timezone)\nconst startTime = startDate.toISOString();\nconst endTime = endDate.toISOString();\n\n// Build event data\nconst eventData = {\n calendarId: data.calendar_id,\n summary: `Konsultasi: ${data.topic} - ${data.client_name}`,\n description: `Client: ${data.client_email}\\n\\nNotes: ${data.notes || '-'}\\n\\nSlot ID: ${data.slot_id}\\nBrand: ${data.brand_name || 'Access Hub'}`,\n start: {\n dateTime: startTime,\n timeZone: 'Asia/Jakarta'\n },\n end: {\n dateTime: endTime,\n timeZone: 'Asia/Jakarta'\n },\n attendees: [\n { email: data.client_email }\n ],\n conferenceData: {\n createRequest: {\n requestId: data.slot_id,\n conferenceSolutionKey: { type: 'hangoutsMeet' }\n }\n },\n sendUpdates: 'all',\n guestsCanInviteOthers: false,\n guestsCanModify: false,\n guestsCanSeeOtherGuests: false\n};\n\nreturn [{ json: eventData }];"
|
||||||
|
},
|
||||||
|
"id": "prepare-event-data",
|
||||||
|
"name": "Prepare Event Data",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [470, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "serviceAccount",
|
||||||
|
"resource": "calendar",
|
||||||
|
"operation": "insert",
|
||||||
|
"calendarId": "={{ $json.calendarId }}",
|
||||||
|
"body": "={{ { summary: $json.summary, description: $json.description, start: $json.start, end: $json.end, attendees: $json.attendees, conferenceData: $json.conferenceData, sendUpdates: $json.sendUpdates, guestsCanInviteOthers: $json.guestsCanInviteOthers, guestsCanModify: $json.guestsCanModify, guestsCanSeeOtherGuests: $json.guestsCanSeeOtherGuests } }}",
|
||||||
|
"options": {
|
||||||
|
"conferenceDataVersion": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "google-calendar-api",
|
||||||
|
"name": "Google Calendar API",
|
||||||
|
"type": "n8n-nodes-base.googleApi",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [690, 300],
|
||||||
|
"credentials": {
|
||||||
|
"googleApi": {
|
||||||
|
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
|
||||||
|
"name": "Google Calendar API (Service Account)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ { \"meet_link\": $json.hangoutLink, \"event_id\": $json.id, \"html_link\": $json.htmlLink } }}"
|
||||||
|
},
|
||||||
|
"id": "respond-to-webhook",
|
||||||
|
"name": "Respond to Webhook",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [910, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "error-handler",
|
||||||
|
"name": "error",
|
||||||
|
"value": "={{ $json.error?.message || 'Unknown error' }}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "error-handler",
|
||||||
|
"name": "Error Handler",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [910, 480]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Prepare Event Data",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Prepare Event Data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Google Calendar API",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Google Calendar API": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Respond to Webhook",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"tags": ["access-hub", "calendar", "meet"],
|
||||||
|
"triggerCount": 1,
|
||||||
|
"updatedAt": "2025-12-23T00:00:00.000Z",
|
||||||
|
"versionId": "1"
|
||||||
|
}
|
||||||
89
n8n-workflows/create-google-meet-event.json
Normal file
89
n8n-workflows/create-google-meet-event.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "Create Google Meet Event - Access Hub",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "create-meet",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "webhook-trigger",
|
||||||
|
"name": "Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [250, 300],
|
||||||
|
"webhookId": "create-meet-webhook"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "create",
|
||||||
|
"calendarId": "={{ $json.calendar_id }}",
|
||||||
|
"title": "=Konsultasi: {{ $json.topic }} - {{ $json.client_name }}",
|
||||||
|
"description": "=Client: {{ $json.client_email }}\n\nNotes: {{ $json.notes }}\n\nSlot ID: {{ $json.slot_id }}",
|
||||||
|
"location": "Google Meet",
|
||||||
|
"attendees": "={{ $json.client_email }}",
|
||||||
|
"startsAt": "={{ $json.date }}T{{ $json.start_time }}",
|
||||||
|
"endsAt": "={{ $json.date }}T{{ $json.end_time }}",
|
||||||
|
"sendUpdates": "all",
|
||||||
|
"conferenceDataVersion": 1,
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "google-calendar",
|
||||||
|
"name": "Google Calendar",
|
||||||
|
"type": "n8n-nodes-base.googleCalendar",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [470, 300],
|
||||||
|
"credentials": {
|
||||||
|
"googleCalendarApi": {
|
||||||
|
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
|
||||||
|
"name": "Google Calendar account (Service Account)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ { \"meet_link\": $json.hangoutLink, \"event_id\": $json.id, \"html_link\": $json.htmlLink } }}"
|
||||||
|
},
|
||||||
|
"id": "respond-to-webhook",
|
||||||
|
"name": "Respond to Webhook",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [690, 300]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Google Calendar",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Google Calendar": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Respond to Webhook",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"tags": [],
|
||||||
|
"triggerCount": 1,
|
||||||
|
"updatedAt": "2025-12-23T00:00:00.000Z",
|
||||||
|
"versionId": "1"
|
||||||
|
}
|
||||||
341
otp-testing-guide.md
Normal file
341
otp-testing-guide.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# OTP Email Verification Testing Guide
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
✅ **Backend Working**: Edge functions tested with curl and working
|
||||||
|
✅ **Database Setup**: Migrations applied, tables created
|
||||||
|
⚠️ **Frontend Integration**: Need to test and debug
|
||||||
|
|
||||||
|
## What Should Happen
|
||||||
|
|
||||||
|
### Registration Flow
|
||||||
|
1. User fills registration form (name, email, password)
|
||||||
|
2. Clicks "Daftar" (Register) button
|
||||||
|
3. Supabase Auth creates user account
|
||||||
|
4. Frontend calls `send-auth-otp` edge function
|
||||||
|
5. Edge function:
|
||||||
|
- Generates 6-digit OTP
|
||||||
|
- Stores in `auth_otps` table (15 min expiry)
|
||||||
|
- Fetches email template from `notification_templates`
|
||||||
|
- Sends email via Mailketing API
|
||||||
|
6. Frontend shows OTP input form
|
||||||
|
7. User receives email with 6-digit code
|
||||||
|
8. User enters OTP code
|
||||||
|
9. Frontend calls `verify-auth-otp` edge function
|
||||||
|
10. Edge function:
|
||||||
|
- Validates OTP (not expired, not used)
|
||||||
|
- Marks OTP as used
|
||||||
|
- Confirms email in Supabase Auth
|
||||||
|
11. User can now login
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### 1. Backend Verification (Already Done ✅)
|
||||||
|
|
||||||
|
Test edge function with curl:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://lovable.backoffice.biz.id/functions/v1/send-auth-otp \
|
||||||
|
-H "Authorization: Bearer YOUR_SERVICE_ROLE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user_id":"USER_UUID","email":"test@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `{"success":true,"message":"OTP sent successfully"}`
|
||||||
|
|
||||||
|
### 2. Frontend Testing (Do This Now)
|
||||||
|
|
||||||
|
#### Step 1: Open Browser DevTools
|
||||||
|
1. Open your browser
|
||||||
|
2. Press F12 (or Cmd+Option+I on Mac)
|
||||||
|
3. Go to **Console** tab
|
||||||
|
4. Go to **Network** tab
|
||||||
|
|
||||||
|
#### Step 2: Attempt Registration
|
||||||
|
1. Navigate to `/auth` page
|
||||||
|
2. Click "Belum punya akun? Daftar" (switch to registration)
|
||||||
|
3. Fill in:
|
||||||
|
- Nama: Test User
|
||||||
|
- Email: Your real email address
|
||||||
|
- Password: Any password (6+ characters)
|
||||||
|
4. Click "Daftar" button
|
||||||
|
|
||||||
|
#### Step 3: Check Console Logs
|
||||||
|
You should see these logs in order:
|
||||||
|
|
||||||
|
**Log 1:**
|
||||||
|
```
|
||||||
|
SignUp result: {
|
||||||
|
error: null,
|
||||||
|
data: { user: {...}, session: null },
|
||||||
|
hasUser: true,
|
||||||
|
hasSession: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If you see this → User creation succeeded ✅
|
||||||
|
|
||||||
|
**Log 2:**
|
||||||
|
```
|
||||||
|
User created successfully: {
|
||||||
|
userId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
email: "your@email.com",
|
||||||
|
session: null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If you see this → Proceeding to OTP sending ✅
|
||||||
|
|
||||||
|
**Log 3:**
|
||||||
|
```
|
||||||
|
Sending OTP request: {
|
||||||
|
userId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
email: "your@email.com",
|
||||||
|
hasSession: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If you see this → OTP function is being called ✅
|
||||||
|
|
||||||
|
**Log 4:**
|
||||||
|
```
|
||||||
|
OTP response status: 200
|
||||||
|
```
|
||||||
|
If you see this → Edge function responded successfully ✅
|
||||||
|
|
||||||
|
**Log 5:**
|
||||||
|
```
|
||||||
|
OTP send result: { success: true, message: "OTP sent successfully" }
|
||||||
|
```
|
||||||
|
If you see this → Everything worked! 🎉
|
||||||
|
|
||||||
|
#### Step 4: Check Network Tab
|
||||||
|
1. In DevTools Network tab
|
||||||
|
2. Look for request to `/functions/v1/send-auth-otp`
|
||||||
|
3. Click on it
|
||||||
|
4. Check:
|
||||||
|
- **Status**: Should be 200
|
||||||
|
- **Payload**: Should contain `{"user_id":"...", "email":"..."}`
|
||||||
|
- **Response**: Should be `{"success":true,"message":"OTP sent successfully"}`
|
||||||
|
|
||||||
|
### 3. Database Verification
|
||||||
|
|
||||||
|
After registration, check the database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check if OTP was created
|
||||||
|
SELECT * FROM auth_otps ORDER BY created_at DESC LIMIT 1;
|
||||||
|
|
||||||
|
-- Check if user was created
|
||||||
|
SELECT id, email, email_confirmed_at, created_at
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'your@email.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `auth_otps` table has 1 new row with your email
|
||||||
|
- `auth.users` table has your user with `email_confirmed_at = NULL`
|
||||||
|
|
||||||
|
### 4. Email Verification
|
||||||
|
|
||||||
|
1. Check your email inbox (and spam folder)
|
||||||
|
2. Look for email with subject like "Kode Verifikasi Email Anda"
|
||||||
|
3. Open email and find 6-digit code (e.g., "123456")
|
||||||
|
4. Go back to browser
|
||||||
|
5. Enter the 6-digit code in OTP form
|
||||||
|
6. Click "Verifikasi" button
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Toast notification: "Verifikasi Berhasil - Email Anda telah terverifikasi"
|
||||||
|
- Form switches back to login mode
|
||||||
|
|
||||||
|
## Debugging Common Issues
|
||||||
|
|
||||||
|
### Issue 1: No Console Logs Appear
|
||||||
|
|
||||||
|
**Symptoms**: Submit form but nothing shows in console
|
||||||
|
|
||||||
|
**Possible Causes**:
|
||||||
|
1. Dev server not running → Run `npm run dev`
|
||||||
|
2. Code not reloaded → Refresh browser (Cmd+R / F5)
|
||||||
|
3. JavaScript error → Check Console for red error messages
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```bash
|
||||||
|
# Stop dev server (Cmd+C)
|
||||||
|
# Then restart
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 2: Console Shows "SignUp result: hasUser: false"
|
||||||
|
|
||||||
|
**Symptoms**: User creation fails
|
||||||
|
|
||||||
|
**Possible Causes**:
|
||||||
|
1. Email already registered
|
||||||
|
2. Supabase Auth configuration issue
|
||||||
|
3. Network error
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```sql
|
||||||
|
-- Check if user exists
|
||||||
|
SELECT id, email FROM auth.users WHERE email = 'your@email.com';
|
||||||
|
|
||||||
|
-- If exists, delete and try again
|
||||||
|
DELETE FROM auth.users WHERE email = 'your@email.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 3: Log 3 Appears But No Log 4
|
||||||
|
|
||||||
|
**Symptoms**: OTP request sent but no response
|
||||||
|
|
||||||
|
**Possible Causes**:
|
||||||
|
1. CORS error
|
||||||
|
2. Edge function not deployed
|
||||||
|
3. Wrong Supabase URL in env variables
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```bash
|
||||||
|
# Check env variables
|
||||||
|
cat .env
|
||||||
|
# Should have VITE_SUPABASE_URL=https://lovable.backoffice.biz.id
|
||||||
|
```
|
||||||
|
|
||||||
|
Check Console for CORS errors (red text like "Access-Control-Allow-Origin")
|
||||||
|
|
||||||
|
### Issue 4: Log 4 Shows Status != 200
|
||||||
|
|
||||||
|
**Symptoms**: Edge function returns error
|
||||||
|
|
||||||
|
**Solution**: Check the error message in Log 4 or Console
|
||||||
|
|
||||||
|
Common errors:
|
||||||
|
- `401 Unauthorized`: Check authorization token
|
||||||
|
- `404 Not Found`: Edge function not deployed
|
||||||
|
- `500 Server Error`: Check edge function logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check edge function logs
|
||||||
|
supabase functions logs send-auth-otp --tail
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 5: OTP Created But No Email Received
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- `auth_otps` table has new row
|
||||||
|
- Network request shows 200 OK
|
||||||
|
- But no email in inbox
|
||||||
|
|
||||||
|
**Possible Causes**:
|
||||||
|
1. Mailketing API issue
|
||||||
|
2. Wrong API token
|
||||||
|
3. Email in spam folder
|
||||||
|
4. Template not active
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```sql
|
||||||
|
-- Check notification_logs table
|
||||||
|
SELECT * FROM notification_logs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Check if template is active
|
||||||
|
SELECT * FROM notification_templates
|
||||||
|
WHERE key = 'auth_email_verification';
|
||||||
|
```
|
||||||
|
|
||||||
|
If `status = 'failed'`, check `error_message` column.
|
||||||
|
|
||||||
|
### Issue 6: Email Received But Wrong Code
|
||||||
|
|
||||||
|
**Symptoms**: Enter code from email but verification fails
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```sql
|
||||||
|
-- Check OTP in database
|
||||||
|
SELECT otp_code, expires_at, used_at
|
||||||
|
FROM auth_otps
|
||||||
|
WHERE email = 'your@email.com'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare `otp_code` in database with code in email. They should match.
|
||||||
|
|
||||||
|
## Environment Variables Checklist
|
||||||
|
|
||||||
|
Make sure these are set in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_SUPABASE_URL=https://lovable.backoffice.biz.id
|
||||||
|
VITE_SUPABASE_ANON_KEY=your_anon_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
Check:
|
||||||
|
```bash
|
||||||
|
# View env vars (without showing secrets)
|
||||||
|
grep VITE_SUPABASE .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge Functions Deployment Status
|
||||||
|
|
||||||
|
Check if functions are deployed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase functions list
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
send-auth-otp ...
|
||||||
|
verify-auth-otp ...
|
||||||
|
send-email-v2 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Test Script
|
||||||
|
|
||||||
|
Save this as `test-otp.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "📧 OTP Email Verification Test Script"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. Checking environment variables..."
|
||||||
|
if [ -z "$VITE_SUPABASE_URL" ]; then
|
||||||
|
echo "❌ VITE_SUPABASE_URL not set"
|
||||||
|
else
|
||||||
|
echo "✅ VITE_SUPABASE_URL=$VITE_SUPABASE_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Checking database connection..."
|
||||||
|
# Add your DB check here
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Checking edge functions..."
|
||||||
|
supabase functions list
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Next steps:"
|
||||||
|
echo " - Open browser to http://localhost:5173/auth"
|
||||||
|
echo " - Open DevTools (F12) → Console tab"
|
||||||
|
echo " - Try to register"
|
||||||
|
echo " - Check console logs"
|
||||||
|
echo " - Check email inbox"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
✅ All 5 console logs appear in order
|
||||||
|
✅ Network request shows 200 OK
|
||||||
|
✅ `auth_otps` table has new row
|
||||||
|
✅ Email received with 6-digit code
|
||||||
|
✅ OTP code verifies successfully
|
||||||
|
✅ User email confirmed in `auth.users`
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
If you're still stuck, please provide:
|
||||||
|
1. Screenshot of Console tab (all logs)
|
||||||
|
2. Screenshot of Network tab (send-auth-otp request)
|
||||||
|
3. Output of: `SELECT * FROM auth_otps ORDER BY created_at DESC LIMIT 1;`
|
||||||
|
4. Output of: `SELECT * FROM notification_logs ORDER BY created_at DESC LIMIT 1;`
|
||||||
|
5. Any error messages shown in red in Console
|
||||||
698
package-lock.json
generated
698
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -41,19 +41,32 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@supabase/supabase-js": "^2.88.0",
|
"@supabase/supabase-js": "^2.88.0",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
||||||
"@tiptap/extension-image": "^3.13.0",
|
"@tiptap/extension-image": "^3.13.0",
|
||||||
"@tiptap/extension-link": "^3.13.0",
|
"@tiptap/extension-link": "^3.13.0",
|
||||||
"@tiptap/extension-placeholder": "^3.13.0",
|
"@tiptap/extension-placeholder": "^3.13.0",
|
||||||
|
"@tiptap/extension-table": "^3.14.0",
|
||||||
|
"@tiptap/extension-table-cell": "^3.14.0",
|
||||||
|
"@tiptap/extension-table-header": "^3.14.0",
|
||||||
|
"@tiptap/extension-table-row": "^3.14.0",
|
||||||
|
"@tiptap/extension-text-align": "^3.14.0",
|
||||||
"@tiptap/react": "^3.13.0",
|
"@tiptap/react": "^3.13.0",
|
||||||
"@tiptap/starter-kit": "^3.13.0",
|
"@tiptap/starter-kit": "^3.13.0",
|
||||||
|
"@types/hls.js": "^0.13.3",
|
||||||
|
"@types/video.js": "^7.3.58",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"plyr": "^3.8.3",
|
||||||
|
"plyr-react": "^6.0.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
@@ -67,6 +80,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tiptap-extension-resize-image": "^1.3.2",
|
"tiptap-extension-resize-image": "^1.3.2",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
|
"video.js": "^8.23.4",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -84,6 +98,7 @@
|
|||||||
"lovable-tagger": "^1.1.13",
|
"lovable-tagger": "^1.1.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"terser": "^5.44.1",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
public/opengraph.png
Normal file
BIN
public/opengraph.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
5
refresh-schema.sql
Normal file
5
refresh-schema.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- This query will force a schema refresh
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'platform_settings'
|
||||||
|
AND column_name = 'google_service_account_json';
|
||||||
165
src/App.tsx
165
src/App.tsx
@@ -6,15 +6,18 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|||||||
import { AuthProvider } from "@/hooks/useAuth";
|
import { AuthProvider } from "@/hooks/useAuth";
|
||||||
import { CartProvider } from "@/contexts/CartContext";
|
import { CartProvider } from "@/contexts/CartContext";
|
||||||
import { BrandingProvider } from "@/hooks/useBranding";
|
import { BrandingProvider } from "@/hooks/useBranding";
|
||||||
|
import { ProtectedRoute } from "@/components/ProtectedRoute";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import Auth from "./pages/Auth";
|
import Auth from "./pages/Auth";
|
||||||
|
import ConfirmOTP from "./pages/ConfirmOTP";
|
||||||
import Products from "./pages/Products";
|
import Products from "./pages/Products";
|
||||||
import ProductDetail from "./pages/ProductDetail";
|
import ProductDetail from "./pages/ProductDetail";
|
||||||
import Checkout from "./pages/Checkout";
|
import Checkout from "./pages/Checkout";
|
||||||
import Bootcamp from "./pages/Bootcamp";
|
import Bootcamp from "./pages/Bootcamp";
|
||||||
|
import WebinarRecording from "./pages/WebinarRecording";
|
||||||
import Events from "./pages/Events";
|
import Events from "./pages/Events";
|
||||||
import ConsultingBooking from "./pages/ConsultingBooking";
|
import ConsultingBooking from "./pages/ConsultingBooking";
|
||||||
import Calendar from "./pages/Calendar";
|
import CalendarPage from "./pages/Calendar";
|
||||||
import Privacy from "./pages/Privacy";
|
import Privacy from "./pages/Privacy";
|
||||||
import Terms from "./pages/Terms";
|
import Terms from "./pages/Terms";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
@@ -25,6 +28,7 @@ import MemberAccess from "./pages/member/MemberAccess";
|
|||||||
import MemberOrders from "./pages/member/MemberOrders";
|
import MemberOrders from "./pages/member/MemberOrders";
|
||||||
import MemberProfile from "./pages/member/MemberProfile";
|
import MemberProfile from "./pages/member/MemberProfile";
|
||||||
import OrderDetail from "./pages/member/OrderDetail";
|
import OrderDetail from "./pages/member/OrderDetail";
|
||||||
|
import MemberProfit from "./pages/member/MemberProfit";
|
||||||
|
|
||||||
// Admin pages
|
// Admin pages
|
||||||
import AdminDashboard from "./pages/admin/AdminDashboard";
|
import AdminDashboard from "./pages/admin/AdminDashboard";
|
||||||
@@ -36,6 +40,8 @@ import AdminEvents from "./pages/admin/AdminEvents";
|
|||||||
import AdminSettings from "./pages/admin/AdminSettings";
|
import AdminSettings from "./pages/admin/AdminSettings";
|
||||||
import AdminConsulting from "./pages/admin/AdminConsulting";
|
import AdminConsulting from "./pages/admin/AdminConsulting";
|
||||||
import AdminReviews from "./pages/admin/AdminReviews";
|
import AdminReviews from "./pages/admin/AdminReviews";
|
||||||
|
import ProductCurriculum from "./pages/admin/ProductCurriculum";
|
||||||
|
import AdminWithdrawals from "./pages/admin/AdminWithdrawals";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -51,33 +57,158 @@ const App = () => (
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/auth" element={<Auth />} />
|
<Route path="/auth" element={<Auth />} />
|
||||||
|
<Route path="/confirm-otp" element={<ConfirmOTP />} />
|
||||||
<Route path="/products" element={<Products />} />
|
<Route path="/products" element={<Products />} />
|
||||||
<Route path="/products/:slug" element={<ProductDetail />} />
|
<Route path="/products/:slug" element={<ProductDetail />} />
|
||||||
<Route path="/checkout" element={<Checkout />} />
|
<Route path="/checkout" element={<Checkout />} />
|
||||||
<Route path="/events" element={<Events />} />
|
<Route path="/events" element={<Events />} />
|
||||||
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
<Route path="/bootcamp/:slug/:lessonId?" element={<Bootcamp />} />
|
||||||
|
<Route path="/webinar/:slug" element={<WebinarRecording />} />
|
||||||
<Route path="/consulting" element={<ConsultingBooking />} />
|
<Route path="/consulting" element={<ConsultingBooking />} />
|
||||||
<Route path="/calendar" element={<Calendar />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/privacy" element={<Privacy />} />
|
<Route path="/privacy" element={<Privacy />} />
|
||||||
<Route path="/terms" element={<Terms />} />
|
<Route path="/terms" element={<Terms />} />
|
||||||
|
|
||||||
{/* Member routes */}
|
{/* Member routes */}
|
||||||
<Route path="/dashboard" element={<MemberDashboard />} />
|
<Route
|
||||||
<Route path="/access" element={<MemberAccess />} />
|
path="/dashboard"
|
||||||
<Route path="/orders" element={<MemberOrders />} />
|
element={
|
||||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
<ProtectedRoute>
|
||||||
<Route path="/profile" element={<MemberProfile />} />
|
<MemberDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/access"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MemberAccess />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/orders"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MemberOrders />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/orders/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<OrderDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MemberProfile />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profit"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MemberProfit />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
<Route path="/admin" element={<AdminDashboard />} />
|
<Route
|
||||||
<Route path="/admin/products" element={<AdminProducts />} />
|
path="/admin"
|
||||||
<Route path="/admin/bootcamp" element={<AdminBootcamp />} />
|
element={
|
||||||
<Route path="/admin/orders" element={<AdminOrders />} />
|
<ProtectedRoute requireAdmin>
|
||||||
<Route path="/admin/members" element={<AdminMembers />} />
|
<AdminDashboard />
|
||||||
<Route path="/admin/events" element={<AdminEvents />} />
|
</ProtectedRoute>
|
||||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
}
|
||||||
<Route path="/admin/consulting" element={<AdminConsulting />} />
|
/>
|
||||||
<Route path="/admin/reviews" element={<AdminReviews />} />
|
<Route
|
||||||
|
path="/admin/products"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminProducts />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/products/:id/curriculum"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<ProductCurriculum />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/bootcamp"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminBootcamp />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/orders"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminOrders />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/members"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminMembers />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/events"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminEvents />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminSettings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/consulting"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminConsulting />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/reviews"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminReviews />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/withdrawals"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminWithdrawals />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ReactNode, useState } from 'react';
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
import { useBranding } from '@/hooks/useBranding';
|
import { useBranding } from '@/hooks/useBranding';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Video,
|
Video,
|
||||||
Star,
|
Star,
|
||||||
|
Wallet,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -43,27 +45,27 @@ const userNavItems: NavItem[] = [
|
|||||||
const adminNavItems: NavItem[] = [
|
const adminNavItems: NavItem[] = [
|
||||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||||
{ label: 'Produk', href: '/admin/products', icon: Package },
|
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||||
{ label: 'Bootcamp', href: '/admin/bootcamp', icon: BookOpen },
|
|
||||||
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video },
|
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video },
|
||||||
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||||
{ label: 'Member', href: '/admin/members', icon: Users },
|
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||||
|
{ label: 'Withdrawals', href: '/admin/withdrawals', icon: Wallet },
|
||||||
{ label: 'Ulasan', href: '/admin/reviews', icon: Star },
|
{ label: 'Ulasan', href: '/admin/reviews', icon: Star },
|
||||||
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
|
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
|
||||||
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings },
|
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
const mobileUserNav: NavItem[] = [
|
const mobileUserNav: NavItem[] = [
|
||||||
{ label: 'Home', href: '/dashboard', icon: Home },
|
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
{ label: 'Kelas', href: '/access', icon: BookOpen },
|
{ label: 'Akses', href: '/access', icon: BookOpen },
|
||||||
{ label: 'Pesanan', href: '/orders', icon: Receipt },
|
{ label: 'Order', href: '/orders', icon: Receipt },
|
||||||
{ label: 'Profil', href: '/profile', icon: User },
|
{ label: 'Profil', href: '/profile', icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
const mobileAdminNav: NavItem[] = [
|
const mobileAdminNav: NavItem[] = [
|
||||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||||
{ label: 'Produk', href: '/admin/products', icon: Package },
|
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||||
{ label: 'Pesanan', href: '/admin/orders', icon: Receipt },
|
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||||
{ label: 'Pengguna', href: '/admin/members', icon: Users },
|
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface AppLayoutProps {
|
interface AppLayoutProps {
|
||||||
@@ -77,9 +79,36 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
const [isCollaborator, setIsCollaborator] = useState(false);
|
||||||
|
|
||||||
const navItems = isAdmin ? adminNavItems : userNavItems;
|
useEffect(() => {
|
||||||
const mobileNav = isAdmin ? mobileAdminNav : mobileUserNav;
|
const checkCollaborator = async () => {
|
||||||
|
if (!user || isAdmin) {
|
||||||
|
setIsCollaborator(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [walletRes, productRes] = await Promise.all([
|
||||||
|
supabase.from("collaborator_wallets").select("user_id").eq("user_id", user.id).maybeSingle(),
|
||||||
|
supabase.from("products").select("id").eq("collaborator_user_id", user.id).limit(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setIsCollaborator(!!walletRes.data || !!(productRes.data && productRes.data.length > 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
checkCollaborator();
|
||||||
|
}, [user, isAdmin]);
|
||||||
|
|
||||||
|
const navItems = isAdmin
|
||||||
|
? adminNavItems
|
||||||
|
: isCollaborator
|
||||||
|
? [...userNavItems.slice(0, 4), { label: 'Profit', href: '/profit', icon: Wallet }, userNavItems[4]]
|
||||||
|
: userNavItems;
|
||||||
|
const mobileNav = isAdmin
|
||||||
|
? mobileAdminNav
|
||||||
|
: isCollaborator
|
||||||
|
? [...mobileUserNav.slice(0, 3), { label: 'Profit', href: '/profit', icon: Wallet }, mobileUserNav[3]]
|
||||||
|
: mobileUserNav;
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
@@ -108,13 +137,14 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
<header className="border-b-2 border-border bg-background sticky top-0 z-50">
|
<header className="border-b-2 border-border bg-background sticky top-0 z-50">
|
||||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<Link to="/" className="text-2xl font-bold flex items-center gap-2">
|
<Link to="/" className="text-2xl font-bold flex items-center gap-2">
|
||||||
{logoUrl ? (
|
{logoUrl && (
|
||||||
<img src={logoUrl} alt={brandName} className="h-8 object-contain" />
|
<img src={logoUrl} alt={brandName} className="h-8 object-contain" />
|
||||||
) : (
|
|
||||||
brandName
|
|
||||||
)}
|
)}
|
||||||
|
<span>{brandName}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-4">
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex items-center gap-4">
|
||||||
<Link to="/products" className="hover:underline font-medium">Produk</Link>
|
<Link to="/products" className="hover:underline font-medium">Produk</Link>
|
||||||
<Link to="/calendar" className="hover:underline font-medium">Kalender</Link>
|
<Link to="/calendar" className="hover:underline font-medium">Kalender</Link>
|
||||||
<Link to="/auth">
|
<Link to="/auth">
|
||||||
@@ -134,6 +164,43 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Menu Trigger */}
|
||||||
|
<div className="md:hidden flex items-center gap-2">
|
||||||
|
<Link to="/checkout">
|
||||||
|
<Button variant="outline" size="sm" className="relative border-2">
|
||||||
|
<ShoppingCart className="w-4 h-4" />
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="absolute -top-2 -right-2 bg-primary text-primary-foreground text-xs w-5 h-5 flex items-center justify-center">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="border-l-2 border-border">
|
||||||
|
<nav className="flex flex-col space-y-4 mt-8">
|
||||||
|
<Link to="/products" className="flex items-center gap-3 text-lg font-medium">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
Produk
|
||||||
|
</Link>
|
||||||
|
<Link to="/calendar" className="flex items-center gap-3 text-lg font-medium">
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
|
Kalender
|
||||||
|
</Link>
|
||||||
|
<Link to="/auth" className="flex items-center gap-3 text-lg font-medium">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
@@ -148,11 +215,10 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
<aside className="hidden md:flex flex-col w-64 border-r-2 border-border bg-sidebar fixed h-screen">
|
<aside className="hidden md:flex flex-col w-64 border-r-2 border-border bg-sidebar fixed h-screen">
|
||||||
<div className="p-4 border-b-2 border-border">
|
<div className="p-4 border-b-2 border-border">
|
||||||
<Link to="/" className="text-xl font-bold flex items-center gap-2">
|
<Link to="/" className="text-xl font-bold flex items-center gap-2">
|
||||||
{logoUrl ? (
|
{logoUrl && (
|
||||||
<img src={logoUrl} alt={brandName} className="h-8 object-contain" />
|
<img src={logoUrl} alt={brandName} className="h-8 object-contain" />
|
||||||
) : (
|
|
||||||
brandName
|
|
||||||
)}
|
)}
|
||||||
|
<span>{brandName}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -201,21 +267,22 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
{/* Mobile Header */}
|
{/* Mobile Header */}
|
||||||
<header className="md:hidden sticky top-0 z-50 border-b-2 border-border bg-background px-4 py-3 flex items-center justify-between">
|
<header className="md:hidden sticky top-0 z-50 border-b-2 border-border bg-background px-4 py-3 flex items-center justify-between">
|
||||||
<Link to="/" className="text-xl font-bold flex items-center gap-2">
|
<Link to="/" className="text-xl font-bold flex items-center gap-2">
|
||||||
{logoUrl ? (
|
{logoUrl && (
|
||||||
<img src={logoUrl} alt={brandName} className="h-6 object-contain" />
|
<img src={logoUrl} alt={brandName} className="h-6 object-contain" />
|
||||||
) : (
|
|
||||||
brandName
|
|
||||||
)}
|
)}
|
||||||
|
<span>{brandName}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link to="/checkout" className="relative p-2">
|
{!isAdmin && (
|
||||||
<ShoppingCart className="w-5 h-5" />
|
<Link to="/checkout" className="relative p-2">
|
||||||
{items.length > 0 && (
|
<ShoppingCart className="w-5 h-5" />
|
||||||
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-xs w-4 h-4 flex items-center justify-center">
|
{items.length > 0 && (
|
||||||
{items.length}
|
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-xs w-4 h-4 flex items-center justify-center">
|
||||||
</span>
|
{items.length}
|
||||||
)}
|
</span>
|
||||||
</Link>
|
)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,42 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
|
import { useBranding } from '@/hooks/useBranding';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ShoppingCart, User, LogOut, Settings } from 'lucide-react';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import { ShoppingCart, User, LogOut, Settings, Menu, Package } from 'lucide-react';
|
||||||
|
|
||||||
export function Layout({ children }: { children: ReactNode }) {
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
const { user, isAdmin, signOut } = useAuth();
|
const { user, isAdmin, signOut } = useAuth();
|
||||||
const { items } = useCart();
|
const { items } = useCart();
|
||||||
|
const branding = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
|
setMobileMenuOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const brandName = branding.brand_name || 'LearnHub';
|
||||||
|
const logoUrl = branding.brand_logo_url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<header className="border-b-2 border-border bg-background">
|
<header className="border-b-2 border-border bg-background sticky top-0 z-50">
|
||||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<Link to="/" className="text-2xl font-bold">
|
<Link to="/" className="text-2xl font-bold flex items-center gap-2">
|
||||||
LearnHub
|
{logoUrl && (
|
||||||
|
<img src={logoUrl} alt={brandName} className="h-8 object-contain" />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{brandName}</span>
|
||||||
|
<span className="sm:hidden text-lg">{brandName}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-4">
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex items-center gap-4">
|
||||||
<Link to="/products" className="hover:underline font-medium">
|
<Link to="/products" className="hover:underline font-medium">
|
||||||
Products
|
Products
|
||||||
</Link>
|
</Link>
|
||||||
@@ -63,6 +76,76 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
<div className="md:hidden flex items-center gap-2">
|
||||||
|
<Link to="/checkout" className="relative p-2">
|
||||||
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-xs w-4 h-4 flex items-center justify-center">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="p-2">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="border-l-2 border-border w-80">
|
||||||
|
<nav className="flex flex-col space-y-4 mt-8">
|
||||||
|
<Link
|
||||||
|
to="/products"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-muted rounded-lg text-lg font-medium"
|
||||||
|
>
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
Products
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-muted rounded-lg text-lg font-medium"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-muted rounded-lg text-lg font-medium"
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-muted rounded-lg text-lg font-medium w-full text-left text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/auth"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-muted rounded-lg text-lg font-medium"
|
||||||
|
>
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
58
src/components/ProtectedRoute.tsx
Normal file
58
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
||||||
|
const { user, loading: authLoading, isAdmin } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
// Save current URL to redirect back after login
|
||||||
|
const currentPath = window.location.pathname + window.location.search;
|
||||||
|
sessionStorage.setItem('redirectAfterLogin', currentPath);
|
||||||
|
navigate('/auth');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin role if required (only after user is loaded AND admin check is complete)
|
||||||
|
if (!authLoading && user && requireAdmin && !isAdmin) {
|
||||||
|
// Redirect non-admin users to member dashboard
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
}, [user, authLoading, isAdmin, navigate, requireAdmin]);
|
||||||
|
|
||||||
|
// Show loading skeleton while checking auth
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-4">
|
||||||
|
<Skeleton className="h-10 w-1/3" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render children if user is not authenticated
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render if admin access required but user is not admin
|
||||||
|
if (requireAdmin && !isAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@ import StarterKit from '@tiptap/starter-kit';
|
|||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link';
|
||||||
import Image from '@tiptap/extension-image';
|
import Image from '@tiptap/extension-image';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
|
import CodeBlock from '@tiptap/extension-code-block';
|
||||||
|
import { Node } from '@tiptap/core';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
|
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
|
||||||
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
|
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
|
||||||
Maximize2, Minimize2
|
Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus, Code, Copy, Check
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
@@ -16,6 +19,38 @@ import { toast } from '@/hooks/use-toast';
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { common, createLowlight } from 'lowlight';
|
||||||
|
|
||||||
|
// Register common languages for syntax highlighting
|
||||||
|
const lowlight = createLowlight(common);
|
||||||
|
|
||||||
|
// Code Block Component with Copy Button
|
||||||
|
const CodeBlockWithCopy = ({ node }: { node: any }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const code = node.textContent;
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10 h-7 px-2"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||||
|
</Button>
|
||||||
|
<pre className="line-numbers">
|
||||||
|
<code>{code}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -49,6 +84,191 @@ const ResizableImage = Image.extend({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom Button extension for email templates
|
||||||
|
const EmailButton = Node.create({
|
||||||
|
name: 'emailButton',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
url: {
|
||||||
|
default: '#',
|
||||||
|
parseHTML: element => element.getAttribute('data-url') || '#',
|
||||||
|
renderHTML: attributes => ({
|
||||||
|
'data-url': attributes.url,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
default: 'Button',
|
||||||
|
parseHTML: element => element.textContent || 'Button',
|
||||||
|
renderHTML: attributes => ({}),
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
default: false,
|
||||||
|
parseHTML: element => element.classList.contains('btn-full'),
|
||||||
|
renderHTML: attributes => ({
|
||||||
|
class: attributes.fullWidth ? 'btn btn-full' : 'btn',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div[data-email-button]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes, node }) {
|
||||||
|
const { url, text, fullWidth } = node.attrs;
|
||||||
|
return [
|
||||||
|
'p',
|
||||||
|
{ style: 'margin-top: 20px; text-align: center;' },
|
||||||
|
[
|
||||||
|
'a',
|
||||||
|
{
|
||||||
|
href: url,
|
||||||
|
class: fullWidth ? 'btn btn-full' : 'btn',
|
||||||
|
'data-email-button': '',
|
||||||
|
style: `
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #000;
|
||||||
|
color: #FFF !important;
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 2px solid #000;
|
||||||
|
box-shadow: 4px 4px 0px 0px #000000;
|
||||||
|
margin: 10px 0;
|
||||||
|
transition: all 0.1s;
|
||||||
|
text-align: center;
|
||||||
|
${fullWidth ? 'width: 100%; box-sizing: border-box;' : ''}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
text || 'Button',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node, editor }) => {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.style.cssText = 'margin: 10px 0; border: 2px dashed #007acc; padding: 8px; border-radius: 4px; background: #f0f9ff;';
|
||||||
|
|
||||||
|
const button = document.createElement('a');
|
||||||
|
button.href = node.attrs.url;
|
||||||
|
button.textContent = node.attrs.text;
|
||||||
|
button.style.cssText = `
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #000;
|
||||||
|
color: #FFF;
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 2px solid #000;
|
||||||
|
box-shadow: 4px 4px 0px 0px #000000;
|
||||||
|
cursor: pointer;
|
||||||
|
${node.attrs.fullWidth ? 'width: 100%; text-align: center; box-sizing: border-box;' : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
dom.appendChild(button);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
destroy: () => {
|
||||||
|
dom.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom OTP Box extension
|
||||||
|
const OTPBox = Node.create({
|
||||||
|
name: 'otpBox',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
code: {
|
||||||
|
default: '123-456',
|
||||||
|
parseHTML: element => element.getAttribute('data-code') || '123-456',
|
||||||
|
renderHTML: attributes => ({
|
||||||
|
'data-code': attributes.code,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div[data-otp-box]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes, node }) {
|
||||||
|
const { code } = node.attrs;
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
'data-otp-box': '',
|
||||||
|
style: `
|
||||||
|
background-color: #F4F4F5;
|
||||||
|
border: 2px dashed #000;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
code,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node, editor }) => {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.style.cssText = 'margin: 10px 0; border: 2px dashed #007acc; padding: 8px; border-radius: 4px; background: #f0f9ff;';
|
||||||
|
dom.innerHTML = `
|
||||||
|
<div style="text-align: center; font-size: 12px; color: #007acc; margin-bottom: 4px;">OTP Box: ${node.attrs.code}</div>
|
||||||
|
<div style="
|
||||||
|
background-color: #F4F4F5;
|
||||||
|
border: 2px dashed #000;
|
||||||
|
padding: 20px;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
text-align: center;
|
||||||
|
">${node.attrs.code}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
destroy: () => {
|
||||||
|
dom.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) {
|
export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [selectedImage, setSelectedImage] = useState<{ src: string; width?: number; height?: number } | null>(null);
|
const [selectedImage, setSelectedImage] = useState<{ src: string; width?: number; height?: number } | null>(null);
|
||||||
@@ -57,7 +277,29 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit.configure({
|
||||||
|
heading: {
|
||||||
|
levels: [1, 2, 3],
|
||||||
|
},
|
||||||
|
horizontalRule: true,
|
||||||
|
codeBlock: false, // Disable default code block to use custom one
|
||||||
|
}),
|
||||||
|
CodeBlock.configure({
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage: 'text',
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'code-block-wrapper',
|
||||||
|
},
|
||||||
|
}).extend({
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Mod-Shift-c': () => this.editor.commands.toggleCodeBlock(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ['heading', 'paragraph'],
|
||||||
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
@@ -69,6 +311,8 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
class: 'max-w-full h-auto rounded-md cursor-pointer',
|
class: 'max-w-full h-auto rounded-md cursor-pointer',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
EmailButton,
|
||||||
|
OTPBox,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
@@ -110,6 +354,35 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
}
|
}
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const addButton = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const text = window.prompt('Teks Button:') || 'Button';
|
||||||
|
const url = window.prompt('URL Button:') || '#';
|
||||||
|
const fullWidth = window.confirm('Gunakan lebar penuh?');
|
||||||
|
|
||||||
|
editor.chain().focus().insertContent({
|
||||||
|
type: 'emailButton',
|
||||||
|
attrs: {
|
||||||
|
text,
|
||||||
|
url,
|
||||||
|
fullWidth,
|
||||||
|
},
|
||||||
|
}).run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const addOTPBox = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const code = window.prompt('Kode OTP (contoh: 123-456):') || '123-456';
|
||||||
|
|
||||||
|
editor.chain().focus().insertContent({
|
||||||
|
type: 'otpBox',
|
||||||
|
attrs: {
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
}).run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
|
||||||
const uploadImageToStorage = async (file: File): Promise<string | null> => {
|
const uploadImageToStorage = async (file: File): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const fileExt = file.name.split('.').pop();
|
const fileExt = file.name.split('.').pop();
|
||||||
@@ -232,7 +505,7 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
className={editor.isActive('bold') ? 'bg-accent' : ''}
|
className={editor.isActive('bold') ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<Bold className="w-4 h-4" />
|
<Bold className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -241,7 +514,7 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
className={editor.isActive('italic') ? 'bg-accent' : ''}
|
className={editor.isActive('italic') ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<Italic className="w-4 h-4" />
|
<Italic className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -250,7 +523,7 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
className={editor.isActive('heading', { level: 1 }) ? 'bg-accent' : ''}
|
className={editor.isActive('heading', { level: 1 }) ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<Heading1 className="w-4 h-4" />
|
<Heading1 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -259,7 +532,7 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
className={editor.isActive('heading', { level: 2 }) ? 'bg-accent' : ''}
|
className={editor.isActive('heading', { level: 2 }) ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<Heading2 className="w-4 h-4" />
|
<Heading2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -268,7 +541,7 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
className={editor.isActive('bulletList') ? 'bg-accent' : ''}
|
className={editor.isActive('bulletList') ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<List className="w-4 h-4" />
|
<List className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -277,7 +550,7 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
className={editor.isActive('orderedList') ? 'bg-accent' : ''}
|
className={editor.isActive('orderedList') ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<ListOrdered className="w-4 h-4" />
|
<ListOrdered className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -286,19 +559,113 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
className={editor.isActive('blockquote') ? 'bg-accent' : ''}
|
className={editor.isActive('blockquote') ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<Quote className="w-4 h-4" />
|
<Quote className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
className={editor.isActive('codeBlock') ? 'bg-primary text-primary-foreground' : ''}
|
||||||
|
title="Code Block (Ctrl+Shift+C)"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={addLink}
|
onClick={addLink}
|
||||||
className={editor.isActive('link') ? 'bg-accent' : ''}
|
className={editor.isActive('link') ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<LinkIcon className="w-4 h-4" />
|
<LinkIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Text Align Separator */}
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
|
{/* Text Align Buttons */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||||
|
className={editor.isActive({ textAlign: 'left' }) ? 'bg-primary text-primary-foreground' : ''}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
<AlignLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||||
|
className={editor.isActive({ textAlign: 'center' }) ? 'bg-primary text-primary-foreground' : ''}
|
||||||
|
title="Align Center"
|
||||||
|
>
|
||||||
|
<AlignCenter className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||||
|
className={editor.isActive({ textAlign: 'right' }) ? 'bg-primary text-primary-foreground' : ''}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
<AlignRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||||
|
className={editor.isActive({ textAlign: 'justify' }) ? 'bg-primary text-primary-foreground' : ''}
|
||||||
|
title="Justify"
|
||||||
|
>
|
||||||
|
<AlignJustify className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Spacer/Separator */}
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
|
title="Insert Spacer"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Email Components Separator */}
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
|
{/* Email Component Buttons */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addButton}
|
||||||
|
title="Tambah Email Button"
|
||||||
|
>
|
||||||
|
<MousePointer className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addOTPBox}
|
||||||
|
title="Tambah OTP Box"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Image Upload Separator */}
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<Button type="button" variant="ghost" size="sm" asChild disabled={uploading}>
|
<Button type="button" variant="ghost" size="sm" asChild disabled={uploading}>
|
||||||
<span className={uploading ? 'opacity-50' : ''}>
|
<span className={uploading ? 'opacity-50' : ''}>
|
||||||
@@ -384,7 +751,7 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
<div onPaste={handlePaste}>
|
<div onPaste={handlePaste}>
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={editor}
|
editor={editor}
|
||||||
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary"
|
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary [&_h1]:font-bold [&_h1]:text-2xl [&_h1]:mb-4 [&_h1]:mt-6 [&_h2]:font-bold [&_h2]:text-xl [&_h2]:mb-3 [&_h2]:mt-5 [&_p]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-1 [&_li]:marker:text-primary [&_hr]:border-border [&_hr]:my-4 [&_hr]:border-t-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{uploading && (
|
{uploading && (
|
||||||
|
|||||||
123
src/components/TimelineChapters.tsx
Normal file
123
src/components/TimelineChapters.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number; // Time in seconds
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineChaptersProps {
|
||||||
|
chapters: VideoChapter[];
|
||||||
|
isYouTube?: boolean;
|
||||||
|
onChapterClick?: (time: number) => void;
|
||||||
|
currentTime?: number; // Current video playback time in seconds
|
||||||
|
accentColor?: string;
|
||||||
|
clickable?: boolean; // Control whether chapters are clickable
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineChapters({
|
||||||
|
chapters,
|
||||||
|
onChapterClick,
|
||||||
|
currentTime = 0,
|
||||||
|
accentColor = '#f97316',
|
||||||
|
clickable = true,
|
||||||
|
}: TimelineChaptersProps) {
|
||||||
|
// Format time in seconds to MM:SS or HH:MM:SS
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a chapter is currently active
|
||||||
|
const isChapterActive = (index: number): boolean => {
|
||||||
|
if (currentTime === 0) return false;
|
||||||
|
|
||||||
|
const chapter = chapters[index];
|
||||||
|
const nextChapter = chapters[index + 1];
|
||||||
|
|
||||||
|
return currentTime >= chapter.time && (!nextChapter || currentTime < nextChapter.time);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chapters.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h3 className="font-semibold">Timeline</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable chapter list with max-height */}
|
||||||
|
<div className="max-h-[400px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 hover:scrollbar-thumb-gray-400">
|
||||||
|
{chapters.map((chapter, index) => {
|
||||||
|
const active = isChapterActive(index);
|
||||||
|
const isLast = index === chapters.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => clickable && onChapterClick && onChapterClick(chapter.time)}
|
||||||
|
disabled={!clickable}
|
||||||
|
className={`
|
||||||
|
w-full flex items-start gap-3 p-3 rounded-lg transition-all text-left
|
||||||
|
${clickable ? 'hover:bg-muted cursor-pointer' : 'cursor-not-allowed opacity-75'}
|
||||||
|
${active
|
||||||
|
? `bg-primary/10 border-l-4`
|
||||||
|
: 'border-l-4 border-transparent'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={
|
||||||
|
active
|
||||||
|
? { borderColor: accentColor, backgroundColor: `${accentColor}10` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
title={clickable ? `Klik untuk lompat ke ${formatTime(chapter.time)}` : 'Belum membeli produk ini'}
|
||||||
|
>
|
||||||
|
{/* Timestamp */}
|
||||||
|
<div className={`
|
||||||
|
font-mono text-sm font-semibold shrink-0 pt-0.5
|
||||||
|
${active ? 'text-primary' : 'text-muted-foreground'}
|
||||||
|
`} style={active ? { color: accentColor } : undefined}>
|
||||||
|
{formatTime(chapter.time)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapter Title - supports HTML with sanitized output */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex-1 text-sm prose prose-sm max-w-none
|
||||||
|
${active ? 'font-medium' : 'text-muted-foreground'}
|
||||||
|
`}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(chapter.title, {
|
||||||
|
ALLOWED_TAGS: ['code', 'strong', 'em', 'b', 'i', 'u', 'br', 'p', 'span'],
|
||||||
|
ALLOWED_ATTR: ['class', 'style'],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Active indicator */}
|
||||||
|
{active && (
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full shrink-0 mt-1.5"
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/UnpaidOrderAlert.tsx
Normal file
58
src/components/UnpaidOrderAlert.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
interface UnpaidOrderAlertProps {
|
||||||
|
orderId: string;
|
||||||
|
expiresAt: string; // ISO timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnpaidOrderAlert({ orderId, expiresAt }: UnpaidOrderAlertProps) {
|
||||||
|
// Non-dismissable alert - NO onDismiss prop
|
||||||
|
// Alert will auto-hide when QR expires via Dashboard logic
|
||||||
|
|
||||||
|
const formatExpiryTime = (isoString: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(isoString).toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 border-2">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="bg-orange-100 p-2 rounded-full flex-shrink-0">
|
||||||
|
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-semibold text-orange-900 mb-1 flex items-center gap-2">
|
||||||
|
Pembayaran Belum Selesai
|
||||||
|
<span className="text-xs bg-orange-200 text-orange-800 px-2 py-0.5 rounded">
|
||||||
|
Segera
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<AlertDescription className="text-orange-700">
|
||||||
|
Anda memiliki pesanan konsultasi yang menunggu pembayaran. QRIS kode akan kedaluwarsa pada{" "}
|
||||||
|
<strong>{formatExpiryTime(expiresAt)}</strong>.
|
||||||
|
</AlertDescription>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 bg-orange-600 hover:bg-orange-700 text-white shadow-md"
|
||||||
|
>
|
||||||
|
<Link to={`/orders/${orderId}`}>
|
||||||
|
Lihat & Bayar Sekarang →
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
589
src/components/VideoPlayerWithChapters.tsx
Normal file
589
src/components/VideoPlayerWithChapters.tsx
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, useCallback } from 'react';
|
||||||
|
import { Plyr } from 'plyr-react';
|
||||||
|
import 'plyr/dist/plyr.css';
|
||||||
|
import { useAdiloPlayer } from '@/hooks/useAdiloPlayer';
|
||||||
|
import { useVideoProgress } from '@/hooks/useVideoProgress';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number; // Time in seconds
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoPlayerWithChaptersProps {
|
||||||
|
videoUrl?: string;
|
||||||
|
embedCode?: string | null;
|
||||||
|
m3u8Url?: string;
|
||||||
|
mp4Url?: string;
|
||||||
|
videoHost?: 'youtube' | 'adilo' | 'unknown';
|
||||||
|
chapters?: VideoChapter[];
|
||||||
|
accentColor?: string;
|
||||||
|
onChapterChange?: (chapter: VideoChapter) => void;
|
||||||
|
onTimeUpdate?: (time: number) => void;
|
||||||
|
className?: string;
|
||||||
|
videoId?: string; // For progress tracking
|
||||||
|
videoType?: 'lesson' | 'webinar'; // For progress tracking
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPlayerRef {
|
||||||
|
jumpToTime: (time: number) => void;
|
||||||
|
getCurrentTime: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
|
||||||
|
videoUrl,
|
||||||
|
embedCode,
|
||||||
|
m3u8Url,
|
||||||
|
mp4Url,
|
||||||
|
videoHost = 'unknown',
|
||||||
|
chapters = [],
|
||||||
|
accentColor,
|
||||||
|
onChapterChange,
|
||||||
|
onTimeUpdate,
|
||||||
|
className = '',
|
||||||
|
videoId,
|
||||||
|
videoType,
|
||||||
|
}, ref) => {
|
||||||
|
const plyrRef = useRef<any>(null);
|
||||||
|
const currentChapterIndexRef = useRef<number>(-1);
|
||||||
|
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [playerInstance, setPlayerInstance] = useState<any>(null);
|
||||||
|
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||||
|
const [resumeTime, setResumeTime] = useState(0);
|
||||||
|
const saveProgressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const hasShownResumePromptRef = useRef(false);
|
||||||
|
|
||||||
|
// Determine if using Adilo (M3U8) or YouTube
|
||||||
|
const isAdilo = videoHost === 'adilo' || m3u8Url;
|
||||||
|
const isYouTube = videoHost === 'youtube' || (videoUrl && (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')));
|
||||||
|
|
||||||
|
// Video progress tracking
|
||||||
|
const { progress, loading: progressLoading, saveProgress: saveProgressDirect, hasProgress } = useVideoProgress({
|
||||||
|
videoId: videoId || '',
|
||||||
|
videoType: videoType || 'lesson',
|
||||||
|
duration: playerInstance?.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounced save function (saves every 5 seconds)
|
||||||
|
const saveProgressDebounced = useCallback((time: number) => {
|
||||||
|
if (saveProgressTimeoutRef.current) {
|
||||||
|
clearTimeout(saveProgressTimeoutRef.current);
|
||||||
|
}
|
||||||
|
saveProgressTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveProgressDirect(time);
|
||||||
|
}, 5000);
|
||||||
|
}, [saveProgressDirect]);
|
||||||
|
|
||||||
|
// Stable callback for finding current chapter
|
||||||
|
const findCurrentChapter = useCallback((time: number) => {
|
||||||
|
if (chapters.length === 0) return -1;
|
||||||
|
|
||||||
|
let index = chapters.findIndex((chapter, i) => {
|
||||||
|
const nextChapter = chapters[i + 1];
|
||||||
|
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (index === -1 && time < chapters[0].time) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}, [chapters]);
|
||||||
|
|
||||||
|
// Stable onTimeUpdate callback for Adilo player
|
||||||
|
const handleAdiloTimeUpdate = useCallback((time: number) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
onTimeUpdate?.(time);
|
||||||
|
saveProgressDebounced(time);
|
||||||
|
|
||||||
|
// Find and update current chapter for Adilo
|
||||||
|
const index = findCurrentChapter(time);
|
||||||
|
if (index !== currentChapterIndexRef.current) {
|
||||||
|
currentChapterIndexRef.current = index;
|
||||||
|
setCurrentChapterIndex(index);
|
||||||
|
if (index >= 0 && onChapterChange) {
|
||||||
|
onChapterChange(chapters[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onTimeUpdate, onChapterChange, findCurrentChapter, chapters, saveProgressDebounced]);
|
||||||
|
|
||||||
|
// Adilo player hook
|
||||||
|
const adiloPlayer = useAdiloPlayer({
|
||||||
|
m3u8Url,
|
||||||
|
mp4Url,
|
||||||
|
onTimeUpdate: handleAdiloTimeUpdate,
|
||||||
|
accentColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get YouTube video ID
|
||||||
|
const getYouTubeId = (url: string): string | null => {
|
||||||
|
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s/]+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert embed code to YouTube URL if possible
|
||||||
|
const getYouTubeUrlFromEmbed = (embed: string): string | null => {
|
||||||
|
const match = embed.match(/src=["'](?:https?:)?\/\/(?:www\.)?youtube\.com\/embed\/([^"'\s?]*)/);
|
||||||
|
return match ? `https://www.youtube.com/watch?v=${match[1]}` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine which video source to use
|
||||||
|
const effectiveVideoUrl = isYouTube ? videoUrl : (embedCode ? getYouTubeUrlFromEmbed(embedCode) : videoUrl);
|
||||||
|
const useEmbed = !isYouTube && embedCode;
|
||||||
|
|
||||||
|
// Block right-click and dev tools
|
||||||
|
useEffect(() => {
|
||||||
|
const blockRightClick = (e: MouseEvent | KeyboardEvent) => {
|
||||||
|
// Block right-click
|
||||||
|
if (e.type === 'contextmenu') {
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block F12, Ctrl+Shift+I, Ctrl+Shift+J, Ctrl+U
|
||||||
|
const keyboardEvent = e as KeyboardEvent;
|
||||||
|
if (
|
||||||
|
keyboardEvent.key === 'F12' ||
|
||||||
|
(keyboardEvent.ctrlKey && keyboardEvent.shiftKey && (keyboardEvent.key === 'I' || keyboardEvent.key === 'J')) ||
|
||||||
|
(keyboardEvent.ctrlKey && keyboardEvent.key === 'U')
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('contextmenu', blockRightClick);
|
||||||
|
document.addEventListener('keydown', blockRightClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('contextmenu', blockRightClick);
|
||||||
|
document.removeEventListener('keydown', blockRightClick);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize Plyr and set up time tracking
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isYouTube) return;
|
||||||
|
|
||||||
|
// Wait for player to initialize
|
||||||
|
const checkPlayer = setInterval(() => {
|
||||||
|
const player = plyrRef.current?.plyr;
|
||||||
|
if (player) {
|
||||||
|
clearInterval(checkPlayer);
|
||||||
|
setPlayerInstance(player);
|
||||||
|
|
||||||
|
// Set up time tracking using Plyr's event API
|
||||||
|
if (typeof player.on === 'function') {
|
||||||
|
player.on('timeupdate', () => {
|
||||||
|
const time = player.currentTime;
|
||||||
|
setCurrentTime(time);
|
||||||
|
|
||||||
|
if (onTimeUpdate) {
|
||||||
|
onTimeUpdate(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProgressDebounced(time);
|
||||||
|
|
||||||
|
// Find current chapter
|
||||||
|
const index = findCurrentChapter(time);
|
||||||
|
if (index !== currentChapterIndexRef.current) {
|
||||||
|
currentChapterIndexRef.current = index;
|
||||||
|
setCurrentChapterIndex(index);
|
||||||
|
if (index >= 0 && onChapterChange) {
|
||||||
|
onChapterChange(chapters[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: poll for time updates
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const time = player.currentTime;
|
||||||
|
setCurrentTime(time);
|
||||||
|
|
||||||
|
if (onTimeUpdate) {
|
||||||
|
onTimeUpdate(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProgressDebounced(time);
|
||||||
|
|
||||||
|
// Find current chapter
|
||||||
|
const index = findCurrentChapter(time);
|
||||||
|
if (index !== currentChapterIndexRef.current) {
|
||||||
|
currentChapterIndexRef.current = index;
|
||||||
|
setCurrentChapterIndex(index);
|
||||||
|
if (index >= 0 && onChapterChange) {
|
||||||
|
onChapterChange(chapters[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Store interval ID for cleanup
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearInterval(checkPlayer);
|
||||||
|
}, [isYouTube, findCurrentChapter, onChapterChange, onTimeUpdate, chapters, saveProgressDebounced]);
|
||||||
|
|
||||||
|
// Jump to specific time using Plyr API or Adilo player
|
||||||
|
const jumpToTime = useCallback((time: number) => {
|
||||||
|
if (isAdilo) {
|
||||||
|
const video = adiloPlayer.videoRef.current;
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
console.warn('Video element not available for jump');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to jump immediately if video is seekable
|
||||||
|
if (video.seekable && video.seekable.length > 0) {
|
||||||
|
console.log(`🎯 Jumping to ${time}s (video seekable)`);
|
||||||
|
video.currentTime = time;
|
||||||
|
} else {
|
||||||
|
// Video not seekable yet, wait for it to be ready
|
||||||
|
console.log(`⏳ Video not seekable yet, waiting to jump to ${time}s`);
|
||||||
|
|
||||||
|
const onCanPlay = () => {
|
||||||
|
console.log(`🎯 Video seekable now, jumping to ${time}s`);
|
||||||
|
video.currentTime = time;
|
||||||
|
video.removeEventListener('canplay', onCanPlay);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('canplay', onCanPlay, { once: true });
|
||||||
|
}
|
||||||
|
} else if (playerInstance) {
|
||||||
|
playerInstance.currentTime = time;
|
||||||
|
playerInstance.play();
|
||||||
|
}
|
||||||
|
}, [isAdilo, adiloPlayer.videoRef, playerInstance]);
|
||||||
|
|
||||||
|
const getCurrentTime = () => {
|
||||||
|
return currentTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset resume prompt flag when videoId changes (switching lessons)
|
||||||
|
useEffect(() => {
|
||||||
|
hasShownResumePromptRef.current = false;
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
}, [videoId]);
|
||||||
|
|
||||||
|
// Check for saved progress and show resume prompt (only once on mount)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasShownResumePromptRef.current && !progressLoading && hasProgress && progress && progress.last_position > 5) {
|
||||||
|
setShowResumePrompt(true);
|
||||||
|
setResumeTime(progress.last_position);
|
||||||
|
hasShownResumePromptRef.current = true;
|
||||||
|
}
|
||||||
|
}, [progressLoading, hasProgress, progress]);
|
||||||
|
|
||||||
|
const handleResume = () => {
|
||||||
|
jumpToTime(resumeTime);
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartFromBeginning = () => {
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save progress immediately on pause/ended
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adiloPlayer.videoRef.current) return;
|
||||||
|
|
||||||
|
const video = adiloPlayer.videoRef.current;
|
||||||
|
const handlePause = () => {
|
||||||
|
// Save immediately on pause
|
||||||
|
saveProgressDirect(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
// Save immediately on end
|
||||||
|
saveProgressDirect(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('pause', handlePause);
|
||||||
|
video.addEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('pause', handlePause);
|
||||||
|
video.removeEventListener('ended', handleEnded);
|
||||||
|
};
|
||||||
|
}, [adiloPlayer.videoRef, saveProgressDirect]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyPress = (e: KeyboardEvent) => {
|
||||||
|
// Ignore if user is typing in an input
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLSelectElement ||
|
||||||
|
e.target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = isAdilo ? adiloPlayer.videoRef.current : playerInstance;
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
// Space: Play/Pause
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isAdilo) {
|
||||||
|
const video = player as HTMLVideoElement;
|
||||||
|
video.paused ? video.play() : video.pause();
|
||||||
|
} else {
|
||||||
|
player.playing ? player.pause() : player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow Left: Back 5 seconds
|
||||||
|
if (e.code === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||||
|
jumpToTime(Math.max(0, currentTime - 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow Right: Forward 5 seconds
|
||||||
|
if (e.code === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||||
|
const duration = isAdilo ? (player as HTMLVideoElement).duration : player.duration;
|
||||||
|
jumpToTime(Math.min(duration, currentTime + 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow Up: Volume up 10%
|
||||||
|
if (e.code === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isAdilo) {
|
||||||
|
const newVolume = Math.min(1, player.volume + 0.1);
|
||||||
|
player.volume = newVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow Down: Volume down 10%
|
||||||
|
if (e.code === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isAdilo) {
|
||||||
|
const newVolume = Math.max(0, player.volume - 0.1);
|
||||||
|
player.volume = newVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// F: Fullscreen
|
||||||
|
if (e.code === 'KeyF') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isAdilo) {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
(player as HTMLVideoElement).parentElement?.requestFullscreen();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.fullscreen.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M: Mute
|
||||||
|
if (e.code === 'KeyM') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isAdilo) {
|
||||||
|
(player as HTMLVideoElement).muted = !(player as HTMLVideoElement).muted;
|
||||||
|
} else {
|
||||||
|
player.muted = !player.muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// J: Back 10 seconds
|
||||||
|
if (e.code === 'KeyJ') {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||||
|
jumpToTime(Math.max(0, currentTime - 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// L: Forward 10 seconds
|
||||||
|
if (e.code === 'KeyL') {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||||
|
const duration = isAdilo ? (player as HTMLVideoElement).duration : player.duration;
|
||||||
|
jumpToTime(Math.min(duration, currentTime + 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyPress);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyPress);
|
||||||
|
}, [isAdilo, adiloPlayer.isReady, playerInstance]);
|
||||||
|
|
||||||
|
// Expose methods via ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
jumpToTime,
|
||||||
|
getCurrentTime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Adilo M3U8 Player with Video.js
|
||||||
|
if (isAdilo) {
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<div className="aspect-video rounded-lg overflow-hidden bg-black vjs-big-play-centered">
|
||||||
|
<video
|
||||||
|
ref={adiloPlayer.videoRef}
|
||||||
|
className="video-js vjs-default-skin vjs-big-play-centered vjs-fill"
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resume prompt */}
|
||||||
|
{showResumePrompt && (
|
||||||
|
<div className="absolute inset-0 bg-black/80 flex items-center justify-center z-10 rounded-lg">
|
||||||
|
<div className="text-center space-y-4 p-6">
|
||||||
|
<div className="text-white text-lg font-semibold">
|
||||||
|
Lanjutkan dari posisi terakhir?
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 text-sm">
|
||||||
|
{Math.floor(resumeTime / 60)}:{String(Math.floor(resumeTime % 60)).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={handleResume}
|
||||||
|
className="bg-primary hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Lanjutkan
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleStartFromBeginning}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
|
||||||
|
>
|
||||||
|
Mulai dari awal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useEmbed) {
|
||||||
|
// Custom embed (Vimeo, etc. - not Adilo anymore)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`aspect-video rounded-lg overflow-hidden ${className}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: embedCode }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null;
|
||||||
|
|
||||||
|
// Apply custom accent color
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accentColor || !plyrRef.current) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.plyr__control--overlared,
|
||||||
|
.plyr__controls .plyr__control.plyr__tab-focus,
|
||||||
|
.plyr__controls .plyr__control:hover,
|
||||||
|
.plyr__controls .plyr__control[aria-current='true'] {
|
||||||
|
background: ${accentColor} !important;
|
||||||
|
}
|
||||||
|
.plyr__progress__value {
|
||||||
|
background: ${accentColor} !important;
|
||||||
|
}
|
||||||
|
.plyr__volume__value {
|
||||||
|
background: ${accentColor} !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(style);
|
||||||
|
};
|
||||||
|
}, [accentColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{youtubeId && (
|
||||||
|
<>
|
||||||
|
<div style={{ position: 'relative', pointerEvents: 'auto' }}>
|
||||||
|
<Plyr
|
||||||
|
ref={plyrRef}
|
||||||
|
source={{
|
||||||
|
type: 'video',
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
src: `https://www.youtube.com/watch?v=${youtubeId}`,
|
||||||
|
provider: 'youtube',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
controls: [
|
||||||
|
'play-large',
|
||||||
|
'play',
|
||||||
|
'progress',
|
||||||
|
'current-time',
|
||||||
|
'mute',
|
||||||
|
'volume',
|
||||||
|
'captions',
|
||||||
|
'settings',
|
||||||
|
'pip',
|
||||||
|
'airplay',
|
||||||
|
'fullscreen',
|
||||||
|
],
|
||||||
|
speed: {
|
||||||
|
selected: 1,
|
||||||
|
options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
noCookie: true,
|
||||||
|
rel: 0,
|
||||||
|
showinfo: 0,
|
||||||
|
iv_load_policy: 3,
|
||||||
|
modestbranding: 1,
|
||||||
|
controls: 0,
|
||||||
|
disablekb: 1,
|
||||||
|
fs: 0,
|
||||||
|
},
|
||||||
|
hideControls: false,
|
||||||
|
keyboardShortcuts: {
|
||||||
|
focused: true,
|
||||||
|
global: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
/* Block YouTube UI overlays */
|
||||||
|
.plyr__video-wrapper .plyr__video-embed iframe {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only allow clicks on Plyr controls */
|
||||||
|
.plyr__controls,
|
||||||
|
.plyr__control--overlaid {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide YouTube's native play button that appears behind */
|
||||||
|
.plyr__video-wrapper::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
157
src/components/admin/ChaptersEditor.tsx
Normal file
157
src/components/admin/ChaptersEditor.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Plus, Trash2, GripVertical } from 'lucide-react';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number; // Time in seconds
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChaptersEditorProps {
|
||||||
|
chapters: VideoChapter[];
|
||||||
|
onChange: (chapters: VideoChapter[]) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChaptersEditor({ chapters, onChange, className = '' }: ChaptersEditorProps) {
|
||||||
|
const [chaptersList, setChaptersList] = useState<VideoChapter[]>(
|
||||||
|
chapters.length > 0 ? chapters : [{ time: 0, title: '' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync internal state when prop changes (e.g., when switching between lessons)
|
||||||
|
useEffect(() => {
|
||||||
|
if (chapters.length > 0) {
|
||||||
|
setChaptersList(chapters);
|
||||||
|
} else {
|
||||||
|
setChaptersList([{ time: 0, title: '' }]);
|
||||||
|
}
|
||||||
|
}, [chapters]);
|
||||||
|
|
||||||
|
const updateTime = (index: number, value: string) => {
|
||||||
|
const newChapters = [...chaptersList];
|
||||||
|
const parts = value.split(':').map(Number);
|
||||||
|
|
||||||
|
let totalSeconds = 0;
|
||||||
|
if (parts.length === 3) {
|
||||||
|
// HH:MM:SS format
|
||||||
|
const [hours = 0, minutes = 0, seconds = 0] = parts;
|
||||||
|
totalSeconds = hours * 3600 + minutes * 60 + seconds;
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
// MM:SS format
|
||||||
|
const [minutes = 0, seconds = 0] = parts;
|
||||||
|
totalSeconds = minutes * 60 + seconds;
|
||||||
|
} else {
|
||||||
|
// Just seconds or invalid
|
||||||
|
totalSeconds = parts[0] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
newChapters[index].time = totalSeconds;
|
||||||
|
setChaptersList(newChapters);
|
||||||
|
onChange(newChapters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTitle = (index: number, title: string) => {
|
||||||
|
const newChapters = [...chaptersList];
|
||||||
|
newChapters[index].title = title;
|
||||||
|
setChaptersList(newChapters);
|
||||||
|
onChange(newChapters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChapter = () => {
|
||||||
|
const newChapters = [...chaptersList, { time: 0, title: '' }];
|
||||||
|
setChaptersList(newChapters);
|
||||||
|
onChange(newChapters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeChapter = (index: number) => {
|
||||||
|
if (chaptersList.length <= 1) return;
|
||||||
|
const newChapters = chaptersList.filter((_, i) => i !== index);
|
||||||
|
setChaptersList(newChapters);
|
||||||
|
onChange(newChapters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeForInput = (seconds: number): string => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Timeline Chapters</CardTitle>
|
||||||
|
<Button size="sm" onClick={addChapter}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Add Chapter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add chapter markers to help users navigate through the video content
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{chaptersList.map((chapter, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<GripVertical className="w-5 h-5 text-muted-foreground cursor-move" />
|
||||||
|
|
||||||
|
{/* Time Input */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`time-${index}`} className="sr-only">
|
||||||
|
Time
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`time-${index}`}
|
||||||
|
type="text"
|
||||||
|
value={formatTimeForInput(chapter.time)}
|
||||||
|
onChange={(e) => updateTime(index, e.target.value)}
|
||||||
|
placeholder="0:00 or 1:23:34"
|
||||||
|
pattern="([0-9]+:)?[0-9]+:[0-5][0-9]"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title Input */}
|
||||||
|
<div className="flex-[3]">
|
||||||
|
<Label htmlFor={`title-${index}`} className="sr-only">
|
||||||
|
Chapter Title
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`title-${index}`}
|
||||||
|
type="text"
|
||||||
|
value={chapter.title}
|
||||||
|
onChange={(e) => updateTitle(index, e.target.value)}
|
||||||
|
placeholder="Chapter title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removeChapter(index)}
|
||||||
|
disabled={chaptersList.length <= 1}
|
||||||
|
title="Remove chapter"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
|
||||||
|
<p>💡 <strong>Format:</strong> Enter time as MM:SS or HH:MM:SS (e.g., 5:30 or 1:23:34)</p>
|
||||||
|
<p>📌 <strong>Note:</strong> Chapters work with both YouTube and Adilo videos.</p>
|
||||||
|
<p>✨ <strong>Tip:</strong> Chapters are automatically sorted by time when displayed.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,17 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
|
import { ChaptersEditor } from './ChaptersEditor';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,8 +30,14 @@ interface Lesson {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
|
youtube_url: string | null;
|
||||||
|
embed_code: string | null;
|
||||||
|
m3u8_url?: string | null;
|
||||||
|
mp4_url?: string | null;
|
||||||
|
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||||
position: number;
|
position: number;
|
||||||
release_at: string | null;
|
release_at: string | null;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CurriculumEditorProps {
|
interface CurriculumEditorProps {
|
||||||
@@ -46,7 +60,13 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
video_url: '',
|
video_url: '',
|
||||||
|
youtube_url: '',
|
||||||
|
embed_code: '',
|
||||||
|
m3u8_url: '',
|
||||||
|
mp4_url: '',
|
||||||
|
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||||
release_at: '',
|
release_at: '',
|
||||||
|
chapters: [] as VideoChapter[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
||||||
@@ -64,7 +84,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
.order('position'),
|
.order('position'),
|
||||||
supabase
|
supabase
|
||||||
.from('bootcamp_lessons')
|
.from('bootcamp_lessons')
|
||||||
.select('*')
|
.select('id, module_id, title, content, video_url, youtube_url, embed_code, m3u8_url, mp4_url, video_host, position, release_at, chapters')
|
||||||
.order('position'),
|
.order('position'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -168,7 +188,13 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
video_url: '',
|
video_url: '',
|
||||||
|
youtube_url: '',
|
||||||
|
embed_code: '',
|
||||||
|
m3u8_url: '',
|
||||||
|
mp4_url: '',
|
||||||
|
video_host: 'youtube',
|
||||||
release_at: '',
|
release_at: '',
|
||||||
|
chapters: [],
|
||||||
});
|
});
|
||||||
setLessonDialogOpen(true);
|
setLessonDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -180,7 +206,13 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
title: lesson.title,
|
title: lesson.title,
|
||||||
content: lesson.content || '',
|
content: lesson.content || '',
|
||||||
video_url: lesson.video_url || '',
|
video_url: lesson.video_url || '',
|
||||||
|
youtube_url: lesson.youtube_url || '',
|
||||||
|
embed_code: lesson.embed_code || '',
|
||||||
|
m3u8_url: lesson.m3u8_url || '',
|
||||||
|
mp4_url: lesson.mp4_url || '',
|
||||||
|
video_host: lesson.video_host || 'youtube',
|
||||||
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
||||||
|
chapters: lesson.chapters ? [...lesson.chapters] : [], // Create a copy to avoid mutation
|
||||||
});
|
});
|
||||||
setLessonDialogOpen(true);
|
setLessonDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -196,7 +228,13 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
title: lessonForm.title,
|
title: lessonForm.title,
|
||||||
content: lessonForm.content || null,
|
content: lessonForm.content || null,
|
||||||
video_url: lessonForm.video_url || null,
|
video_url: lessonForm.video_url || null,
|
||||||
|
youtube_url: lessonForm.youtube_url || null,
|
||||||
|
embed_code: lessonForm.embed_code || null,
|
||||||
|
m3u8_url: lessonForm.m3u8_url || null,
|
||||||
|
mp4_url: lessonForm.mp4_url || null,
|
||||||
|
video_host: lessonForm.video_host || 'youtube',
|
||||||
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
|
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
|
||||||
|
chapters: lessonForm.chapters || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingLesson) {
|
if (editingLesson) {
|
||||||
@@ -279,7 +317,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
<h3 className="text-lg font-semibold">Curriculum</h3>
|
<h3 className="text-lg font-semibold">Curriculum</h3>
|
||||||
<Button onClick={handleNewModule} size="sm" className="shadow-sm">
|
<Button onClick={handleNewModule} size="sm" className="shadow-sm">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@@ -298,7 +336,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
{modules.map((module, moduleIndex) => (
|
{modules.map((module, moduleIndex) => (
|
||||||
<Card key={module.id} className="border-2 border-border">
|
<Card key={module.id} className="border-2 border-border">
|
||||||
<CardHeader className="py-3">
|
<CardHeader className="py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleModule(module.id)}
|
onClick={() => toggleModule(module.id)}
|
||||||
className="flex items-center gap-2 text-left"
|
className="flex items-center gap-2 text-left"
|
||||||
@@ -341,7 +379,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
{getLessonsForModule(module.id).map((lesson, lessonIndex) => (
|
{getLessonsForModule(module.id).map((lesson, lessonIndex) => (
|
||||||
<div
|
<div
|
||||||
key={lesson.id}
|
key={lesson.id}
|
||||||
className="flex items-center justify-between p-2 bg-muted rounded-md"
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-2 bg-muted rounded-md"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -432,24 +470,85 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
className="border-2"
|
className="border-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Video URL</Label>
|
<Label>Video Host</Label>
|
||||||
<Input
|
<Select
|
||||||
value={lessonForm.video_url}
|
value={lessonForm.video_host}
|
||||||
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
onValueChange={(value: 'youtube' | 'adilo') => setLessonForm({ ...lessonForm, video_host: value })}
|
||||||
placeholder="https://youtube.com/... or https://vimeo.com/..."
|
>
|
||||||
className="border-2"
|
<SelectTrigger className="border-2">
|
||||||
/>
|
<SelectValue placeholder="Select video host" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="youtube">YouTube</SelectItem>
|
||||||
|
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* YouTube URL */}
|
||||||
|
{lessonForm.video_host === 'youtube' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>YouTube URL</Label>
|
||||||
|
<Input
|
||||||
|
value={lessonForm.video_url}
|
||||||
|
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Paste YouTube URL here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Adilo URLs */}
|
||||||
|
{lessonForm.video_host === 'adilo' && (
|
||||||
|
<div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>M3U8 URL (Primary)</Label>
|
||||||
|
<Input
|
||||||
|
value={lessonForm.m3u8_url}
|
||||||
|
onChange={(e) => setLessonForm({ ...lessonForm, m3u8_url: e.target.value })}
|
||||||
|
placeholder="https://adilo.bigcommand.com/m3u8/..."
|
||||||
|
className="border-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
HLS streaming URL from Adilo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>MP4 URL (Optional Fallback)</Label>
|
||||||
|
<Input
|
||||||
|
value={lessonForm.mp4_url}
|
||||||
|
onChange={(e) => setLessonForm({ ...lessonForm, mp4_url: e.target.value })}
|
||||||
|
placeholder="https://adilo.bigcommand.com/videos/..."
|
||||||
|
className="border-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Direct MP4 file for legacy browsers (optional)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChaptersEditor
|
||||||
|
chapters={lessonForm.chapters || []}
|
||||||
|
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
|
||||||
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Content (HTML)</Label>
|
<Label>Content</Label>
|
||||||
<Textarea
|
<RichTextEditor
|
||||||
value={lessonForm.content}
|
content={lessonForm.content}
|
||||||
onChange={(e) => setLessonForm({ ...lessonForm, content: e.target.value })}
|
onChange={(html) => setLessonForm({ ...lessonForm, content: html })}
|
||||||
placeholder="Lesson content..."
|
placeholder="Write your lesson content here... Use code blocks for syntax highlighting."
|
||||||
rows={6}
|
className="min-h-[400px]"
|
||||||
className="border-2 font-mono text-sm"
|
|
||||||
/>
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Supports rich text formatting, code blocks with syntax highlighting, images, and more.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Release Date (optional)</Label>
|
<Label>Release Date (optional)</Label>
|
||||||
|
|||||||
398
src/components/admin/EmailTemplatePreview.tsx
Normal file
398
src/components/admin/EmailTemplatePreview.tsx
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { Eye, Send, Mail, X } from 'lucide-react';
|
||||||
|
import { EmailTemplateRenderer, ShortcodeProcessor } from '@/lib/email-templates/master-template';
|
||||||
|
|
||||||
|
interface NotificationTemplate {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
email_subject: string;
|
||||||
|
email_body_html: string;
|
||||||
|
webhook_url: string;
|
||||||
|
last_payload_example?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailTemplatePreviewProps {
|
||||||
|
template: NotificationTemplate;
|
||||||
|
onTest?: (template: NotificationTemplate) => void;
|
||||||
|
isTestSending?: boolean;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailTemplatePreview({
|
||||||
|
template,
|
||||||
|
onTest,
|
||||||
|
isTestSending = false,
|
||||||
|
open,
|
||||||
|
onClose
|
||||||
|
}: EmailTemplatePreviewProps) {
|
||||||
|
const [previewMode, setPreviewMode] = useState<'master' | 'content'>('master');
|
||||||
|
const [testEmail, setTestEmail] = useState('');
|
||||||
|
const [showTestForm, setShowTestForm] = useState(false);
|
||||||
|
|
||||||
|
// Generate preview with dummy shortcode data
|
||||||
|
const generatePreview = () => {
|
||||||
|
if (!template) return '<div>No template selected</div>';
|
||||||
|
|
||||||
|
const processedSubject = ShortcodeProcessor.process(template.email_subject || '');
|
||||||
|
const processedContent = ShortcodeProcessor.process(template.email_body_html || '');
|
||||||
|
|
||||||
|
if (previewMode === 'master') {
|
||||||
|
const fullHtml = EmailTemplateRenderer.render({
|
||||||
|
subject: processedSubject,
|
||||||
|
content: processedContent,
|
||||||
|
brandName: 'ACCESS HUB'
|
||||||
|
});
|
||||||
|
return fullHtml;
|
||||||
|
} else {
|
||||||
|
return processedContent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestEmail = async () => {
|
||||||
|
if (!testEmail) {
|
||||||
|
toast({ title: 'Error', description: 'Masukkan email tujuan', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onTest) {
|
||||||
|
await onTest({ ...template, test_email: testEmail });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewHtml = generatePreview();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5" />
|
||||||
|
Preview: {template.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Preview template email dengan master styling
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Preview Controls */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Label>Preview Mode:</Label>
|
||||||
|
<select
|
||||||
|
value={previewMode}
|
||||||
|
onChange={(e) => setPreviewMode(e.target.value as 'master' | 'content')}
|
||||||
|
className="border-2 px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
<option value="master">Master Template</option>
|
||||||
|
<option value="content">Content Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowTestForm(!showTestForm)}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
{showTestForm ? 'Cancel' : 'Test Email'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Email Form */}
|
||||||
|
{showTestForm && (
|
||||||
|
<div className="p-4 border-2 border-gray-300 rounded-lg bg-gray-50">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="test-email">Test Email Address:</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="test-email"
|
||||||
|
type="email"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(e) => setTestEmail(e.target.value)}
|
||||||
|
placeholder="test@example.com"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleTestEmail}
|
||||||
|
disabled={!testEmail || isTestSending}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
{isTestSending ? 'Sending...' : 'Send Test'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This will send a test email with dummy data for all available shortcodes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview Info */}
|
||||||
|
<Alert>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{previewMode === 'master'
|
||||||
|
? 'Showing complete email template with header, footer, and styling applied.'
|
||||||
|
: 'Showing only the content section without master template styling.'
|
||||||
|
}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Email Preview */}
|
||||||
|
<div className="border-2 border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-gray-100 px-4 py-2 border-b border-gray-300">
|
||||||
|
<span className="text-sm font-mono text-gray-600">
|
||||||
|
{previewMode === 'master' ? 'Full Email Preview' : 'Content Preview'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white" style={{ height: '500px', overflow: 'hidden' }}>
|
||||||
|
<iframe
|
||||||
|
srcDoc={previewHtml}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
scrolling="no"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shortcodes Used */}
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="font-semibold text-sm mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||||
|
Shortcodes Available
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Used in this template */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs font-medium text-blue-700 mb-2">Used in this template:</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{[
|
||||||
|
// User information
|
||||||
|
'{nama}', '{email}',
|
||||||
|
// Order information
|
||||||
|
'{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{status_pesanan}', '{invoice_url}',
|
||||||
|
// Product information
|
||||||
|
'{produk}', '{kategori_produk}', '{harga_produk}', '{deskripsi_produk}',
|
||||||
|
// Access information
|
||||||
|
'{link_akses}', '{username_akses}', '{password_akses}', '{kadaluarsa_akses}',
|
||||||
|
// Consulting information
|
||||||
|
'{tanggal_konsultasi}', '{jam_konsultasi}', '{durasi_konsultasi}', '{link_meet}',
|
||||||
|
'{jenis_konsultasi}', '{topik_konsultasi}',
|
||||||
|
// Event information
|
||||||
|
'{judul_event}', '{tanggal_event}', '{jam_event}', '{link_event}', '{lokasi_event}', '{kapasitas_event}',
|
||||||
|
// Bootcamp/Course information
|
||||||
|
'{judul_bootcamp}', '{progres_bootcamp}', '{modul_selesai}', '{modul_selanjutnya}', '{link_progress}',
|
||||||
|
// Company information
|
||||||
|
'{nama_perusahaan}', '{website_perusahaan}', '{email_support}', '{telepon_support}',
|
||||||
|
// Payment information
|
||||||
|
'{bank_tujuan}', '{nomor_rekening}', '{atas_nama}', '{jumlah_pembayaran}', '{batas_pembayaran}',
|
||||||
|
'{payment_link}', '{thank_you_page}'
|
||||||
|
].filter(shortcode =>
|
||||||
|
(template.email_subject && template.email_subject.includes(shortcode)) ||
|
||||||
|
(template.email_body_html && template.email_body_html.includes(shortcode))
|
||||||
|
).map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-mono border border-blue-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
{![
|
||||||
|
// User information
|
||||||
|
'{nama}', '{email}',
|
||||||
|
// Order information
|
||||||
|
'{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{status_pesanan}', '{invoice_url}',
|
||||||
|
// Product information
|
||||||
|
'{produk}', '{kategori_produk}', '{harga_produk}', '{deskripsi_produk}',
|
||||||
|
// Access information
|
||||||
|
'{link_akses}', '{username_akses}', '{password_akses}', '{kadaluarsa_akses}',
|
||||||
|
// Consulting information
|
||||||
|
'{tanggal_konsultasi}', '{jam_konsultasi}', '{durasi_konsultasi}', '{link_meet}',
|
||||||
|
'{jenis_konsultasi}', '{topik_konsultasi}',
|
||||||
|
// Event information
|
||||||
|
'{judul_event}', '{tanggal_event}', '{jam_event}', '{link_event}', '{lokasi_event}', '{kapasitas_event}',
|
||||||
|
// Bootcamp/Course information
|
||||||
|
'{judul_bootcamp}', '{progres_bootcamp}', '{modul_selesai}', '{modul_selanjutnya}', '{link_progress}',
|
||||||
|
// Company information
|
||||||
|
'{nama_perusahaan}', '{website_perusahaan}', '{email_support}', '{telepon_support}',
|
||||||
|
// Payment information
|
||||||
|
'{bank_tujuan}', '{nomor_rekening}', '{atas_nama}', '{jumlah_pembayaran}', '{batas_pembayaran}',
|
||||||
|
'{payment_link}', '{thank_you_page}'
|
||||||
|
].some(shortcode =>
|
||||||
|
(template.email_subject && template.email_subject.includes(shortcode)) ||
|
||||||
|
(template.email_body_html && template.email_body_html.includes(shortcode))
|
||||||
|
) && (
|
||||||
|
<span className="text-xs text-gray-500 italic">No shortcodes used yet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All available shortcodes */}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-900 transition-colors flex items-center gap-1">
|
||||||
|
<span className="group-open:rotate-90 transition-transform">▶</span>
|
||||||
|
View all available shortcodes
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 pt-3 border-t border-blue-200 space-y-3">
|
||||||
|
|
||||||
|
{/* User Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">👤 User Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{nama}', '{email}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">📦 Order Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{status_pesanan}', '{invoice_url}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">🛍️ Product Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{produk}', '{kategori_produk}', '{harga_produk}', '{deskripsi_produk}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Access Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">🔐 Access Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{link_akses}', '{username_akses}', '{password_akses}', '{kadaluarsa_akses}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">💳 Payment Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{bank_tujuan}', '{nomor_rekening}', '{atas_nama}', '{jumlah_pembayaran}', '{batas_pembayaran}', '{payment_link}', '{thank_you_page}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consulting Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">📅 Consulting Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{tanggal_konsultasi}', '{jam_konsultasi}', '{durasi_konsultasi}', '{link_meet}', '{jenis_konsultasi}', '{topik_konsultasi}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">🎪 Event Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{judul_event}', '{tanggal_event}', '{jam_event}', '{link_event}', '{lokasi_event}', '{kapasitas_event}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bootcamp Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">🎓 Bootcamp Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{judul_bootcamp}', '{progres_bootcamp}', '{modul_selesai}', '{modul_selanjutnya}', '{link_progress}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Information */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">🏢 Company Information</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{['{nama_perusahaan}', '{website_perusahaan}', '{email_support}', '{telepon_support}'].map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-mono border border-gray-300">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Actions */}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{!showTestForm && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowTestForm(true)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Test Email
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showTestForm && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowTestForm(false)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
448
src/components/admin/TimeSlotPickerModal.tsx
Normal file
448
src/components/admin/TimeSlotPickerModal.tsx
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Clock, Calendar as CalendarIcon, Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { format, addMinutes, parse, isAfter, isBefore, startOfDay, addDays, isToday, isPast } from 'date-fns';
|
||||||
|
import { id } from 'date-fns/locale';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
|
||||||
|
interface ConsultingSettings {
|
||||||
|
consulting_block_duration_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Workhour {
|
||||||
|
weekday: number;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmedSlot {
|
||||||
|
session_date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeSlot {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeSlotPickerModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedDate: Date;
|
||||||
|
initialStartTime?: string;
|
||||||
|
initialEndTime?: string;
|
||||||
|
onSave: (startTime: string, endTime: string, totalBlocks: number, totalDuration: number, selectedDate: string) => void;
|
||||||
|
sessionId?: string; // If editing, exclude this session from availability check
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeSlotPickerModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
selectedDate,
|
||||||
|
initialStartTime,
|
||||||
|
initialEndTime,
|
||||||
|
onSave,
|
||||||
|
sessionId
|
||||||
|
}: TimeSlotPickerModalProps) {
|
||||||
|
const [settings, setSettings] = useState<ConsultingSettings | null>(null);
|
||||||
|
const [workhours, setWorkhours] = useState<Workhour[]>([]);
|
||||||
|
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Date selection state
|
||||||
|
const [currentDate, setCurrentDate] = useState<Date>(selectedDate);
|
||||||
|
|
||||||
|
// Range selection state
|
||||||
|
const [selectedRange, setSelectedRange] = useState<{ start: string | null; end: string | null }>({
|
||||||
|
start: initialStartTime || null,
|
||||||
|
end: initialEndTime || null
|
||||||
|
});
|
||||||
|
const [pendingSlot, setPendingSlot] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset range when date changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentDate(selectedDate);
|
||||||
|
setSelectedRange({
|
||||||
|
start: initialStartTime || null,
|
||||||
|
end: initialEndTime || null
|
||||||
|
});
|
||||||
|
setPendingSlot(null);
|
||||||
|
}, [selectedDate, initialStartTime, initialEndTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [open, currentDate]);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const [settingsRes, workhoursRes] = await Promise.all([
|
||||||
|
supabase.from('consulting_settings').select('consulting_block_duration_minutes').single(),
|
||||||
|
supabase.from('workhours').select('*').order('weekday'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (settingsRes.data) {
|
||||||
|
setSettings(settingsRes.data);
|
||||||
|
}
|
||||||
|
if (workhoursRes.data) {
|
||||||
|
setWorkhours(workhoursRes.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch confirmed sessions for availability check
|
||||||
|
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||||
|
const query = supabase
|
||||||
|
.from('consulting_sessions')
|
||||||
|
.select('session_date, start_time, end_time')
|
||||||
|
.eq('session_date', dateStr)
|
||||||
|
.in('status', ['pending_payment', 'confirmed']);
|
||||||
|
|
||||||
|
// If editing, exclude current session
|
||||||
|
if (sessionId) {
|
||||||
|
query.neq('id', sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: sessions } = await query;
|
||||||
|
if (sessions) {
|
||||||
|
setConfirmedSlots(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Date navigation handlers
|
||||||
|
const handlePreviousDay = () => {
|
||||||
|
const newDate = addDays(currentDate, -1);
|
||||||
|
// Prevent going to past dates
|
||||||
|
if (isPast(newDate) && !isToday(newDate)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextDay = () => {
|
||||||
|
const newDate = addDays(currentDate, 1);
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newDate = parse(e.target.value, 'yyyy-MM-dd', new Date());
|
||||||
|
if (!isNaN(newDate.getTime())) {
|
||||||
|
// Prevent selecting past dates
|
||||||
|
if (isPast(newDate) && !isToday(newDate)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTimeSlots = (): TimeSlot[] => {
|
||||||
|
if (!settings || !workhours.length) return [];
|
||||||
|
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
const workhour = workhours.find(wh => wh.weekday === dayOfWeek);
|
||||||
|
|
||||||
|
if (!workhour) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotDuration = settings.consulting_block_duration_minutes;
|
||||||
|
const slots: TimeSlot[] = [];
|
||||||
|
|
||||||
|
const startTime = parse(workhour.start_time, 'HH:mm:ss', new Date());
|
||||||
|
const endTime = parse(workhour.end_time, 'HH:mm:ss', new Date());
|
||||||
|
|
||||||
|
// For today, filter out passed time slots
|
||||||
|
const now = new Date();
|
||||||
|
const isTodayDate = isToday(currentDate);
|
||||||
|
const currentTimeStr = isTodayDate ? format(now, 'HH:mm') : '00:00';
|
||||||
|
|
||||||
|
let currentSlotTime = startTime;
|
||||||
|
while (true) {
|
||||||
|
const slotEnd = addMinutes(currentSlotTime, slotDuration);
|
||||||
|
|
||||||
|
if (isAfter(slotEnd, endTime) || isBefore(slotEnd, currentSlotTime)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeString = format(currentSlotTime, 'HH:mm');
|
||||||
|
|
||||||
|
// Skip slots that have already passed for today
|
||||||
|
if (isTodayDate && timeString < currentTimeStr) {
|
||||||
|
currentSlotTime = slotEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this slot is available (not booked by another session)
|
||||||
|
const isAvailable = !confirmedSlots.some(slot => {
|
||||||
|
const slotStart = slot.start_time.substring(0, 5);
|
||||||
|
const slotEnd = slot.end_time.substring(0, 5);
|
||||||
|
return timeString >= slotStart && timeString < slotEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
start: timeString,
|
||||||
|
end: format(slotEnd, 'HH:mm'),
|
||||||
|
available: isAvailable
|
||||||
|
});
|
||||||
|
|
||||||
|
currentSlotTime = slotEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeSlots = generateTimeSlots();
|
||||||
|
|
||||||
|
// Get slots in selected range
|
||||||
|
const getSlotsInRange = () => {
|
||||||
|
if (!selectedRange.start || !selectedRange.end) return [];
|
||||||
|
|
||||||
|
const startIndex = timeSlots.findIndex(s => s.start === selectedRange.start);
|
||||||
|
const endIndex = timeSlots.findIndex(s => s.start === selectedRange.end);
|
||||||
|
|
||||||
|
if (startIndex === -1 || endIndex === -1) return [];
|
||||||
|
|
||||||
|
return timeSlots.slice(startIndex, endIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalBlocks = getSlotsInRange().length;
|
||||||
|
const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30);
|
||||||
|
|
||||||
|
const handleSlotClick = (slotStart: string, isAvailable: boolean) => {
|
||||||
|
// Prevent clicking on unavailable slots
|
||||||
|
if (!isAvailable) return;
|
||||||
|
|
||||||
|
// No selection yet → Set as pending
|
||||||
|
if (!selectedRange.start) {
|
||||||
|
setPendingSlot(slotStart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Have pending slot → Check if clicking same slot
|
||||||
|
if (pendingSlot) {
|
||||||
|
if (pendingSlot === slotStart) {
|
||||||
|
// Confirm pending slot as range start
|
||||||
|
setSelectedRange({ start: pendingSlot, end: pendingSlot });
|
||||||
|
setPendingSlot(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different slot → Set as range end
|
||||||
|
setSelectedRange({ start: pendingSlot, end: slotStart });
|
||||||
|
setPendingSlot(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already have range → Start new selection
|
||||||
|
setSelectedRange({ start: slotStart, end: slotStart });
|
||||||
|
setPendingSlot(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSelectedRange({ start: null, end: null });
|
||||||
|
setPendingSlot(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (selectedRange.start && selectedRange.end) {
|
||||||
|
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||||
|
onSave(selectedRange.start, selectedRange.end, totalBlocks, totalDuration, dateStr);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl border-2 border-border max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Pilih Jadwal Sesi</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Pilih tanggal dan waktu untuk sesi konsultasi
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-8 space-y-3">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Date Selector */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
<span>Tanggal</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Navigation */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePreviousDay}
|
||||||
|
className="border-2"
|
||||||
|
disabled={isPast(addDays(currentDate, 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={format(currentDate, 'yyyy-MM-dd')}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
className="flex-1 border-2"
|
||||||
|
min={format(new Date(), 'yyyy-MM-dd')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextDay}
|
||||||
|
className="border-2"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Date Display */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{format(currentDate, 'd MMMM yyyy', { locale: id })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isToday(currentDate) && 'Hari ini • '}
|
||||||
|
{timeSlots.length} slot tersedia
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
|
{/* Time Slots Section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Waktu</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
{isPast(currentDate) && !isToday(currentDate) ? (
|
||||||
|
<div className="bg-destructive/10 border-2 border-destructive/20 p-4 rounded-lg text-center">
|
||||||
|
<p className="text-sm text-destructive font-medium">
|
||||||
|
Tidak dapat memilih tanggal yang sudah lewat. Silakan pilih tanggal hari ini atau tanggal yang akan datang.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : isToday(currentDate) && timeSlots.length === 0 ? (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-950 border-2 border-amber-200 dark:border-amber-800 p-4 rounded-lg text-center">
|
||||||
|
<p className="text-sm text-amber-900 dark:text-amber-100">
|
||||||
|
Tidak ada slot tersedia untuk sisa hari ini. Silakan pilih tanggal lain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : timeSlots.length === 0 ? (
|
||||||
|
<div className="bg-muted p-4 rounded-lg text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tidak ada jadwal kerja untuk tanggal ini.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted p-3 rounded-lg">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
Klik slot untuk memilih durasi. Setiap slot = {settings?.consulting_block_duration_minutes || 30} menit
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Slots Grid */}
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||||
|
{timeSlots.map((slot) => {
|
||||||
|
const isSelected = selectedRange.start && selectedRange.end &&
|
||||||
|
timeSlots.findIndex(s => s.start === selectedRange.start) <=
|
||||||
|
timeSlots.findIndex(s => s.start === slot.start) &&
|
||||||
|
timeSlots.findIndex(s => s.start === selectedRange.end) >=
|
||||||
|
timeSlots.findIndex(s => s.start === slot.start);
|
||||||
|
|
||||||
|
const isPending = pendingSlot === slot.start;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={slot.start}
|
||||||
|
variant={isSelected ? "default" : isPending ? "secondary" : "outline"}
|
||||||
|
className={`h-12 text-sm border-2 ${
|
||||||
|
!slot.available ? 'opacity-30 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
disabled={!slot.available}
|
||||||
|
onClick={() => handleSlotClick(slot.start, slot.available)}
|
||||||
|
>
|
||||||
|
{slot.start}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selection Summary */}
|
||||||
|
{selectedRange.start && selectedRange.end && (
|
||||||
|
<div className="bg-primary/10 p-4 rounded-lg border-2 border-primary/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Mulai</p>
|
||||||
|
<p className="font-bold text-lg">{selectedRange.start}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl">→</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{totalBlocks} blok</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-muted-foreground">Selesai</p>
|
||||||
|
<p className="font-bold text-lg">
|
||||||
|
{format(addMinutes(parse(selectedRange.end, 'HH:mm', new Date()), settings?.consulting_block_duration_minutes || 30), 'HH:mm')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm mt-2 text-primary font-medium">
|
||||||
|
Durasi: {totalDuration} menit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Slot */}
|
||||||
|
{pendingSlot && (
|
||||||
|
<div className="bg-amber-500/10 p-3 rounded-lg border-2 border-amber-500/20">
|
||||||
|
<p className="text-center text-sm">
|
||||||
|
Klik lagi untuk konfirmasi slot: <strong>{pendingSlot}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" onClick={handleReset} className="border-2">
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!selectedRange.start || !selectedRange.end}
|
||||||
|
className="shadow-sm"
|
||||||
|
>
|
||||||
|
Simpan Jadwal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Palette, Image, Mail, Home, Plus, Trash2 } from 'lucide-react';
|
import { uploadToContentStorage } from '@/lib/storageUpload';
|
||||||
|
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||||
|
import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X, User } from 'lucide-react';
|
||||||
|
|
||||||
interface HomepageFeature {
|
interface HomepageFeature {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -22,7 +25,8 @@ interface PlatformSettings {
|
|||||||
brand_favicon_url: string;
|
brand_favicon_url: string;
|
||||||
brand_primary_color: string;
|
brand_primary_color: string;
|
||||||
brand_accent_color: string;
|
brand_accent_color: string;
|
||||||
brand_email_from_name: string;
|
owner_name: string;
|
||||||
|
owner_avatar_url: string;
|
||||||
homepage_headline: string;
|
homepage_headline: string;
|
||||||
homepage_description: string;
|
homepage_description: string;
|
||||||
homepage_features: HomepageFeature[];
|
homepage_features: HomepageFeature[];
|
||||||
@@ -41,7 +45,8 @@ const emptySettings: PlatformSettings = {
|
|||||||
brand_favicon_url: '',
|
brand_favicon_url: '',
|
||||||
brand_primary_color: '#111827',
|
brand_primary_color: '#111827',
|
||||||
brand_accent_color: '#0F766E',
|
brand_accent_color: '#0F766E',
|
||||||
brand_email_from_name: '',
|
owner_name: 'Dwindi',
|
||||||
|
owner_avatar_url: '',
|
||||||
homepage_headline: 'Learn. Grow. Succeed.',
|
homepage_headline: 'Learn. Grow. Succeed.',
|
||||||
homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.',
|
homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.',
|
||||||
homepage_features: defaultFeatures,
|
homepage_features: defaultFeatures,
|
||||||
@@ -53,6 +58,16 @@ export function BrandingTab() {
|
|||||||
const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
|
const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
|
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
||||||
|
const [uploadingOwnerAvatar, setUploadingOwnerAvatar] = useState(false);
|
||||||
|
|
||||||
|
// Preview states for selected files
|
||||||
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||||
|
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const faviconInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
@@ -84,7 +99,8 @@ export function BrandingTab() {
|
|||||||
brand_favicon_url: data.brand_favicon_url || '',
|
brand_favicon_url: data.brand_favicon_url || '',
|
||||||
brand_primary_color: data.brand_primary_color || '#111827',
|
brand_primary_color: data.brand_primary_color || '#111827',
|
||||||
brand_accent_color: data.brand_accent_color || '#0F766E',
|
brand_accent_color: data.brand_accent_color || '#0F766E',
|
||||||
brand_email_from_name: data.brand_email_from_name || '',
|
owner_name: data.owner_name || 'Dwindi',
|
||||||
|
owner_avatar_url: data.owner_avatar_url || '',
|
||||||
homepage_headline: data.homepage_headline || emptySettings.homepage_headline,
|
homepage_headline: data.homepage_headline || emptySettings.homepage_headline,
|
||||||
homepage_description: data.homepage_description || emptySettings.homepage_description,
|
homepage_description: data.homepage_description || emptySettings.homepage_description,
|
||||||
homepage_features: features,
|
homepage_features: features,
|
||||||
@@ -95,6 +111,7 @@ export function BrandingTab() {
|
|||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
console.log('Current settings before save:', settings);
|
||||||
const payload = {
|
const payload = {
|
||||||
brand_name: settings.brand_name,
|
brand_name: settings.brand_name,
|
||||||
brand_tagline: settings.brand_tagline,
|
brand_tagline: settings.brand_tagline,
|
||||||
@@ -102,11 +119,13 @@ export function BrandingTab() {
|
|||||||
brand_favicon_url: settings.brand_favicon_url,
|
brand_favicon_url: settings.brand_favicon_url,
|
||||||
brand_primary_color: settings.brand_primary_color,
|
brand_primary_color: settings.brand_primary_color,
|
||||||
brand_accent_color: settings.brand_accent_color,
|
brand_accent_color: settings.brand_accent_color,
|
||||||
brand_email_from_name: settings.brand_email_from_name,
|
owner_name: settings.owner_name,
|
||||||
|
owner_avatar_url: settings.owner_avatar_url,
|
||||||
homepage_headline: settings.homepage_headline,
|
homepage_headline: settings.homepage_headline,
|
||||||
homepage_description: settings.homepage_description,
|
homepage_description: settings.homepage_description,
|
||||||
homepage_features: settings.homepage_features,
|
homepage_features: settings.homepage_features,
|
||||||
};
|
};
|
||||||
|
console.log('Saving payload:', payload);
|
||||||
|
|
||||||
if (settings.id) {
|
if (settings.id) {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
@@ -154,6 +173,178 @@ export function BrandingTab() {
|
|||||||
setSettings({ ...settings, homepage_features: newFeatures });
|
setSettings({ ...settings, homepage_features: newFeatures });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle logo upload with auto-delete
|
||||||
|
const handleLogoUpload = async (file: File) => {
|
||||||
|
setUploadingLogo(true);
|
||||||
|
try {
|
||||||
|
const fileExt = file.name.split('.').pop();
|
||||||
|
const filePath = `brand-assets/logo/logo-current.${fileExt}`;
|
||||||
|
|
||||||
|
// Step 1: Delete old logo if exists
|
||||||
|
const { data: existingFiles } = await supabase.storage
|
||||||
|
.from('content')
|
||||||
|
.list('brand-assets/logo/');
|
||||||
|
|
||||||
|
if (existingFiles?.length > 0) {
|
||||||
|
const oldFile = existingFiles.find(f => f.name.startsWith('logo-current'));
|
||||||
|
if (oldFile) {
|
||||||
|
await supabase.storage.from('content').remove([`brand-assets/logo/${oldFile.name}`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Upload new logo
|
||||||
|
const { data, error } = await supabase.storage
|
||||||
|
.from('content')
|
||||||
|
.upload(filePath, file, {
|
||||||
|
cacheControl: '3600',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Step 3: Get public URL and update settings
|
||||||
|
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
|
||||||
|
console.log('Logo upload successful:', urlData.publicUrl);
|
||||||
|
setSettings(prev => ({ ...prev, brand_logo_url: urlData.publicUrl }));
|
||||||
|
console.log('State updated with logo URL');
|
||||||
|
|
||||||
|
toast({ title: 'Berhasil', description: 'Logo berhasil diupload' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logo upload error:', error);
|
||||||
|
toast({ title: 'Error', description: 'Gagal upload logo', variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle favicon upload with auto-delete
|
||||||
|
const handleFaviconUpload = async (file: File) => {
|
||||||
|
setUploadingFavicon(true);
|
||||||
|
try {
|
||||||
|
const fileExt = file.name.split('.').pop();
|
||||||
|
const filePath = `brand-assets/favicon/favicon-current.${fileExt}`;
|
||||||
|
|
||||||
|
// Step 1: Delete old favicon if exists
|
||||||
|
const { data: existingFiles } = await supabase.storage
|
||||||
|
.from('content')
|
||||||
|
.list('brand-assets/favicon/');
|
||||||
|
|
||||||
|
if (existingFiles?.length > 0) {
|
||||||
|
const oldFile = existingFiles.find(f => f.name.startsWith('favicon-current'));
|
||||||
|
if (oldFile) {
|
||||||
|
await supabase.storage.from('content').remove([`brand-assets/favicon/${oldFile.name}`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Upload new favicon
|
||||||
|
const { data, error } = await supabase.storage
|
||||||
|
.from('content')
|
||||||
|
.upload(filePath, file, {
|
||||||
|
cacheControl: '3600',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Step 3: Get public URL and update settings
|
||||||
|
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
|
||||||
|
console.log('Favicon upload successful:', urlData.publicUrl);
|
||||||
|
setSettings(prev => ({ ...prev, brand_favicon_url: urlData.publicUrl }));
|
||||||
|
console.log('State updated with favicon URL');
|
||||||
|
|
||||||
|
toast({ title: 'Berhasil', description: 'Favicon berhasil diupload' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Favicon upload error:', error);
|
||||||
|
toast({ title: 'Error', description: 'Gagal upload favicon', variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setUploadingFavicon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file size (2MB max)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast({ title: 'Error', description: 'Ukuran file maksimal 2MB', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview first
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setLogoPreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFaviconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file size (1MB max)
|
||||||
|
if (file.size > 1 * 1024 * 1024) {
|
||||||
|
toast({ title: 'Error', description: 'Ukuran file maksimal 1MB', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview first
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setFaviconPreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmLogoUpload = async () => {
|
||||||
|
const file = logoInputRef.current?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
await handleLogoUpload(file);
|
||||||
|
setLogoPreview(null); // Clear preview after upload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmFaviconUpload = async () => {
|
||||||
|
const file = faviconInputRef.current?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
await handleFaviconUpload(file);
|
||||||
|
setFaviconPreview(null); // Clear preview after upload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLogo = () => {
|
||||||
|
setSettings({ ...settings, brand_logo_url: '' });
|
||||||
|
setLogoPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFavicon = () => {
|
||||||
|
setSettings({ ...settings, brand_favicon_url: '' });
|
||||||
|
setFaviconPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOwnerAvatarUpload = async (file: File) => {
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast({ title: 'Error', description: 'Ukuran file maksimal 2MB', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploadingOwnerAvatar(true);
|
||||||
|
const ext = file.name.split('.').pop() || 'png';
|
||||||
|
const path = `brand-assets/logo/owner-avatar-${Date.now()}.${ext}`;
|
||||||
|
const publicUrl = await uploadToContentStorage(file, path);
|
||||||
|
setSettings((prev) => ({ ...prev, owner_avatar_url: publicUrl }));
|
||||||
|
toast({ title: 'Berhasil', description: 'Avatar owner berhasil diupload' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Owner avatar upload error:', error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Gagal upload avatar owner';
|
||||||
|
toast({ title: 'Error', description: message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setUploadingOwnerAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -199,47 +390,205 @@ export function BrandingTab() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Image className="w-4 h-4" />
|
<Image className="w-4 h-4" />
|
||||||
Logo Utama (URL)
|
Logo Utama
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
|
||||||
value={settings.brand_logo_url}
|
<input
|
||||||
onChange={(e) => setSettings({ ...settings, brand_logo_url: e.target.value })}
|
ref={logoInputRef}
|
||||||
placeholder="https://example.com/logo.png"
|
type="file"
|
||||||
className="border-2"
|
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
||||||
|
onChange={handleLogoSelect}
|
||||||
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
{settings.brand_logo_url && (
|
|
||||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
<div className="space-y-2">
|
||||||
<img
|
{/* Show preview if file selected, otherwise show current logo or upload button */}
|
||||||
src={settings.brand_logo_url}
|
{logoPreview ? (
|
||||||
alt="Logo preview"
|
<div className="relative">
|
||||||
className="h-12 object-contain"
|
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
<img
|
||||||
/>
|
src={logoPreview}
|
||||||
</div>
|
alt="Logo preview"
|
||||||
)}
|
className="h-16 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirmLogoUpload}
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
className="flex-1 border-2"
|
||||||
|
>
|
||||||
|
{uploadingLogo ? 'Mengupload...' : 'Konfirmasi Upload'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLogoPreview(null)}
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : settings.brand_logo_url ? (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={settings.brand_logo_url}
|
||||||
|
alt="Logo preview"
|
||||||
|
className="h-16 object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
toast({ title: 'Error', description: 'Gagal memuat logo', variant: 'destructive' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => logoInputRef.current?.click()}
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
className="flex-1 border-2"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploadingLogo ? 'Mengupload...' : 'Ganti'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveLogo}
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => logoInputRef.current?.click()}
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
className="w-full border-2"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploadingLogo ? 'Mengupload...' : 'Upload Logo'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
PNG, SVG, JPG, atau WebP. Maks 2MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Image className="w-4 h-4" />
|
<Image className="w-4 h-4" />
|
||||||
Favicon (URL)
|
Favicon
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
|
||||||
value={settings.brand_favicon_url}
|
<input
|
||||||
onChange={(e) => setSettings({ ...settings, brand_favicon_url: e.target.value })}
|
ref={faviconInputRef}
|
||||||
placeholder="https://example.com/favicon.ico"
|
type="file"
|
||||||
className="border-2"
|
accept="image/png,image/svg+xml,image/jpeg,image/x-icon"
|
||||||
|
onChange={handleFaviconSelect}
|
||||||
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
{settings.brand_favicon_url && (
|
|
||||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
<div className="space-y-2">
|
||||||
<img
|
{/* Show preview if file selected, otherwise show current favicon or upload button */}
|
||||||
src={settings.brand_favicon_url}
|
{faviconPreview ? (
|
||||||
alt="Favicon preview"
|
<div className="relative">
|
||||||
className="h-8 w-8 object-contain"
|
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
<img
|
||||||
/>
|
src={faviconPreview}
|
||||||
</div>
|
alt="Favicon preview"
|
||||||
)}
|
className="h-12 w-12 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirmFaviconUpload}
|
||||||
|
disabled={uploadingFavicon}
|
||||||
|
className="flex-1 border-2"
|
||||||
|
>
|
||||||
|
{uploadingFavicon ? 'Mengupload...' : 'Konfirmasi Upload'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFaviconPreview(null)}
|
||||||
|
disabled={uploadingFavicon}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : settings.brand_favicon_url ? (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={settings.brand_favicon_url}
|
||||||
|
alt="Favicon preview"
|
||||||
|
className="h-12 w-12 object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
toast({ title: 'Error', description: 'Gagal memuat favicon', variant: 'destructive' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => faviconInputRef.current?.click()}
|
||||||
|
disabled={uploadingFavicon}
|
||||||
|
className="flex-1 border-2"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploadingFavicon ? 'Mengupload...' : 'Ganti'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveFavicon}
|
||||||
|
disabled={uploadingFavicon}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => faviconInputRef.current?.click()}
|
||||||
|
disabled={uploadingFavicon}
|
||||||
|
className="w-full border-2"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploadingFavicon ? 'Mengupload...' : 'Upload Favicon'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
PNG, SVG, JPG, atau ICO. Maks 1MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,20 +630,52 @@ export function BrandingTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="border-t pt-6">
|
||||||
<Label className="flex items-center gap-2">
|
<h3 className="font-semibold mb-4">Identitas Owner</h3>
|
||||||
<Mail className="w-4 h-4" />
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
Nama Pengirim Default Email
|
<div className="space-y-2">
|
||||||
</Label>
|
<Label>Nama Owner</Label>
|
||||||
<Input
|
<Input
|
||||||
value={settings.brand_email_from_name}
|
value={settings.owner_name}
|
||||||
onChange={(e) => setSettings({ ...settings, brand_email_from_name: e.target.value })}
|
onChange={(e) => setSettings({ ...settings, owner_name: e.target.value })}
|
||||||
placeholder="LearnHub Team"
|
placeholder="Dwindi"
|
||||||
className="border-2"
|
className="border-2"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
</div>
|
||||||
Digunakan jika SMTP from_name kosong
|
<div className="space-y-2">
|
||||||
</p>
|
<Label>Avatar Owner</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-16 w-16 border-2 border-border">
|
||||||
|
<AvatarImage src={resolveAvatarUrl(settings.owner_avatar_url) || undefined} alt={settings.owner_name} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-muted">
|
||||||
|
<User className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
void handleOwnerAvatarUpload(file);
|
||||||
|
e.currentTarget.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" asChild disabled={uploadingOwnerAvatar}>
|
||||||
|
<span>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploadingOwnerAvatar ? 'Mengupload...' : 'Upload Avatar'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
120
src/components/admin/settings/CollaborationTab.tsx
Normal file
120
src/components/admin/settings/CollaborationTab.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface CollaborationSettings {
|
||||||
|
id?: string;
|
||||||
|
collaboration_enabled: boolean;
|
||||||
|
min_withdrawal_amount: number;
|
||||||
|
default_profit_share: number;
|
||||||
|
max_pending_withdrawals: number;
|
||||||
|
withdrawal_processing_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: CollaborationSettings = {
|
||||||
|
collaboration_enabled: true,
|
||||||
|
min_withdrawal_amount: 100000,
|
||||||
|
default_profit_share: 50,
|
||||||
|
max_pending_withdrawals: 1,
|
||||||
|
withdrawal_processing_days: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CollaborationTab() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<CollaborationSettings>(defaults);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("platform_settings")
|
||||||
|
.select("id, collaboration_enabled, min_withdrawal_amount, default_profit_share, max_pending_withdrawals, withdrawal_processing_days")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setSettings({
|
||||||
|
id: data.id,
|
||||||
|
collaboration_enabled: data.collaboration_enabled ?? defaults.collaboration_enabled,
|
||||||
|
min_withdrawal_amount: data.min_withdrawal_amount ?? defaults.min_withdrawal_amount,
|
||||||
|
default_profit_share: data.default_profit_share ?? defaults.default_profit_share,
|
||||||
|
max_pending_withdrawals: data.max_pending_withdrawals ?? defaults.max_pending_withdrawals,
|
||||||
|
withdrawal_processing_days: data.withdrawal_processing_days ?? defaults.withdrawal_processing_days,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!settings.id) {
|
||||||
|
toast({ title: "Error", description: "platform_settings row not found", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
const payload = {
|
||||||
|
collaboration_enabled: settings.collaboration_enabled,
|
||||||
|
min_withdrawal_amount: settings.min_withdrawal_amount,
|
||||||
|
default_profit_share: settings.default_profit_share,
|
||||||
|
max_pending_withdrawals: settings.max_pending_withdrawals,
|
||||||
|
withdrawal_processing_days: settings.withdrawal_processing_days,
|
||||||
|
};
|
||||||
|
const { error } = await supabase.from("platform_settings").update(payload).eq("id", settings.id);
|
||||||
|
if (error) {
|
||||||
|
toast({ title: "Error", description: error.message, variant: "destructive" });
|
||||||
|
} else {
|
||||||
|
toast({ title: "Berhasil", description: "Pengaturan kolaborasi disimpan" });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Kolaborasi</CardTitle>
|
||||||
|
<CardDescription>Kontrol global fitur kolaborasi dan withdrawal</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-muted p-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Aktifkan fitur kolaborasi</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Jika nonaktif, alur profit sharing dimatikan</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.collaboration_enabled}
|
||||||
|
onCheckedChange={(checked) => setSettings({ ...settings, collaboration_enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Minimum Withdrawal (IDR)</Label>
|
||||||
|
<Input type="number" value={settings.min_withdrawal_amount} onChange={(e) => setSettings({ ...settings, min_withdrawal_amount: parseInt(e.target.value || "0", 10) || 0 })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Default Profit Share (%)</Label>
|
||||||
|
<Input type="number" min={0} max={100} value={settings.default_profit_share} onChange={(e) => setSettings({ ...settings, default_profit_share: Math.max(0, Math.min(100, parseInt(e.target.value || "0", 10) || 0)) })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max Pending Withdrawals</Label>
|
||||||
|
<Input type="number" min={1} value={settings.max_pending_withdrawals} onChange={(e) => setSettings({ ...settings, max_pending_withdrawals: Math.max(1, parseInt(e.target.value || "1", 10) || 1) })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Withdrawal Processing Days</Label>
|
||||||
|
<Input type="number" min={1} value={settings.withdrawal_processing_days} onChange={(e) => setSettings({ ...settings, withdrawal_processing_days: Math.max(1, parseInt(e.target.value || "1", 10) || 1) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? "Menyimpan..." : "Simpan Pengaturan"}</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@ import { supabase } from '@/integrations/supabase/client';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Puzzle, Webhook, MessageSquare, Calendar, Mail, Link as LinkIcon } from 'lucide-react';
|
import { Puzzle, Webhook, MessageSquare, Calendar, Mail, Link as LinkIcon, Key, Send, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
interface IntegrationSettings {
|
interface IntegrationSettings {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -14,10 +17,15 @@ interface IntegrationSettings {
|
|||||||
integration_whatsapp_number: string;
|
integration_whatsapp_number: string;
|
||||||
integration_whatsapp_url: string;
|
integration_whatsapp_url: string;
|
||||||
integration_google_calendar_id: string;
|
integration_google_calendar_id: string;
|
||||||
|
google_oauth_config?: string;
|
||||||
integration_email_provider: string;
|
integration_email_provider: string;
|
||||||
integration_email_api_base_url: string;
|
integration_email_api_base_url: string;
|
||||||
|
integration_email_api_token: string;
|
||||||
|
integration_email_from_name: string;
|
||||||
|
integration_email_from_email: string;
|
||||||
integration_privacy_url: string;
|
integration_privacy_url: string;
|
||||||
integration_terms_url: string;
|
integration_terms_url: string;
|
||||||
|
integration_n8n_test_mode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptySettings: IntegrationSettings = {
|
const emptySettings: IntegrationSettings = {
|
||||||
@@ -25,38 +33,50 @@ const emptySettings: IntegrationSettings = {
|
|||||||
integration_whatsapp_number: '',
|
integration_whatsapp_number: '',
|
||||||
integration_whatsapp_url: '',
|
integration_whatsapp_url: '',
|
||||||
integration_google_calendar_id: '',
|
integration_google_calendar_id: '',
|
||||||
integration_email_provider: 'smtp',
|
integration_email_provider: 'mailketing',
|
||||||
integration_email_api_base_url: '',
|
integration_email_api_base_url: '',
|
||||||
|
integration_email_api_token: '',
|
||||||
|
integration_email_from_name: '',
|
||||||
|
integration_email_from_email: '',
|
||||||
integration_privacy_url: '/privacy',
|
integration_privacy_url: '/privacy',
|
||||||
integration_terms_url: '/terms',
|
integration_terms_url: '/terms',
|
||||||
|
integration_n8n_test_mode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function IntegrasiTab() {
|
export function IntegrasiTab() {
|
||||||
const [settings, setSettings] = useState<IntegrationSettings>(emptySettings);
|
const [settings, setSettings] = useState<IntegrationSettings>(emptySettings);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testEmail, setTestEmail] = useState('');
|
||||||
|
const [sendingTest, setSendingTest] = useState(false);
|
||||||
|
const [isTestRunning, setIsTestRunning] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
const { data, error } = await supabase
|
const { data: platformData } = await supabase
|
||||||
.from('platform_settings')
|
.from('platform_settings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (data) {
|
if (platformData) {
|
||||||
setSettings({
|
setSettings({
|
||||||
id: data.id,
|
id: platformData.id,
|
||||||
integration_n8n_base_url: data.integration_n8n_base_url || '',
|
integration_n8n_base_url: platformData.integration_n8n_base_url || '',
|
||||||
integration_whatsapp_number: data.integration_whatsapp_number || '',
|
integration_whatsapp_number: platformData.integration_whatsapp_number || '',
|
||||||
integration_whatsapp_url: data.integration_whatsapp_url || '',
|
integration_whatsapp_url: platformData.integration_whatsapp_url || '',
|
||||||
integration_google_calendar_id: data.integration_google_calendar_id || '',
|
integration_google_calendar_id: platformData.integration_google_calendar_id || '',
|
||||||
integration_email_provider: data.integration_email_provider || 'smtp',
|
google_oauth_config: platformData.google_oauth_config || '',
|
||||||
integration_email_api_base_url: data.integration_email_api_base_url || '',
|
integration_email_provider: platformData.integration_email_provider || 'mailketing',
|
||||||
integration_privacy_url: data.integration_privacy_url || '/privacy',
|
integration_email_api_base_url: platformData.integration_email_api_base_url || '',
|
||||||
integration_terms_url: data.integration_terms_url || '/terms',
|
integration_email_api_token: platformData.integration_email_api_token || '',
|
||||||
|
integration_email_from_name: platformData.integration_email_from_name || platformData.brand_email_from_name || '',
|
||||||
|
integration_email_from_email: platformData.integration_email_from_email || '',
|
||||||
|
integration_privacy_url: platformData.integration_privacy_url || '/privacy',
|
||||||
|
integration_terms_url: platformData.integration_terms_url || '/terms',
|
||||||
|
integration_n8n_test_mode: platformData.integration_n8n_test_mode || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -64,33 +84,148 @@ export function IntegrasiTab() {
|
|||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const payload = { ...settings };
|
|
||||||
delete payload.id;
|
|
||||||
|
|
||||||
if (settings.id) {
|
try {
|
||||||
const { error } = await supabase
|
// Save platform settings (includes email settings)
|
||||||
.from('platform_settings')
|
const platformPayload = {
|
||||||
.update(payload)
|
integration_n8n_base_url: settings.integration_n8n_base_url,
|
||||||
.eq('id', settings.id);
|
integration_whatsapp_number: settings.integration_whatsapp_number,
|
||||||
|
integration_whatsapp_url: settings.integration_whatsapp_url,
|
||||||
|
integration_google_calendar_id: settings.integration_google_calendar_id,
|
||||||
|
google_oauth_config: settings.google_oauth_config,
|
||||||
|
integration_email_provider: settings.integration_email_provider,
|
||||||
|
integration_email_api_base_url: settings.integration_email_api_base_url,
|
||||||
|
integration_email_api_token: settings.integration_email_api_token,
|
||||||
|
integration_email_from_name: settings.integration_email_from_name,
|
||||||
|
integration_email_from_email: settings.integration_email_from_email,
|
||||||
|
integration_privacy_url: settings.integration_privacy_url,
|
||||||
|
integration_terms_url: settings.integration_terms_url,
|
||||||
|
integration_n8n_test_mode: settings.integration_n8n_test_mode,
|
||||||
|
};
|
||||||
|
|
||||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
if (settings.id) {
|
||||||
else toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' });
|
const { error: platformError } = await supabase
|
||||||
} else {
|
.from('platform_settings')
|
||||||
const { data, error } = await supabase
|
.update(platformPayload)
|
||||||
.from('platform_settings')
|
.eq('id', settings.id);
|
||||||
.insert(payload)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
if (platformError) {
|
||||||
else {
|
// If schema cache error, try saving OAuth config separately via raw SQL
|
||||||
setSettings({ ...settings, id: data.id });
|
if (platformError.code === 'PGRST204' && settings.google_oauth_config) {
|
||||||
toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' });
|
console.log('Schema cache error, using fallback RPC method');
|
||||||
|
const { error: rpcError } = await supabase.rpc('exec_sql', {
|
||||||
|
sql: `UPDATE platform_settings SET google_oauth_config = '${settings.google_oauth_config.replace(/'/g, "''")}'::jsonb WHERE id = '${settings.id}'`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rpcError) {
|
||||||
|
// Save other fields without the problematic column
|
||||||
|
const { error: retryError } = await supabase
|
||||||
|
.from('platform_settings')
|
||||||
|
.update({
|
||||||
|
integration_n8n_base_url: settings.integration_n8n_base_url,
|
||||||
|
integration_whatsapp_number: settings.integration_whatsapp_number,
|
||||||
|
integration_whatsapp_url: settings.integration_whatsapp_url,
|
||||||
|
integration_google_calendar_id: settings.integration_google_calendar_id,
|
||||||
|
integration_email_provider: settings.integration_email_provider,
|
||||||
|
integration_email_api_base_url: settings.integration_email_api_base_url,
|
||||||
|
integration_email_api_token: settings.integration_email_api_token,
|
||||||
|
integration_email_from_name: settings.integration_email_from_name,
|
||||||
|
integration_email_from_email: settings.integration_email_from_email,
|
||||||
|
integration_privacy_url: settings.integration_privacy_url,
|
||||||
|
integration_terms_url: settings.integration_terms_url,
|
||||||
|
integration_n8n_test_mode: settings.integration_n8n_test_mode,
|
||||||
|
})
|
||||||
|
.eq('id', settings.id);
|
||||||
|
|
||||||
|
if (retryError) throw retryError;
|
||||||
|
toast({ title: 'Peringatan', description: 'Pengaturan disimpan tapi Service Account JSON perlu disimpan manual. Hubungi admin.' });
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Berhasil', description: 'Service Account JSON disimpan via RPC' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw platformError;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' });
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendTestEmail = async () => {
|
||||||
|
if (!testEmail) return toast({ title: 'Error', description: 'Masukkan email tujuan', variant: 'destructive' });
|
||||||
|
if (!isEmailConfigured) return toast({ title: 'Error', description: 'Lengkapi konfigurasi email provider terlebih dahulu', variant: 'destructive' });
|
||||||
|
|
||||||
|
setSendingTest(true);
|
||||||
|
try {
|
||||||
|
// Get brand name for test email
|
||||||
|
const { data: platformData } = await supabase
|
||||||
|
.from('platform_settings')
|
||||||
|
.select('brand_name')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const brandName = platformData?.brand_name || 'ACCESS HUB';
|
||||||
|
|
||||||
|
// Test email content using proper HTML template
|
||||||
|
const testEmailContent = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #333;">Email Test - ${brandName}</h2>
|
||||||
|
|
||||||
|
<p>Halo,</p>
|
||||||
|
|
||||||
|
<p>Ini adalah email tes dari sistem <strong>${brandName}</strong>.</p>
|
||||||
|
|
||||||
|
<div style="margin: 20px 0; padding: 15px; background-color: #f0f0f0; border-left: 4px solid #000;">
|
||||||
|
<p style="margin: 0; font-size: 14px;">
|
||||||
|
<strong>✓ Konfigurasi email berhasil!</strong><br>
|
||||||
|
Email Anda telah terkirim dengan benar menggunakan provider: <strong>Mailketing</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #666;">
|
||||||
|
Jika Anda menerima email ini, berarti konfigurasi email sudah benar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size: 14px;">
|
||||||
|
Terima kasih,<br>
|
||||||
|
Tim ${brandName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { data, error } = await supabase.functions.invoke('send-notification', {
|
||||||
|
body: {
|
||||||
|
template_key: 'test_email',
|
||||||
|
recipient_email: testEmail,
|
||||||
|
recipient_name: 'Admin',
|
||||||
|
variables: {
|
||||||
|
brand_name: brandName,
|
||||||
|
test_email: testEmail
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data?.success) {
|
||||||
|
toast({ title: 'Berhasil', description: data.message });
|
||||||
|
} else {
|
||||||
|
throw new Error(data?.message || 'Failed to send test email');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Test email error:', error);
|
||||||
|
toast({ title: 'Error', description: error.message || 'Gagal mengirim email uji coba', variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setSendingTest(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmailConfigured = settings.integration_email_api_token && settings.integration_email_from_email;
|
||||||
|
|
||||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,6 +254,28 @@ export function IntegrasiTab() {
|
|||||||
Digunakan sebagai target default untuk webhook lanjutan. webhook_url per template tetap harus URL lengkap.
|
Digunakan sebagai target default untuk webhook lanjutan. webhook_url per template tetap harus URL lengkap.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 border rounded-lg space-y-0">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Mode Test n8n</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Aktifkan untuk menggunakan webhook path /webhook-test/ instead of /webhook/
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.integration_n8n_test_mode}
|
||||||
|
onCheckedChange={(checked) => setSettings({ ...settings, integration_n8n_test_mode: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.integration_n8n_test_mode && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Mode test aktif: Webhook akan menggunakan path <code>/webhook-test/</code>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -182,9 +339,72 @@ export function IntegrasiTab() {
|
|||||||
className="border-2"
|
className="border-2"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Backend/n8n akan menggunakan ID ini untuk membuat event
|
Backend akan menggunakan ID ini untuk membuat event
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
Google OAuth Config
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={settings.google_oauth_config || ''}
|
||||||
|
onChange={(e) => setSettings({ ...settings, google_oauth_config: e.target.value })}
|
||||||
|
placeholder='{"client_id": "...", "client_secret": "...", "refresh_token": "..."}'
|
||||||
|
className="min-h-[120px] font-mono text-sm border-2"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
OAuth2 credentials untuk personal Gmail account. Gunakan <a href="/get-google-refresh-token.html" target="_blank" className="text-blue-600 underline">tool ini</a> untuk generate refresh token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!settings.integration_google_calendar_id || !settings.google_oauth_config) {
|
||||||
|
toast({ title: "Error", description: "Lengkapi Calendar ID dan OAuth Config", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTestRunning) {
|
||||||
|
return; // Prevent React Strict Mode double-call
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTestRunning(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke('create-google-meet-event', {
|
||||||
|
body: {
|
||||||
|
slot_id: 'test-connection',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
start_time: '14:00:00',
|
||||||
|
end_time: '15:00:00',
|
||||||
|
client_name: 'Test Connection',
|
||||||
|
client_email: 'test@example.com',
|
||||||
|
topic: 'Connection Test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (data?.success) {
|
||||||
|
toast({ title: "Berhasil", description: "Google Calendar API berfungsi! Event test dibuat." });
|
||||||
|
} else {
|
||||||
|
throw new Error(data?.message || 'Connection failed');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: "Error", description: err.message, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsTestRunning(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isTestRunning}
|
||||||
|
className="w-full border-2"
|
||||||
|
>
|
||||||
|
Test Google Calendar Connection
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -193,43 +413,97 @@ export function IntegrasiTab() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
Provider Email (Opsional)
|
Provider Email
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Konfigurasi alternatif selain SMTP
|
Konfigurasi provider email untuk pengiriman notifikasi
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{!isEmailConfigured && (
|
||||||
|
<Alert variant="destructive" className="border-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Konfigurasi email provider belum lengkap. Email tidak akan terkirim.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Provider Email Eksternal</Label>
|
<Label>Provider Email</Label>
|
||||||
<Select
|
<Select
|
||||||
value={settings.integration_email_provider}
|
value={settings.integration_email_provider}
|
||||||
onValueChange={(value) => setSettings({ ...settings, integration_email_provider: value })}
|
onValueChange={(value: 'mailketing') => setSettings({ ...settings, integration_email_provider: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="border-2">
|
<SelectTrigger className="border-2">
|
||||||
<SelectValue />
|
<SelectValue placeholder="Pilih provider email" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="smtp">SMTP (Default)</SelectItem>
|
<SelectItem value="mailketing">Mailketing</SelectItem>
|
||||||
<SelectItem value="resend">Resend</SelectItem>
|
|
||||||
<SelectItem value="elasticemail">ElasticEmail</SelectItem>
|
|
||||||
<SelectItem value="mailgun">Mailgun</SelectItem>
|
|
||||||
<SelectItem value="sendgrid">SendGrid</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{settings.integration_email_provider === 'mailketing' && (
|
||||||
<Label>API Base URL Provider Email</Label>
|
<>
|
||||||
<Input
|
<div className="space-y-2">
|
||||||
value={settings.integration_email_api_base_url}
|
<Label className="flex items-center gap-2">
|
||||||
onChange={(e) => setSettings({ ...settings, integration_email_api_base_url: e.target.value })}
|
<Key className="w-4 h-4" />
|
||||||
placeholder="https://api.resend.com"
|
API Token
|
||||||
className="border-2"
|
</Label>
|
||||||
disabled={settings.integration_email_provider === 'smtp'}
|
<Input
|
||||||
/>
|
type="password"
|
||||||
</div>
|
value={settings.integration_email_api_token}
|
||||||
|
onChange={(e) => setSettings({ ...settings, integration_email_api_token: e.target.value })}
|
||||||
|
placeholder="Masukkan API token dari Mailketing"
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Dapatkan API token dari menu Integration di dashboard Mailketing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nama Pengirim</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.integration_email_from_name}
|
||||||
|
onChange={(e) => setSettings({ ...settings, integration_email_from_name: e.target.value })}
|
||||||
|
placeholder="Nama Bisnis"
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Email Pengirim</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={settings.integration_email_from_email}
|
||||||
|
onChange={(e) => setSettings({ ...settings, integration_email_from_email: e.target.value })}
|
||||||
|
placeholder="info@domain.com"
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pastikan email sudah terdaftar di Mailketing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4 border-t">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(e) => setTestEmail(e.target.value)}
|
||||||
|
placeholder="Email uji coba"
|
||||||
|
className="border-2 flex-1"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={sendTestEmail} className="border-2 flex-1" disabled={sendingTest}>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
{sendingTest ? 'Mengirim...' : 'Kirim Email Uji Coba'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -273,9 +547,11 @@ export function IntegrasiTab() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Button onClick={saveSettings} disabled={saving} className="shadow-sm">
|
<div className="flex gap-4 pt-4 border-t-2 border-border">
|
||||||
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
|
<Button onClick={saveSettings} disabled={saving} className="shadow-sm flex-1">
|
||||||
</Button>
|
{saving ? 'Menyimpan...' : 'Simpan Semua Pengaturan'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Mail, AlertTriangle, Send, ChevronDown, ChevronUp, Webhook } from 'lucide-react';
|
import { Mail, ChevronDown, ChevronUp, Webhook } from 'lucide-react';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { EmailTemplatePreview } from '@/components/admin/EmailTemplatePreview';
|
||||||
interface SmtpSettings {
|
|
||||||
id?: string;
|
|
||||||
smtp_host: string;
|
|
||||||
smtp_port: number;
|
|
||||||
smtp_username: string;
|
|
||||||
smtp_password: string;
|
|
||||||
smtp_from_name: string;
|
|
||||||
smtp_from_email: string;
|
|
||||||
smtp_use_tls: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationTemplate {
|
interface NotificationTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,11 +23,14 @@ interface NotificationTemplate {
|
|||||||
last_payload_example: Record<string, unknown> | null;
|
last_payload_example: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHORTCODES_HELP = {
|
const RELEVANT_SHORTCODES = {
|
||||||
common: ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}'],
|
'payment_success': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{produk}', '{link_akses}', '{thank_you_page}'],
|
||||||
access: ['{produk}', '{link_akses}'],
|
'access_granted': ['{nama}', '{email}', '{produk}', '{link_akses}', '{username_akses}', '{password_akses}', '{kadaluarsa_akses}'],
|
||||||
consulting: ['{tanggal_konsultasi}', '{jam_konsultasi}', '{link_meet}'],
|
'order_created': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{produk}', '{payment_link}', '{thank_you_page}', '{qr_code_image}', '{qr_expiry_time}'],
|
||||||
event: ['{judul_event}', '{tanggal_event}', '{jam_event}', '{link_event}'],
|
'payment_reminder': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{batas_pembayaran}', '{jumlah_pembayaran}', '{bank_tujuan}', '{nomor_rekening}', '{payment_link}', '{thank_you_page}'],
|
||||||
|
'consulting_scheduled': ['{nama}', '{email}', '{tanggal_konsultasi}', '{jam_konsultasi}', '{durasi_konsultasi}', '{link_meet}', '{jenis_konsultasi}', '{topik_konsultasi}'],
|
||||||
|
'event_reminder': ['{nama}', '{email}', '{judul_event}', '{tanggal_event}', '{jam_event}', '{link_event}', '{lokasi_event}', '{kapasitas_event}'],
|
||||||
|
'bootcamp_progress': ['{nama}', '{email}', '{judul_bootcamp}', '{progres_bootcamp}', '{modul_selesai}', '{modul_selanjutnya}', '{link_progress}'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TEMPLATES: { key: string; name: string; defaultSubject: string; defaultBody: string }[] = [
|
const DEFAULT_TEMPLATES: { key: string; name: string; defaultSubject: string; defaultBody: string }[] = [
|
||||||
@@ -46,149 +38,447 @@ const DEFAULT_TEMPLATES: { key: string; name: string; defaultSubject: string; de
|
|||||||
key: 'payment_success',
|
key: 'payment_success',
|
||||||
name: 'Pembayaran Berhasil',
|
name: 'Pembayaran Berhasil',
|
||||||
defaultSubject: 'Pembayaran Berhasil - Order #{order_id}',
|
defaultSubject: 'Pembayaran Berhasil - Order #{order_id}',
|
||||||
defaultBody: '<h2>Halo {nama}!</h2><p>Terima kasih, pembayaran Anda sebesar <strong>{total}</strong> telah berhasil dikonfirmasi.</p><p><strong>Detail Pesanan:</strong></p><ul><li>Order ID: {order_id}</li><li>Tanggal: {tanggal_pesanan}</li><li>Metode: {metode_pembayaran}</li></ul><p>Produk: {produk}</p>'
|
defaultBody: `
|
||||||
|
<h2>Pembayaran Berhasil! 🎉</h2>
|
||||||
|
<p>Halo <strong>{nama}</strong>, terima kasih atas pembayaran Anda. Kami senang menginformasikan bahwa pembayaran Anda telah berhasil dikonfirmasi.</p>
|
||||||
|
|
||||||
|
<h3>Detail Pembayaran</h3>
|
||||||
|
<table style="width: 100%; border: 2px solid #000; margin-bottom: 25px; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Parameter</th>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Order ID</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{order_id}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Tanggal Pesanan</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">{tanggal_pesanan}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Total Pembayaran</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{total}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Metode Pembayaran</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">{metode_pembayaran}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Produk yang Dibeli</h3>
|
||||||
|
<p><strong>{produk}</strong></p>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
<a href="#" style="display:inline-block;background-color:#000;color:#FFF !important;padding:14px 28px;font-weight:700;text-transform:uppercase;text-decoration:none !important;font-size:16px;border:2px solid #000;box-shadow:4px 4px 0px 0px #000000;margin:10px 0;transition:all 0.1s;text-align:center">
|
||||||
|
Lihat Detail Pesanan
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote style="margin: 0 0 20px 0; padding: 15px 20px; border-left: 6px solid #00A651; background-color: #E6F4EA; font-style: italic; font-weight: 500; color: #005A2B;">
|
||||||
|
<strong>Info:</strong> Anda akan menerima email terpisah untuk mengakses produk Anda.
|
||||||
|
</blockquote>
|
||||||
|
`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'access_granted',
|
key: 'access_granted',
|
||||||
name: 'Akses Produk Diberikan',
|
name: 'Akses Produk Diberikan',
|
||||||
defaultSubject: 'Akses Anda Sudah Aktif - {produk}',
|
defaultSubject: 'Akses Anda Sudah Aktif - {produk}',
|
||||||
defaultBody: '<h2>Halo {nama}!</h2><p>Selamat! Akses Anda ke <strong>{produk}</strong> sudah aktif.</p><p><a href="{link_akses}" style="display:inline-block;padding:12px 24px;background:#0066cc;color:white;text-decoration:none;border-radius:6px;">Akses Sekarang</a></p>'
|
defaultBody: `
|
||||||
|
<h2>Selamat! Akses Aktif 🚀</h2>
|
||||||
|
<p>Halo <strong>{nama}</strong>, selamat! Akses Anda ke <strong>{produk}</strong> sudah aktif dan siap digunakan.</p>
|
||||||
|
|
||||||
|
<p>Anda sekarang dapat mengakses semua materi dan fitur yang tersedia dalam produk ini.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #F4F4F5; border: 2px dashed #000; padding: 20px; text-align: center; margin: 20px 0; letter-spacing: 5px; font-family: 'Courier New', Courier, monospace; font-size: 32px; font-weight: 700; color: #000;">
|
||||||
|
ACCESS GRANTED
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px; text-align: center;">
|
||||||
|
<a href="{link_akses}" style="display:inline-block;background-color:#000;color:#FFF !important;padding:14px 28px;font-weight:700;text-transform:uppercase;text-decoration:none !important;font-size:16px;border:2px solid #000;box-shadow:4px 4px 0px 0px #000000;margin:10px 0;transition:all 0.1s;width:100%;box-sizing:border-box">
|
||||||
|
Akses Sekarang
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Penting:</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Simpan link akses ini dengan aman</li>
|
||||||
|
<li>Jangan bagikan kredensial Anda kepada orang lain</li>
|
||||||
|
<li>Jika mengalami kendala, hubungi support kami</li>
|
||||||
|
</ul>
|
||||||
|
`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'order_created',
|
key: 'order_created',
|
||||||
name: 'Pesanan Dibuat',
|
name: 'Pesanan Dibuat',
|
||||||
defaultSubject: 'Pesanan Anda #{order_id} Sedang Diproses',
|
defaultSubject: 'Pesanan #{order_id} Sedang Diproses',
|
||||||
defaultBody: '<h2>Halo {nama}!</h2><p>Pesanan Anda dengan nomor <strong>{order_id}</strong> telah kami terima.</p><p>Total: <strong>{total}</strong></p><p>Silakan selesaikan pembayaran sebelum batas waktu.</p>'
|
defaultBody: `
|
||||||
|
<h2>Pesanan Diterima ✅</h2>
|
||||||
|
<p>Halo <strong>{nama}</strong>, terima kasih telah melakukan pesanan. Kami telah menerima pesanan Anda dengan detail sebagai berikut:</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border: 2px solid #000; margin-bottom: 25px; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Informasi Pesanan</th>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Nomor Pesanan</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{order_id}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Total Pembayaran</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{total}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Status</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Menunggu Pembayaran</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0; padding: 20px; background-color: #f5f5f5; border-radius: 8px;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; font-size: 18px; color: #333;">Scan QR untuk Pembayaran</h3>
|
||||||
|
<img src="{qr_code_image}" alt="QRIS Payment QR Code" style="width: 200px; height: 200px; border: 2px solid #000; padding: 10px; background-color: #fff; display: inline-block;">
|
||||||
|
<p style="margin: 15px 0 5px 0; font-size: 14px; color: #666;">Scan dengan aplikasi e-wallet atau mobile banking Anda</p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 12px; color: #999;">Berlaku hingga: {qr_expiry_time}</p>
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<a href="{payment_link}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">Bayar Sekarang</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Langkah Selanjutnya:</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Selesaikan pembayaran sebelum batas waktu</li>
|
||||||
|
<li>Setelah pembayaran dikonfirmasi, Anda akan menerima email akses produk</li>
|
||||||
|
<li>Simpan bukti pembayaran untuk arsip Anda</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<blockquote style="margin: 0 0 20px 0; padding: 15px 20px; border-left: 6px solid #E11D48; background-color: #FFE4E6; font-style: italic; font-weight: 500; color: #881337;">
|
||||||
|
<strong>Penting:</strong> Segera lakukan pembayaran agar pesanan tidak kedaluwarsa.
|
||||||
|
</blockquote>
|
||||||
|
`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'payment_reminder',
|
key: 'payment_reminder',
|
||||||
name: 'Pengingat Pembayaran',
|
name: 'Pengingat Pembayaran',
|
||||||
defaultSubject: 'Jangan Lupa Bayar - Order #{order_id}',
|
defaultSubject: 'Reminder: Segera Selesaikan Pembayaran #{order_id}',
|
||||||
defaultBody: '<h2>Halo {nama}!</h2><p>Pesanan Anda dengan nomor <strong>{order_id}</strong> menunggu pembayaran.</p><p>Total: <strong>{total}</strong></p><p>Segera selesaikan pembayaran agar tidak kedaluwarsa.</p>'
|
defaultBody: `
|
||||||
|
<h2>Reminder Pembayaran ⏰</h2>
|
||||||
|
<p>Halo <strong>{nama}</strong>, ini adalah pengingat bahwa pesanan Anda masih menunggu pembayaran.</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border: 2px solid #000; margin-bottom: 25px; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Detail Pesanan</th>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Informasi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Order ID</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{order_id}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Total Pembayaran</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{total}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Tanggal Pesanan</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">{tanggal_pesanan}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px; text-align: center;">
|
||||||
|
<a href="#" style="display:inline-block;background-color:#E11D48;color:#FFF !important;padding:14px 28px;font-weight:700;text-transform:uppercase;text-decoration:none !important;font-size:16px;border:2px solid #E11D48;box-shadow:4px 4px 0px 0px #E11D48;margin:10px 0;transition:all 0.1s;width:100%;box-sizing:border-box">
|
||||||
|
Bayar Sekarang
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote style="margin: 0 0 20px 0; padding: 15px 20px; border-left: 6px solid #E11D48; background-color: #FFE4E6; font-style: italic; font-weight: 500; color: #881337;">
|
||||||
|
<strong>Peringatan:</strong> Jika pembayaran tidak diselesaikan dalam batas waktu, pesanan akan otomatis dibatalkan.
|
||||||
|
</blockquote>
|
||||||
|
`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'consulting_scheduled',
|
key: 'consulting_scheduled',
|
||||||
name: 'Konsultasi Terjadwal',
|
name: 'Konsultasi Terjadwal',
|
||||||
defaultSubject: 'Konsultasi Anda Sudah Terjadwal - {tanggal_konsultasi}',
|
defaultSubject: 'Konsultasi Terjadwal - {tanggal_konsultasi}',
|
||||||
defaultBody: '<h2>Halo {nama}!</h2><p>Sesi konsultasi Anda telah dikonfirmasi:</p><ul><li>Tanggal: <strong>{tanggal_konsultasi}</strong></li><li>Jam: <strong>{jam_konsultasi}</strong></li></ul><p>Link meeting: <a href="{link_meet}">{link_meet}</a></p><p>Jika ada pertanyaan, hubungi kami.</p>'
|
defaultBody: `
|
||||||
|
<h2>Sesi Konsultasi Dikonfirmasi 📅</h2>
|
||||||
|
<p>Halo <strong>{nama}</strong>, sesi konsultasi Anda telah berhasil dijadwalkan. Berikut adalah detailnya:</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border: 2px solid #000; margin-bottom: 25px; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Detail Sesi</th>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Informasi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Tanggal</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{tanggal_konsultasi}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Waktu</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{jam_konsultasi}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Link Meeting</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">
|
||||||
|
<a href="{link_meet}" style="color: #000; text-decoration: underline; font-weight: 700;">{link_meet}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px; text-align: center;">
|
||||||
|
<a href="{link_meet}" style="display:inline-block;background-color:#0066cc;color:#FFF !important;padding:14px 28px;font-weight:700;text-transform:uppercase;text-decoration:none !important;font-size:16px;border:2px solid #0066cc;box-shadow:4px 4px 0px 0px #0066cc;margin:10px 0;transition:all 0.1s">
|
||||||
|
Bergabung ke Meeting
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Persiapan Sebelum Sesi:</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Uji koneksi internet Anda</li>
|
||||||
|
<li>Siapkan materi atau pertanyaan yang akan dibahas</li>
|
||||||
|
<li>Login 10 menit sebelum jadwal</li>
|
||||||
|
<li>Gunakan laptop dengan kamera dan mikrofon</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<blockquote style="margin: 0 0 20px 0; padding: 15px 20px; border-left: 6px solid #1976D2; background-color: #E3F2FD; font-style: italic; font-weight: 500; color: #0D47A1;">
|
||||||
|
<strong>Tip:</strong> Gunakan Google Chrome untuk pengalaman meeting terbaik.
|
||||||
|
</blockquote>
|
||||||
|
`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'event_reminder',
|
key: 'event_reminder',
|
||||||
name: 'Reminder Webinar/Bootcamp',
|
name: 'Reminder Webinar/Bootcamp',
|
||||||
defaultSubject: 'Reminder: {judul_event} Dimulai {tanggal_event}',
|
defaultSubject: 'Reminder: {judul_event} - {tanggal_event}',
|
||||||
defaultBody: '<h2>Halo {nama}!</h2><p>Jangan lupa, <strong>{judul_event}</strong> akan dimulai:</p><ul><li>Tanggal: {tanggal_event}</li><li>Jam: {jam_event}</li></ul><p><a href="{link_event}" style="display:inline-block;padding:12px 24px;background:#0066cc;color:white;text-decoration:none;border-radius:6px;">Bergabung</a></p>'
|
defaultBody: `
|
||||||
|
<h2>Jangan Sampai Ketinggalan! 🔥</h2>
|
||||||
|
<p>Halo <strong>{nama}</strong>, jangan lupa bahwa <strong>{judul_event}</strong> akan segera dimulai!</p>
|
||||||
|
|
||||||
|
<div style="background-color: #F4F4F5; border: 2px dashed #000; padding: 20px; text-align: center; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #E11D48;">EVENT STARTING SOON!</h3>
|
||||||
|
<div style="font-size: 24px; font-weight: 700; letter-spacing: 2px; margin: 10px 0;">
|
||||||
|
{judul_event}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style="width: 100%; border: 2px solid #000; margin-bottom: 25px; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Event Detail</th>
|
||||||
|
<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Informasi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Judul Event</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;"><strong>{judul_event}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Tanggal</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">{tanggal_event}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Waktu</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">{jam_event}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px; text-align: center;">
|
||||||
|
<a href="{link_event}" style="display:inline-block;background-color:#00A651;color:#FFF !important;padding:14px 28px;font-weight:700;text-transform:uppercase;text-decoration:none !important;font-size:16px;border:2px solid #00A651;box-shadow:4px 4px 0px 0px #00A651;margin:10px 0;transition:all 0.1s;width:100%;box-sizing:border-box">
|
||||||
|
Bergabung Sekarang
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Persiapan Event:</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Stabilkan koneksi internet Anda</li>
|
||||||
|
<li>Siapkan notebook untuk mencatat</li>
|
||||||
|
<li>Login 15 menit sebelum event dimulai</li>
|
||||||
|
<li>Siapkan pertanyaan untuk sesi Q&A</li>
|
||||||
|
</ul>
|
||||||
|
`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'bootcamp_progress',
|
key: 'bootcamp_progress',
|
||||||
name: 'Progress Bootcamp',
|
name: 'Progress Bootcamp',
|
||||||
defaultSubject: 'Update Progress Bootcamp Anda',
|
defaultSubject: 'Update Progress Bootcamp - {nama}',
|
||||||
defaultBody: '<h2>Halo {nama}!</h2><p>Ini adalah update progress bootcamp Anda.</p><p>Terus semangat belajar!</p>'
|
defaultBody: `
|
||||||
|
<h2>Progress Update 📈</h2>
|
||||||
|
<p>Halo <strong>{nama}</strong>, ini adalah update terbaru tentang progress bootcamp Anda.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #F4F4F5; border: 2px dashed #000; padding: 20px; text-align: center; margin: 20px 0;">
|
||||||
|
<div style="font-size: 18px; font-weight: 700; margin-bottom: 10px;">PROGRESS ANDA</div>
|
||||||
|
<div style="font-size: 48px; font-weight: 900; color: #00A651;">75%</div>
|
||||||
|
<div style="font-size: 14px; color: #666;">Completed Modules: 15/20</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Module Selesai:</h3>
|
||||||
|
<ul>
|
||||||
|
<li>✅ Fundamentals & Basics</li>
|
||||||
|
<li>✅ Advanced Concepts</li>
|
||||||
|
<li>✅ Practical Applications</li>
|
||||||
|
<li>✅ Project Workshop</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Module Berikutnya:</h3>
|
||||||
|
<ul>
|
||||||
|
<li>🔄 Final Assessment</li>
|
||||||
|
<li>📋 Portfolio Development</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px; text-align: center;">
|
||||||
|
<a href="#" style="display:inline-block;background-color:#000;color:#FFF !important;padding:14px 28px;font-weight:700;text-transform:uppercase;text-decoration:none !important;font-size:16px;border:2px solid #000;box-shadow:4px 4px 0px 0px #000000;margin:10px 0;transition:all 0.1s">
|
||||||
|
Lanjut Belajar
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote style="margin: 0 0 20px 0; padding: 15px 20px; border-left: 6px solid #00A651; background-color: #E6F4EA; font-style: italic; font-weight: 500; color: #005A2B;">
|
||||||
|
<strong>Motivasi:</strong> Anda sudah 75% selesai! Terus semangat, kesuksesan Anda sudah di depan mata!
|
||||||
|
</blockquote>
|
||||||
|
`
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptySmtp: SmtpSettings = {
|
|
||||||
smtp_host: '',
|
|
||||||
smtp_port: 587,
|
|
||||||
smtp_username: '',
|
|
||||||
smtp_password: '',
|
|
||||||
smtp_from_name: '',
|
|
||||||
smtp_from_email: '',
|
|
||||||
smtp_use_tls: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NotifikasiTab() {
|
export function NotifikasiTab() {
|
||||||
const [smtp, setSmtp] = useState<SmtpSettings>(emptySmtp);
|
|
||||||
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
|
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [testEmail, setTestEmail] = useState('');
|
|
||||||
const [expandedTemplates, setExpandedTemplates] = useState<Set<string>>(new Set());
|
const [expandedTemplates, setExpandedTemplates] = useState<Set<string>>(new Set());
|
||||||
const [sendingTest, setSendingTest] = useState(false);
|
const [testingTemplate, setTestingTemplate] = useState<string | null>(null);
|
||||||
|
const [previewTemplate, setPreviewTemplate] = useState<NotificationTemplate | null>(null);
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
// Fetch SMTP settings
|
try {
|
||||||
const { data: smtpData } = await supabase.from('notification_settings').select('*').single();
|
console.log('Fetching templates...');
|
||||||
if (smtpData) setSmtp(smtpData);
|
// Fetch templates
|
||||||
|
const { data: templatesData, error: fetchError } = await supabase.from('notification_templates').select('*').order('key');
|
||||||
|
|
||||||
// Fetch templates
|
if (fetchError) {
|
||||||
const { data: templatesData } = await supabase.from('notification_templates').select('*').order('key');
|
console.error('Error fetching templates:', fetchError);
|
||||||
if (templatesData && templatesData.length > 0) {
|
toast({
|
||||||
setTemplates(templatesData);
|
title: 'Error',
|
||||||
} else {
|
description: 'Gagal mengambil template: ' + fetchError.message,
|
||||||
// Seed default templates if none exist
|
variant: 'destructive'
|
||||||
await seedTemplates();
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Templates data:', templatesData);
|
||||||
|
console.log('Template count:', templatesData?.length);
|
||||||
|
|
||||||
|
if (templatesData && templatesData.length > 0) {
|
||||||
|
console.log('Setting templates from database:', templatesData.length);
|
||||||
|
// Check if any templates have empty content
|
||||||
|
const emptyTemplates = templatesData.filter(t => !t.email_subject || !t.email_body_html);
|
||||||
|
if (emptyTemplates.length > 0) {
|
||||||
|
console.log('Found templates with empty content:', emptyTemplates.map(t => ({ key: t.key, name: t.name })));
|
||||||
|
console.log('Reseeding templates with empty content...');
|
||||||
|
await forceSeedTemplates();
|
||||||
|
} else {
|
||||||
|
setTemplates(templatesData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No templates found, seeding default templates...');
|
||||||
|
// Seed default templates if none exist
|
||||||
|
await seedTemplates();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error in fetchData:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Terjadi kesalahan tak terduga saat mengambil data',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forceSeedTemplates = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Force reseed: Deleting existing templates...');
|
||||||
|
// Delete existing templates
|
||||||
|
const { error: deleteError } = await supabase.from('notification_templates').delete().neq('id', '');
|
||||||
|
if (deleteError) {
|
||||||
|
console.error('Error deleting templates:', deleteError);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Force reseed: Inserting default templates...');
|
||||||
|
await seedTemplates();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in forceSeedTemplates:', error);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const seedTemplates = async () => {
|
const seedTemplates = async () => {
|
||||||
const toInsert = DEFAULT_TEMPLATES.map(t => ({
|
|
||||||
key: t.key,
|
|
||||||
name: t.name,
|
|
||||||
is_active: false,
|
|
||||||
email_subject: t.defaultSubject,
|
|
||||||
email_body_html: t.defaultBody,
|
|
||||||
webhook_url: '',
|
|
||||||
}));
|
|
||||||
const { data, error } = await supabase.from('notification_templates').insert(toInsert).select();
|
|
||||||
if (!error && data) setTemplates(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveSmtp = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
const payload = { ...smtp };
|
|
||||||
delete payload.id;
|
|
||||||
|
|
||||||
if (smtp.id) {
|
|
||||||
const { error } = await supabase.from('notification_settings').update(payload).eq('id', smtp.id);
|
|
||||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
|
||||||
else toast({ title: 'Berhasil', description: 'Pengaturan SMTP disimpan' });
|
|
||||||
} else {
|
|
||||||
const { data, error } = await supabase.from('notification_settings').insert(payload).select().single();
|
|
||||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
|
||||||
else { setSmtp(data); toast({ title: 'Berhasil', description: 'Pengaturan SMTP disimpan' }); }
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendTestEmail = async () => {
|
|
||||||
if (!testEmail) return toast({ title: 'Error', description: 'Masukkan email tujuan', variant: 'destructive' });
|
|
||||||
if (!isSmtpConfigured) return toast({ title: 'Error', description: 'Lengkapi konfigurasi SMTP terlebih dahulu', variant: 'destructive' });
|
|
||||||
|
|
||||||
setSendingTest(true);
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.functions.invoke('send-test-email', {
|
console.log('Seeding default templates...');
|
||||||
body: {
|
const toUpsert = DEFAULT_TEMPLATES.map(t => ({
|
||||||
to: testEmail,
|
key: t.key,
|
||||||
smtp_host: smtp.smtp_host,
|
name: t.name,
|
||||||
smtp_port: smtp.smtp_port,
|
is_active: false,
|
||||||
smtp_username: smtp.smtp_username,
|
email_subject: t.defaultSubject,
|
||||||
smtp_password: smtp.smtp_password,
|
email_body_html: t.defaultBody,
|
||||||
smtp_from_name: smtp.smtp_from_name,
|
webhook_url: '',
|
||||||
smtp_from_email: smtp.smtp_from_email,
|
}));
|
||||||
smtp_use_tls: smtp.smtp_use_tls,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) throw error;
|
console.log('Upserting templates:', toUpsert.length);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('notification_templates')
|
||||||
|
.upsert(toUpsert, {
|
||||||
|
onConflict: 'key',
|
||||||
|
ignoreDuplicates: false
|
||||||
|
})
|
||||||
|
.select();
|
||||||
|
|
||||||
if (data?.success) {
|
if (error) {
|
||||||
toast({ title: 'Berhasil', description: data.message });
|
console.error('Error seeding templates:', error);
|
||||||
} else {
|
toast({
|
||||||
throw new Error(data?.message || 'Failed to send test email');
|
title: 'Error',
|
||||||
|
description: 'Gagal membuat template default: ' + error.message,
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Test email error:', error);
|
console.log('Templates seeded/updated successfully:', data);
|
||||||
toast({ title: 'Error', description: error.message || 'Gagal mengirim email uji coba', variant: 'destructive' });
|
if (data) {
|
||||||
} finally {
|
setTemplates(data);
|
||||||
setSendingTest(false);
|
toast({
|
||||||
|
title: 'Berhasil',
|
||||||
|
description: `Berhasil membuat ${data.length} template default`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error in seedTemplates:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Terjadi kesalahan saat membuat template default',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const updateTemplate = async (template: NotificationTemplate) => {
|
const updateTemplate = async (template: NotificationTemplate) => {
|
||||||
const { id, key, name, ...updates } = template;
|
const { id, key, name, ...updates } = template;
|
||||||
const { error } = await supabase.from('notification_templates').update(updates).eq('id', id);
|
const { error } = await supabase.from('notification_templates').update(updates).eq('id', id);
|
||||||
@@ -196,6 +486,60 @@ export function NotifikasiTab() {
|
|||||||
else toast({ title: 'Berhasil', description: `Template "${name}" disimpan` });
|
else toast({ title: 'Berhasil', description: `Template "${name}" disimpan` });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendTestEmail = async (template: NotificationTemplate & { test_email?: string }) => {
|
||||||
|
if (!template.test_email) {
|
||||||
|
toast({ title: 'Error', description: 'Masukkan email tujuan', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestingTemplate(template.id);
|
||||||
|
try {
|
||||||
|
// Fetch platform settings to get brand name
|
||||||
|
const { data: platformData } = await supabase
|
||||||
|
.from('platform_settings')
|
||||||
|
.select('brand_name')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const brandName = platformData?.brand_name || 'ACCESS HUB';
|
||||||
|
|
||||||
|
// Import ShortcodeProcessor to get dummy data
|
||||||
|
const { ShortcodeProcessor } = await import('@/lib/email-templates/master-template');
|
||||||
|
|
||||||
|
// Get default dummy data for all template variables
|
||||||
|
const dummyData = ShortcodeProcessor.getDummyData();
|
||||||
|
|
||||||
|
// Send test email using send-notification (same as IntegrasiTab)
|
||||||
|
const { data, error } = await supabase.functions.invoke('send-notification', {
|
||||||
|
body: {
|
||||||
|
template_key: template.key,
|
||||||
|
recipient_email: template.test_email,
|
||||||
|
recipient_name: dummyData.nama,
|
||||||
|
variables: {
|
||||||
|
...dummyData,
|
||||||
|
platform_name: brandName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data?.success) {
|
||||||
|
toast({ title: 'Berhasil', description: `Email test "${template.name}" dikirim ke ${template.test_email}` });
|
||||||
|
} else {
|
||||||
|
throw new Error(data?.message || 'Failed to send test email');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Test template email error:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.message || 'Gagal mengirim email test template',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTestingTemplate(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleExpand = (id: string) => {
|
const toggleExpand = (id: string) => {
|
||||||
setExpandedTemplates(prev => {
|
setExpandedTemplates(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -205,113 +549,41 @@ export function NotifikasiTab() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSmtpConfigured = smtp.smtp_host && smtp.smtp_username && smtp.smtp_password;
|
|
||||||
|
|
||||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* SMTP Settings */}
|
{/* Notification Templates Info */}
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
Pengaturan SMTP
|
Konfigurasi Email
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Konfigurasi server email untuk pengiriman notifikasi</CardDescription>
|
<CardDescription>
|
||||||
|
Pengaturan provider email (Mailketing API) ada di tab <strong>Integrasi</strong>
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent>
|
||||||
{!isSmtpConfigured && (
|
<Alert>
|
||||||
<Alert variant="destructive" className="border-2">
|
<Mail className="w-4 h-4" />
|
||||||
<AlertTriangle className="w-4 h-4" />
|
<AlertDescription>
|
||||||
<AlertDescription>
|
Konfigurasikan provider email di tab <strong>Integrasi → Provider Email</strong> untuk mengirim notifikasi.
|
||||||
Konfigurasi SMTP belum lengkap. Email tidak akan terkirim.
|
Gunakan Mailketing API untuk pengiriman email yang andal.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
<div className="mt-3 pt-3 border-t">
|
||||||
|
<Button
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
variant="outline"
|
||||||
<div className="space-y-2">
|
size="sm"
|
||||||
<Label>SMTP Host</Label>
|
onClick={forceSeedTemplates}
|
||||||
<Input
|
className="text-xs"
|
||||||
value={smtp.smtp_host}
|
>
|
||||||
onChange={(e) => setSmtp({ ...smtp, smtp_host: e.target.value })}
|
🔄 Reset Template Default
|
||||||
placeholder="smtp.example.com"
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>SMTP Port</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={smtp.smtp_port}
|
|
||||||
onChange={(e) => setSmtp({ ...smtp, smtp_port: parseInt(e.target.value) || 587 })}
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Username</Label>
|
|
||||||
<Input
|
|
||||||
value={smtp.smtp_username}
|
|
||||||
onChange={(e) => setSmtp({ ...smtp, smtp_username: e.target.value })}
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Password</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={smtp.smtp_password}
|
|
||||||
onChange={(e) => setSmtp({ ...smtp, smtp_password: e.target.value })}
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Nama Pengirim</Label>
|
|
||||||
<Input
|
|
||||||
value={smtp.smtp_from_name}
|
|
||||||
onChange={(e) => setSmtp({ ...smtp, smtp_from_name: e.target.value })}
|
|
||||||
placeholder="Nama Bisnis"
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Email Pengirim</Label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={smtp.smtp_from_email}
|
|
||||||
onChange={(e) => setSmtp({ ...smtp, smtp_from_email: e.target.value })}
|
|
||||||
placeholder="noreply@example.com"
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
checked={smtp.smtp_use_tls}
|
|
||||||
onCheckedChange={(checked) => setSmtp({ ...smtp, smtp_use_tls: checked })}
|
|
||||||
/>
|
|
||||||
<Label>Gunakan TLS/SSL</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 pt-4 border-t">
|
|
||||||
<Button onClick={saveSmtp} disabled={saving}>
|
|
||||||
{saving ? 'Menyimpan...' : 'Simpan'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-2 flex-1">
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
<Input
|
Gunakan jika template kosong atau bermasalah
|
||||||
type="email"
|
</span>
|
||||||
value={testEmail}
|
|
||||||
onChange={(e) => setTestEmail(e.target.value)}
|
|
||||||
placeholder="Email uji coba"
|
|
||||||
className="border-2 max-w-xs"
|
|
||||||
/>
|
|
||||||
<Button variant="outline" onClick={sendTestEmail} className="border-2" disabled={sendingTest}>
|
|
||||||
<Send className="w-4 h-4 mr-2" />
|
|
||||||
{sendingTest ? 'Mengirim...' : 'Kirim Email Uji Coba'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -327,24 +599,12 @@ export function NotifikasiTab() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="text-sm text-muted-foreground p-3 bg-muted rounded-md space-y-2">
|
<div className="text-sm text-muted-foreground p-3 bg-muted rounded-md space-y-2">
|
||||||
<p className="font-medium">Shortcode yang tersedia:</p>
|
<p className="font-medium">Shortcode yang tersedia:</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<p className="text-xs">Setiap template memiliki shortcode yang relevan dengan jenis notifikasinya. Lihat detail template untuk shortcode yang tersedia.</p>
|
||||||
<div>
|
<p className="text-xs mt-3 p-2 bg-background rounded border">
|
||||||
<span className="font-medium">Umum:</span> {SHORTCODES_HELP.common.join(', ')}
|
<strong>Penting:</strong> Email dikirim melalui Mailketing API yang dikonfigurasi di tab Integrasi.
|
||||||
</div>
|
Pastikan API token valid dan domain pengirim sudah terdaftar di Mailketing.
|
||||||
<div>
|
Toggle "Aktifkan" hanya mengontrol pengiriman email. Jika <code>webhook_url</code> diisi,
|
||||||
<span className="font-medium">Akses:</span> {SHORTCODES_HELP.access.join(', ')}
|
sistem tetap akan mengirim payload ke URL tersebut meskipun email dinonaktifkan.
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Konsultasi:</span> {SHORTCODES_HELP.consulting.join(', ')}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Event:</span> {SHORTCODES_HELP.event.join(', ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs mt-2 p-2 bg-background rounded border">
|
|
||||||
<strong>Penting:</strong> Toggle "Aktifkan" hanya mengontrol pengiriman email.
|
|
||||||
Jika <code>webhook_url</code> diisi, sistem tetap akan mengirim payload ke URL tersebut
|
|
||||||
meskipun email dinonaktifkan.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -381,49 +641,76 @@ export function NotifikasiTab() {
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="p-4 pt-0 space-y-4 border-t">
|
<div className="p-4 pt-0 space-y-6 border-t">
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Label>Subjek Email</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label>Subjek Email</Label>
|
||||||
value={template.email_subject}
|
<Input
|
||||||
onChange={(e) => {
|
value={template.email_subject}
|
||||||
setTemplates(templates.map(t =>
|
onChange={(e) => {
|
||||||
t.id === template.id ? { ...t, email_subject: e.target.value } : t
|
setTemplates(templates.map(t =>
|
||||||
));
|
t.id === template.id ? { ...t, email_subject: e.target.value } : t
|
||||||
}}
|
));
|
||||||
placeholder="Subjek email..."
|
}}
|
||||||
className="border-2"
|
placeholder="Subjek email..."
|
||||||
/>
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Isi Email (HTML)</Label>
|
||||||
|
<RichTextEditor
|
||||||
|
content={template.email_body_html}
|
||||||
|
onChange={(html) => {
|
||||||
|
setTemplates(templates.map(t =>
|
||||||
|
t.id === template.id ? { ...t, email_body_html: html } : t
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
placeholder="Tulis isi email..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Webhook className="w-4 h-4" />
|
||||||
|
Webhook URL (opsional, untuk n8n/Zapier)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={template.webhook_url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTemplates(templates.map(t =>
|
||||||
|
t.id === template.id ? { ...t, webhook_url: e.target.value } : t
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
placeholder="https://n8n.example.com/webhook/..."
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relevant Shortcodes for this Template */}
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<h4 className="font-semibold text-sm mb-2">Shortcodes untuk template ini:</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{RELEVANT_SHORTCODES[template.key as keyof typeof RELEVANT_SHORTCODES]?.map(shortcode => (
|
||||||
|
<code key={shortcode} className="bg-blue-100 px-2 py-1 rounded text-xs">
|
||||||
|
{shortcode}
|
||||||
|
</code>
|
||||||
|
)) || <span className="text-xs text-gray-500">Tidak ada shortcode khusus untuk template ini</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex gap-2">
|
||||||
<Label>Isi Email (HTML)</Label>
|
<Button
|
||||||
<RichTextEditor
|
onClick={() => {
|
||||||
content={template.email_body_html}
|
updateTemplate(template);
|
||||||
onChange={(html) => {
|
setPreviewTemplate(template);
|
||||||
setTemplates(templates.map(t =>
|
setIsPreviewOpen(true);
|
||||||
t.id === template.id ? { ...t, email_body_html: html } : t
|
|
||||||
));
|
|
||||||
}}
|
}}
|
||||||
placeholder="Tulis isi email..."
|
className="shadow-sm flex-1"
|
||||||
/>
|
>
|
||||||
</div>
|
Simpan & Preview
|
||||||
|
</Button>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Webhook className="w-4 h-4" />
|
|
||||||
Webhook URL (opsional, untuk n8n/Zapier)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={template.webhook_url}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTemplates(templates.map(t =>
|
|
||||||
t.id === template.id ? { ...t, webhook_url: e.target.value } : t
|
|
||||||
));
|
|
||||||
}}
|
|
||||||
placeholder="https://n8n.example.com/webhook/..."
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{template.last_payload_example && (
|
{template.last_payload_example && (
|
||||||
@@ -435,12 +722,14 @@ export function NotifikasiTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
onClick={() => updateTemplate(template)}
|
<Button
|
||||||
className="shadow-sm"
|
onClick={() => updateTemplate(template)}
|
||||||
>
|
className="shadow-sm flex-1"
|
||||||
Simpan Template
|
>
|
||||||
</Button>
|
Simpan Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,6 +737,17 @@ export function NotifikasiTab() {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Modal Email Preview */}
|
||||||
|
{previewTemplate && (
|
||||||
|
<EmailTemplatePreview
|
||||||
|
template={previewTemplate}
|
||||||
|
open={isPreviewOpen}
|
||||||
|
onClose={() => setIsPreviewOpen(false)}
|
||||||
|
onTest={sendTestEmail}
|
||||||
|
isTestSending={testingTemplate === previewTemplate.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Video, Calendar, Clock, Star, MessageSquare, CheckCircle } from 'lucide-react';
|
import { Video, Calendar, Clock, Star, MessageSquare, CheckCircle, Download } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
import { id } from 'date-fns/locale';
|
||||||
import { ReviewModal } from './ReviewModal';
|
import { ReviewModal } from './ReviewModal';
|
||||||
|
|
||||||
interface ConsultingSlot {
|
interface ConsultingSession {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
session_date: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
status: string;
|
status: string;
|
||||||
topic_category: string | null;
|
topic_category: string | null;
|
||||||
meet_link: string | null;
|
meet_link: string | null;
|
||||||
order_id: string | null;
|
order_id: string | null;
|
||||||
|
total_blocks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConsultingHistoryProps {
|
interface ConsultingHistoryProps {
|
||||||
@@ -25,35 +26,32 @@ interface ConsultingHistoryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||||
const [slots, setSlots] = useState<ConsultingSlot[]>([]);
|
const [sessions, setSessions] = useState<ConsultingSession[]>([]);
|
||||||
const [reviewedSlotIds, setReviewedSlotIds] = useState<Set<string>>(new Set());
|
const [reviewedOrderIds, setReviewedOrderIds] = useState<Set<string>>(new Set());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [reviewModal, setReviewModal] = useState<{
|
const [reviewModal, setReviewModal] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
slotId: string;
|
|
||||||
orderId: string | null;
|
orderId: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
}>({ open: false, slotId: '', orderId: null, label: '' });
|
}>({ open: false, orderId: null, label: '' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
// Fetch consulting slots
|
// Fetch consulting sessions
|
||||||
const { data: slotsData } = await supabase
|
const { data: sessionsData } = await supabase
|
||||||
.from('consulting_slots')
|
.from('consulting_sessions')
|
||||||
.select('id, date, start_time, end_time, status, topic_category, meet_link, order_id')
|
.select('id, session_date, start_time, end_time, status, topic_category, meet_link, order_id, total_blocks')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.order('date', { ascending: false });
|
.order('session_date', { ascending: false });
|
||||||
|
|
||||||
if (slotsData) {
|
if (sessionsData) {
|
||||||
setSlots(slotsData);
|
setSessions(sessionsData);
|
||||||
|
|
||||||
// Check which slots have been reviewed
|
// Check which orders have been reviewed
|
||||||
// We use a combination approach: check for consulting reviews by this user
|
const orderIds = sessionsData
|
||||||
// For consulting, we'll track by order_id since that's how we link them
|
|
||||||
const orderIds = slotsData
|
|
||||||
.filter(s => s.order_id)
|
.filter(s => s.order_id)
|
||||||
.map(s => s.order_id as string);
|
.map(s => s.order_id as string);
|
||||||
|
|
||||||
@@ -66,14 +64,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
|||||||
.in('order_id', orderIds);
|
.in('order_id', orderIds);
|
||||||
|
|
||||||
if (reviewsData) {
|
if (reviewsData) {
|
||||||
const reviewedOrderIds = new Set(reviewsData.map(r => r.order_id));
|
setReviewedOrderIds(new Set(reviewsData.map(r => r.order_id)));
|
||||||
// Map order_id back to slot_id
|
|
||||||
const reviewedIds = new Set(
|
|
||||||
slotsData
|
|
||||||
.filter(s => s.order_id && reviewedOrderIds.has(s.order_id))
|
|
||||||
.map(s => s.id)
|
|
||||||
);
|
|
||||||
setReviewedSlotIds(reviewedIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,36 +75,67 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
|||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'done':
|
case 'done':
|
||||||
return <Badge className="bg-accent">Selesai</Badge>;
|
return <Badge className="bg-brand-accent text-white rounded-full">Selesai</Badge>;
|
||||||
case 'confirmed':
|
case 'confirmed':
|
||||||
return <Badge className="bg-primary">Terkonfirmasi</Badge>;
|
return <Badge className="bg-brand-accent text-white rounded-full">Terkonfirmasi</Badge>;
|
||||||
case 'pending_payment':
|
case 'pending_payment':
|
||||||
return <Badge className="bg-secondary">Menunggu Pembayaran</Badge>;
|
return <Badge className="bg-amber-500 text-white rounded-full">Pending</Badge>;
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
return <Badge variant="destructive">Dibatalkan</Badge>;
|
return <Badge className="bg-destructive text-white rounded-full">Dibatalkan</Badge>;
|
||||||
default:
|
default:
|
||||||
return <Badge variant="outline">{status}</Badge>;
|
return <Badge className="bg-secondary rounded-full">{status}</Badge>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openReviewModal = (slot: ConsultingSlot) => {
|
// Check if session has passed
|
||||||
const dateLabel = format(new Date(slot.date), 'd MMMM yyyy', { locale: id });
|
const isSessionPassed = (session: ConsultingSession) => {
|
||||||
const timeLabel = `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)}`;
|
const sessionEndDateTime = new Date(`${session.session_date}T${session.end_time}`);
|
||||||
|
return new Date() > sessionEndDateTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReviewModal = (session: ConsultingSession) => {
|
||||||
|
const dateLabel = format(new Date(session.session_date), 'd MMMM yyyy', { locale: id });
|
||||||
|
const timeLabel = `${session.start_time.substring(0, 5)} - ${session.end_time.substring(0, 5)}`;
|
||||||
setReviewModal({
|
setReviewModal({
|
||||||
open: true,
|
open: true,
|
||||||
slotId: slot.id,
|
orderId: session.order_id,
|
||||||
orderId: slot.order_id,
|
|
||||||
label: `Sesi konsultasi ${dateLabel}, ${timeLabel}`,
|
label: `Sesi konsultasi ${dateLabel}, ${timeLabel}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReviewSuccess = () => {
|
const handleReviewSuccess = () => {
|
||||||
// Mark this slot as reviewed
|
// Mark this order as reviewed
|
||||||
setReviewedSlotIds(prev => new Set([...prev, reviewModal.slotId]));
|
if (reviewModal.orderId) {
|
||||||
|
setReviewedOrderIds(prev => new Set([...prev, reviewModal.orderId!]));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doneSlots = slots.filter(s => s.status === 'done');
|
// Generate Google Calendar link for adding to user's calendar
|
||||||
const upcomingSlots = slots.filter(s => s.status === 'confirmed');
|
const generateCalendarLink = (session: ConsultingSession) => {
|
||||||
|
if (!session.meet_link) return null;
|
||||||
|
|
||||||
|
const startDate = new Date(`${session.session_date}T${session.start_time}`);
|
||||||
|
const endDate = new Date(`${session.session_date}T${session.end_time}`);
|
||||||
|
|
||||||
|
// Format dates for Google Calendar (YYYYMMDDTHHmmssZ)
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toISOString().replace(/-|:|\.\d\d\d/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'TEMPLATE',
|
||||||
|
text: `Konsultasi: ${session.topic_category || 'Sesi Konsultasi'}`,
|
||||||
|
dates: `${formatDate(startDate)}/${formatDate(endDate)}`,
|
||||||
|
details: `Link Meet: ${session.meet_link}`,
|
||||||
|
location: session.meet_link,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://www.google.com/calendar/render?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const doneSessions = sessions.filter(s => s.status === 'done' || s.status === 'completed');
|
||||||
|
const upcomingSessions = sessions.filter(s => s.status === 'confirmed' && !isSessionPassed(s));
|
||||||
|
const passedSessions = sessions.filter(s => s.status === 'confirmed' && isSessionPassed(s));
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +154,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slots.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,32 +169,49 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Upcoming sessions */}
|
{/* Upcoming sessions */}
|
||||||
{upcomingSlots.length > 0 && (
|
{upcomingSessions.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">Sesi Mendatang</h4>
|
<h4 className="text-sm font-medium text-muted-foreground">Sesi Mendatang</h4>
|
||||||
{upcomingSlots.map((slot) => (
|
{upcomingSessions.map((session) => (
|
||||||
<div key={slot.id} className="flex items-center justify-between p-3 border-2 border-border bg-muted/30">
|
<div key={session.id} className="flex items-center justify-between p-3 border-2 border-border bg-muted/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{format(new Date(slot.date), 'd MMM yyyy', { locale: id })}
|
{format(new Date(session.session_date), 'd MMM yyyy', { locale: id })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
|
{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}
|
||||||
{slot.topic_category && ` • ${slot.topic_category}`}
|
{session.topic_category && ` • ${session.topic_category}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getStatusBadge(slot.status)}
|
{getStatusBadge(session.status)}
|
||||||
{slot.meet_link && (
|
{session.meet_link && (
|
||||||
<Button asChild size="sm" variant="outline" className="border-2">
|
<>
|
||||||
<a href={slot.meet_link} target="_blank" rel="noopener noreferrer">
|
<Button asChild size="sm" variant="outline" className="border-2">
|
||||||
Join
|
<a href={session.meet_link} target="_blank" rel="noopener noreferrer">
|
||||||
</a>
|
Join
|
||||||
</Button>
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={generateCalendarLink(session) || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Tambah ke Kalender"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,29 +219,56 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Passed confirmed sessions (waiting for admin action) */}
|
||||||
|
{passedSessions.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium text-orange-600 dark:text-orange-400">Sesi Terlewat</h4>
|
||||||
|
{passedSessions.map((session) => (
|
||||||
|
<div key={session.id} className="flex items-center justify-between p-3 border-2 border-orange-200 bg-orange-50 dark:bg-orange-950/20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-4 h-4 text-orange-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{format(new Date(session.session_date), 'd MMM yyyy', { locale: id })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}
|
||||||
|
{session.topic_category && ` • ${session.topic_category}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusBadge(session.status)}
|
||||||
|
<span className="text-xs text-muted-foreground">Menunggu konfirmasi admin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Completed sessions */}
|
{/* Completed sessions */}
|
||||||
{doneSlots.length > 0 && (
|
{doneSessions.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">Sesi Selesai</h4>
|
<h4 className="text-sm font-medium text-muted-foreground">Sesi Selesai</h4>
|
||||||
{doneSlots.map((slot) => {
|
{doneSessions.map((session) => {
|
||||||
const hasReviewed = reviewedSlotIds.has(slot.id);
|
const hasReviewed = session.order_id ? reviewedOrderIds.has(session.order_id) : false;
|
||||||
return (
|
return (
|
||||||
<div key={slot.id} className="flex items-center justify-between p-3 border-2 border-border">
|
<div key={session.id} className="flex items-center justify-between p-3 border-2 border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{format(new Date(slot.date), 'd MMM yyyy', { locale: id })}
|
{format(new Date(session.session_date), 'd MMM yyyy', { locale: id })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
|
{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}
|
||||||
{slot.topic_category && ` • ${slot.topic_category}`}
|
{session.topic_category && ` • ${session.topic_category}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getStatusBadge(slot.status)}
|
{getStatusBadge(session.status)}
|
||||||
{hasReviewed ? (
|
{hasReviewed ? (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<CheckCircle className="w-4 h-4 text-accent" />
|
<CheckCircle className="w-4 h-4 text-accent" />
|
||||||
@@ -212,7 +278,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openReviewModal(slot)}
|
onClick={() => openReviewModal(session)}
|
||||||
className="border-2"
|
className="border-2"
|
||||||
>
|
>
|
||||||
<Star className="w-4 h-4 mr-1" />
|
<Star className="w-4 h-4 mr-1" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface Review {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
profiles: { name: string | null } | null;
|
profiles: { name: string | null; avatar_url: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductReviewsProps {
|
interface ProductReviewsProps {
|
||||||
@@ -29,7 +29,7 @@ export function ProductReviews({ productId, type }: ProductReviewsProps) {
|
|||||||
const fetchReviews = async () => {
|
const fetchReviews = async () => {
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('reviews')
|
.from('reviews')
|
||||||
.select('id, rating, title, body, created_at, profiles:user_id (name)')
|
.select('id, rating, title, body, created_at, profiles!user_id (name, avatar_url)')
|
||||||
.eq('is_approved', true);
|
.eq('is_approved', true);
|
||||||
|
|
||||||
if (productId) {
|
if (productId) {
|
||||||
@@ -75,6 +75,7 @@ export function ProductReviews({ productId, type }: ProductReviewsProps) {
|
|||||||
title={review.title}
|
title={review.title}
|
||||||
body={review.body}
|
body={review.body}
|
||||||
authorName={review.profiles?.name || 'Anonymous'}
|
authorName={review.profiles?.name || 'Anonymous'}
|
||||||
|
authorAvatar={review.profiles?.avatar_url}
|
||||||
date={review.created_at}
|
date={review.created_at}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ interface ReviewCardProps {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
authorAvatar?: string | null;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewCard({ rating, title, body, authorName, date }: ReviewCardProps) {
|
export function ReviewCard({ rating, title, body, authorName, authorAvatar, date }: ReviewCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="border-2 border-border p-6 space-y-3">
|
<div className="border-2 border-border p-6 space-y-3">
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
@@ -24,7 +25,16 @@ export function ReviewCard({ rating, title, body, authorName, date }: ReviewCard
|
|||||||
<h4 className="font-bold">{title}</h4>
|
<h4 className="font-bold">{title}</h4>
|
||||||
{body && <p className="text-muted-foreground text-sm">{body}</p>}
|
{body && <p className="text-muted-foreground text-sm">{body}</p>}
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>{authorName}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
{authorAvatar && (
|
||||||
|
<img
|
||||||
|
src={authorAvatar}
|
||||||
|
alt={authorName}
|
||||||
|
className="w-6 h-6 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{authorName}</span>
|
||||||
|
</div>
|
||||||
<span>{new Date(date).toLocaleDateString('id-ID')}</span>
|
<span>{new Date(date).toLocaleDateString('id-ID')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,6 +15,12 @@ interface ReviewModalProps {
|
|||||||
orderId?: string | null;
|
orderId?: string | null;
|
||||||
type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
|
type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
|
existingReview?: {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ export function ReviewModal({
|
|||||||
orderId,
|
orderId,
|
||||||
type,
|
type,
|
||||||
contextLabel,
|
contextLabel,
|
||||||
|
existingReview,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: ReviewModalProps) {
|
}: ReviewModalProps) {
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
@@ -34,6 +41,20 @@ export function ReviewModal({
|
|||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Pre-populate form when existingReview is provided or modal opens with existing data
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingReview) {
|
||||||
|
setRating(existingReview.rating);
|
||||||
|
setTitle(existingReview.title || '');
|
||||||
|
setBody(existingReview.body || '');
|
||||||
|
} else {
|
||||||
|
// Reset form for new review
|
||||||
|
setRating(0);
|
||||||
|
setTitle('');
|
||||||
|
setBody('');
|
||||||
|
}
|
||||||
|
}, [existingReview, open]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (rating === 0) {
|
if (rating === 0) {
|
||||||
toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' });
|
||||||
@@ -45,22 +66,46 @@ export function ReviewModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const { error } = await supabase.from('reviews').insert({
|
|
||||||
user_id: userId,
|
let error;
|
||||||
product_id: productId || null,
|
|
||||||
order_id: orderId || null,
|
if (existingReview) {
|
||||||
type,
|
// Update existing review
|
||||||
rating,
|
const result = await supabase
|
||||||
title: title.trim(),
|
.from('reviews')
|
||||||
body: body.trim() || null,
|
.update({
|
||||||
is_approved: false,
|
rating,
|
||||||
});
|
title: title.trim(),
|
||||||
|
body: body.trim() || null,
|
||||||
|
is_approved: false, // Reset approval status on edit
|
||||||
|
})
|
||||||
|
.eq('id', existingReview.id);
|
||||||
|
error = result.error;
|
||||||
|
} else {
|
||||||
|
// Insert new review
|
||||||
|
const result = await supabase.from('reviews').insert({
|
||||||
|
user_id: userId,
|
||||||
|
product_id: productId || null,
|
||||||
|
order_id: orderId || null,
|
||||||
|
type,
|
||||||
|
rating,
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim() || null,
|
||||||
|
is_approved: false,
|
||||||
|
});
|
||||||
|
error = result.error;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Review submit error:', error);
|
console.error('Review submit error:', error);
|
||||||
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
|
||||||
} else {
|
} else {
|
||||||
toast({ title: 'Berhasil', description: 'Terima kasih! Ulasan Anda akan ditinjau oleh admin.' });
|
toast({
|
||||||
|
title: 'Berhasil',
|
||||||
|
description: existingReview
|
||||||
|
? 'Ulasan Anda diperbarui dan akan ditinjau ulang oleh admin.'
|
||||||
|
: 'Terima kasih! Ulasan Anda akan ditinjau oleh admin.'
|
||||||
|
});
|
||||||
// Reset form
|
// Reset form
|
||||||
setRating(0);
|
setRating(0);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
@@ -81,7 +126,7 @@ export function ReviewModal({
|
|||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Beri Ulasan</DialogTitle>
|
<DialogTitle>{existingReview ? 'Edit Ulasan' : 'Beri Ulasan'}</DialogTitle>
|
||||||
{contextLabel && (
|
{contextLabel && (
|
||||||
<DialogDescription>{contextLabel}</DialogDescription>
|
<DialogDescription>{contextLabel}</DialogDescription>
|
||||||
)}
|
)}
|
||||||
@@ -140,7 +185,7 @@ export function ReviewModal({
|
|||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={submitting}>
|
<Button onClick={handleSubmit} disabled={submitting}>
|
||||||
{submitting ? 'Mengirim...' : 'Kirim Ulasan'}
|
{submitting ? 'Menyimpan...' : (existingReview ? 'Simpan Perubahan' : 'Kirim Ulasan')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface Review {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
profiles: { name: string | null } | null;
|
profiles: { name: string | null; avatar_url: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestimonialsSection() {
|
export function TestimonialsSection() {
|
||||||
@@ -22,7 +22,7 @@ export function TestimonialsSection() {
|
|||||||
const fetchReviews = async () => {
|
const fetchReviews = async () => {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('reviews')
|
.from('reviews')
|
||||||
.select('id, rating, title, body, created_at, profiles:user_id (name)')
|
.select('id, rating, title, body, created_at, profiles!user_id (name, avatar_url)')
|
||||||
.eq('is_approved', true)
|
.eq('is_approved', true)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(6);
|
.limit(6);
|
||||||
@@ -46,6 +46,7 @@ export function TestimonialsSection() {
|
|||||||
title={review.title}
|
title={review.title}
|
||||||
body={review.body}
|
body={review.body}
|
||||||
authorName={review.profiles?.name || 'Anonymous'}
|
authorName={review.profiles?.name || 'Anonymous'}
|
||||||
|
authorAvatar={review.profiles?.avatar_url}
|
||||||
date={review.created_at}
|
date={review.created_at}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const badgeVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80 hover:text-white",
|
||||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
|
|||||||
352
src/hooks/useAdiloPlayer.ts
Normal file
352
src/hooks/useAdiloPlayer.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
import videojs from 'video.js';
|
||||||
|
import 'video.js/dist/video-js.css';
|
||||||
|
|
||||||
|
interface UseAdiloPlayerProps {
|
||||||
|
m3u8Url?: string;
|
||||||
|
mp4Url?: string;
|
||||||
|
autoplay?: boolean;
|
||||||
|
onTimeUpdate?: (time: number) => void;
|
||||||
|
onDuration?: (duration: number) => void;
|
||||||
|
onEnded?: () => void;
|
||||||
|
onError?: (error: any) => void;
|
||||||
|
accentColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAdiloPlayer = ({
|
||||||
|
m3u8Url,
|
||||||
|
mp4Url,
|
||||||
|
autoplay = false,
|
||||||
|
onTimeUpdate,
|
||||||
|
onDuration,
|
||||||
|
onEnded,
|
||||||
|
onError,
|
||||||
|
accentColor,
|
||||||
|
}: UseAdiloPlayerProps) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const videoJsRef = useRef<any>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [error, setError] = useState<any>(null);
|
||||||
|
|
||||||
|
// Use refs to store stable callback references
|
||||||
|
const callbacksRef = useRef({
|
||||||
|
onTimeUpdate,
|
||||||
|
onDuration,
|
||||||
|
onEnded,
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update callbacks ref when props change
|
||||||
|
useEffect(() => {
|
||||||
|
callbacksRef.current = {
|
||||||
|
onTimeUpdate,
|
||||||
|
onDuration,
|
||||||
|
onEnded,
|
||||||
|
onError,
|
||||||
|
};
|
||||||
|
}, [onTimeUpdate, onDuration, onEnded, onError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || (!m3u8Url && !mp4Url)) return;
|
||||||
|
|
||||||
|
// Clean up previous HLS instance
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Try M3U8 with HLS.js first
|
||||||
|
if (m3u8Url) {
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
xhrSetup: (xhr, url) => {
|
||||||
|
// Allow CORS for HLS requests
|
||||||
|
xhr.withCredentials = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
hlsRef.current = hls;
|
||||||
|
hls.loadSource(m3u8Url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
|
||||||
|
console.log('✅ HLS manifest parsed:', data.levels.length, 'quality levels');
|
||||||
|
// Don't set ready yet - wait for first fragment to load
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.FRAG_PARSED, () => {
|
||||||
|
console.log('✅ First segment loaded, video ready');
|
||||||
|
setIsReady(true);
|
||||||
|
|
||||||
|
// Log video element state
|
||||||
|
console.log('📹 Video element state:', {
|
||||||
|
readyState: video.readyState,
|
||||||
|
videoWidth: video.videoWidth,
|
||||||
|
videoHeight: video.videoHeight,
|
||||||
|
duration: video.duration,
|
||||||
|
paused: video.paused,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error('Autoplay failed:', err);
|
||||||
|
callbacksRef.current.onError?.(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
console.error('❌ HLS error:', data.type, data.details);
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
console.log('🔄 Recovering from network error...');
|
||||||
|
hls.startLoad();
|
||||||
|
break;
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
console.log('🔄 Recovering from media error...');
|
||||||
|
hls.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error('💥 Fatal error, destroying HLS instance');
|
||||||
|
hls.destroy();
|
||||||
|
// Fallback to MP4
|
||||||
|
if (mp4Url) {
|
||||||
|
console.log('📹 Falling back to MP4');
|
||||||
|
video.src = mp4Url;
|
||||||
|
} else {
|
||||||
|
setError(data);
|
||||||
|
callbacksRef.current.onError?.(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Safari native HLS support
|
||||||
|
video.src = m3u8Url;
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error('Autoplay failed:', err);
|
||||||
|
callbacksRef.current.onError?.(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No HLS support, fallback to MP4
|
||||||
|
if (mp4Url) {
|
||||||
|
video.src = mp4Url;
|
||||||
|
} else {
|
||||||
|
setError(new Error('No supported video format'));
|
||||||
|
callbacksRef.current.onError?.(new Error('No supported video format'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (mp4Url) {
|
||||||
|
// Direct MP4 playback
|
||||||
|
video.src = mp4Url;
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error('Autoplay failed:', err);
|
||||||
|
callbacksRef.current.onError?.(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time update handler
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const time = video.currentTime;
|
||||||
|
setCurrentTime(time);
|
||||||
|
callbacksRef.current.onTimeUpdate?.(time);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Duration handler
|
||||||
|
const handleDurationChange = () => {
|
||||||
|
const dur = video.duration;
|
||||||
|
if (dur && !isNaN(dur)) {
|
||||||
|
setDuration(dur);
|
||||||
|
callbacksRef.current.onDuration?.(dur);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Play/pause handlers
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
callbacksRef.current.onEnded?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
video.addEventListener('durationchange', handleDurationChange);
|
||||||
|
video.addEventListener('play', handlePlay);
|
||||||
|
video.addEventListener('pause', handlePause);
|
||||||
|
video.addEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
// Initialize Video.js after HLS.js has set up the video
|
||||||
|
// Wait for video to be ready before initializing Video.js
|
||||||
|
const initializeVideoJs = () => {
|
||||||
|
if (!videoRef.current || videoJsRef.current) return;
|
||||||
|
|
||||||
|
// Initialize Video.js with the video element
|
||||||
|
const player = videojs(videoRef.current, {
|
||||||
|
controls: true,
|
||||||
|
autoplay: false,
|
||||||
|
preload: 'auto',
|
||||||
|
fluid: false,
|
||||||
|
fill: true,
|
||||||
|
responsive: false,
|
||||||
|
html5: {
|
||||||
|
vhs: {
|
||||||
|
overrideNative: true,
|
||||||
|
},
|
||||||
|
nativeVideoTracks: false,
|
||||||
|
nativeAudioTracks: false,
|
||||||
|
nativeTextTracks: false,
|
||||||
|
},
|
||||||
|
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||||
|
controlBar: {
|
||||||
|
volumePanel: {
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
videoJsRef.current = player;
|
||||||
|
|
||||||
|
// Apply custom accent color if provided
|
||||||
|
if (accentColor) {
|
||||||
|
const styleId = 'videojs-custom-theme';
|
||||||
|
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
|
||||||
|
|
||||||
|
if (!styleElement) {
|
||||||
|
styleElement = document.createElement('style');
|
||||||
|
styleElement.id = styleId;
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
styleElement.textContent = `
|
||||||
|
.video-js .vjs-play-progress,
|
||||||
|
.video-js .vjs-volume-level {
|
||||||
|
background-color: ${accentColor} !important;
|
||||||
|
}
|
||||||
|
.video-js .vjs-control-bar,
|
||||||
|
.video-js .vjs-big-play-button {
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
.video-js .vjs-slider {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Video.js initialized successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Video.js after a short delay to ensure HLS.js is ready
|
||||||
|
const initTimeout = setTimeout(() => {
|
||||||
|
initializeVideoJs();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(initTimeout);
|
||||||
|
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
video.removeEventListener('durationchange', handleDurationChange);
|
||||||
|
video.removeEventListener('play', handlePlay);
|
||||||
|
video.removeEventListener('pause', handlePause);
|
||||||
|
video.removeEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
if (videoJsRef.current) {
|
||||||
|
videoJsRef.current.dispose();
|
||||||
|
videoJsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [m3u8Url, mp4Url, autoplay, accentColor]);
|
||||||
|
|
||||||
|
// Jump to specific time
|
||||||
|
const jumpToTime = useCallback((time: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video && isReady) {
|
||||||
|
const wasPlaying = !video.paused;
|
||||||
|
|
||||||
|
// Wait for video to be seekable if needed
|
||||||
|
if (video.seekable.length > 0) {
|
||||||
|
video.currentTime = time;
|
||||||
|
|
||||||
|
// Only attempt to play if video was already playing
|
||||||
|
if (wasPlaying) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
// Ignore AbortError from rapid play() calls
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
console.error('Jump failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Video not seekable yet, wait for it to be ready
|
||||||
|
console.log('⏳ Video not seekable yet, waiting...');
|
||||||
|
const onSeekable = () => {
|
||||||
|
video.currentTime = time;
|
||||||
|
if (wasPlaying) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
console.error('Jump failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
video.removeEventListener('canplay', onSeekable);
|
||||||
|
};
|
||||||
|
video.addEventListener('canplay', onSeekable, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
// Play control
|
||||||
|
const play = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video && isReady) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error('Play failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
// Pause control
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoRef,
|
||||||
|
isReady,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
error,
|
||||||
|
jumpToTime,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -8,8 +8,11 @@ interface AuthContextType {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
|
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
|
||||||
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>;
|
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null; data?: { user?: User; session?: Session } }>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
sendAuthOTP: (userId: string, email: string) => Promise<{ success: boolean; message: string }>;
|
||||||
|
verifyAuthOTP: (userId: string, otpCode: string) => Promise<{ success: boolean; message: string }>;
|
||||||
|
getUserByEmail: (email: string) => Promise<{ success: boolean; user_id?: string; email_confirmed?: boolean; message?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
@@ -21,31 +24,55 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
// First, get the initial session
|
||||||
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setSession(session);
|
||||||
|
setUser(session?.user ?? null);
|
||||||
|
|
||||||
|
if (session?.user) {
|
||||||
|
// Wait for admin role check before setting loading to false
|
||||||
|
checkAdminRole(session.user.id).then(() => {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No session, set loading to false immediately
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
}
|
||||||
|
}).catch((error: Error | unknown) => {
|
||||||
|
// Catch CORS errors or other initialization errors
|
||||||
|
console.error('Auth initialization error:', error);
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then listen for auth state changes
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
(event, session) => {
|
(event, session) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
setSession(session);
|
setSession(session);
|
||||||
setUser(session?.user ?? null);
|
setUser(session?.user ?? null);
|
||||||
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
setTimeout(() => {
|
// Wait for admin role check
|
||||||
checkAdminRole(session.user.id);
|
checkAdminRole(session.user.id).then(() => {
|
||||||
}, 0);
|
if (mounted) setLoading(false);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
|
// No session, set loading to false immediately
|
||||||
|
if (mounted) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
return () => {
|
||||||
setSession(session);
|
mounted = false;
|
||||||
setUser(session?.user ?? null);
|
subscription.unsubscribe();
|
||||||
if (session?.user) {
|
};
|
||||||
checkAdminRole(session.user.id);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkAdminRole = async (userId: string) => {
|
const checkAdminRole = async (userId: string) => {
|
||||||
@@ -57,6 +84,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
setIsAdmin(!!data);
|
setIsAdmin(!!data);
|
||||||
|
return !!data; // Return the result
|
||||||
};
|
};
|
||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
@@ -66,7 +94,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const signUp = async (email: string, password: string, name: string) => {
|
const signUp = async (email: string, password: string, name: string) => {
|
||||||
const redirectUrl = `${window.location.origin}/`;
|
const redirectUrl = `${window.location.origin}/`;
|
||||||
const { error } = await supabase.auth.signUp({
|
const { data, error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
options: {
|
options: {
|
||||||
@@ -74,15 +102,112 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
data: { name }
|
data: { name }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { error };
|
return { error, data };
|
||||||
};
|
};
|
||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendAuthOTP = async (userId: string, email: string) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke('send-auth-otp', {
|
||||||
|
body: { user_id: userId, email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('OTP request error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Failed to send OTP'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('OTP result:', data);
|
||||||
|
return {
|
||||||
|
success: data?.success || false,
|
||||||
|
message: data?.message || 'OTP sent successfully'
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error sending OTP:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Failed to send OTP'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyAuthOTP = async (userId: string, otpCode: string) => {
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/verify-auth-otp`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${session?.access_token || import.meta.env.VITE_SUPABASE_ANON_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_id: userId, otp_code: otpCode }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error verifying OTP:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Failed to verify OTP'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserByEmail = async (email: string) => {
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token || import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
console.log('Getting user by email:', email);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/get-user-by-email`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Get user response status:', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Get user request failed:', response.status, errorText);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `HTTP ${response.status}: ${errorText}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Get user result:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error getting user by email:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Failed to lookup user'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, session, loading, isAdmin, signIn, signUp, signOut }}>
|
<AuthContext.Provider value={{ user, session, loading, isAdmin, signIn, signUp, signOut, sendAuthOTP, verifyAuthOTP, getUserByEmail }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,14 @@ export function BrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('platform_settings')
|
.from('platform_settings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.single();
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching branding settings:', error);
|
||||||
|
// Keep default branding on error - still set title
|
||||||
|
document.title = defaultBranding.brand_name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
let features = defaultBranding.homepage_features;
|
let features = defaultBranding.homepage_features;
|
||||||
@@ -79,16 +86,29 @@ export function BrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
homepage_features: features,
|
homepage_features: features,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update CSS variable for accent color
|
||||||
|
if (data.brand_accent_color) {
|
||||||
|
document.documentElement.style.setProperty('--brand-accent', data.brand_accent_color);
|
||||||
|
}
|
||||||
|
|
||||||
// Update favicon if set
|
// Update favicon if set
|
||||||
if (data.brand_favicon_url) {
|
if (data.brand_favicon_url) {
|
||||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||||
if (link) link.href = data.brand_favicon_url;
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.href = data.brand_favicon_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update document title
|
// Update document title
|
||||||
if (data.brand_name) {
|
if (data.brand_name) {
|
||||||
document.title = data.brand_name;
|
document.title = data.brand_name;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No data found - use defaults and set title
|
||||||
|
document.title = defaultBranding.brand_name;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
41
src/hooks/useOwnerIdentity.tsx
Normal file
41
src/hooks/useOwnerIdentity.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { resolveAvatarUrl } from "@/lib/avatar";
|
||||||
|
|
||||||
|
export interface OwnerIdentity {
|
||||||
|
owner_name: string;
|
||||||
|
owner_avatar_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackOwner: OwnerIdentity = {
|
||||||
|
owner_name: "Dwindi",
|
||||||
|
owner_avatar_url: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useOwnerIdentity() {
|
||||||
|
const [owner, setOwner] = useState<OwnerIdentity>(fallbackOwner);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOwnerIdentity = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke("get-owner-identity");
|
||||||
|
if (error) throw error;
|
||||||
|
if (data) {
|
||||||
|
setOwner({
|
||||||
|
owner_name: data.owner_name || fallbackOwner.owner_name,
|
||||||
|
owner_avatar_url: resolveAvatarUrl(data.owner_avatar_url) || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load owner identity:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchOwnerIdentity();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { owner, loading };
|
||||||
|
}
|
||||||
128
src/hooks/useVideoProgress.ts
Normal file
128
src/hooks/useVideoProgress.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
interface UseVideoProgressOptions {
|
||||||
|
videoId: string;
|
||||||
|
videoType: 'lesson' | 'webinar';
|
||||||
|
duration?: number;
|
||||||
|
onSaveInterval?: number; // seconds, default 5
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoProgress {
|
||||||
|
last_position: number;
|
||||||
|
total_duration?: number;
|
||||||
|
completed: boolean;
|
||||||
|
last_watched_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVideoProgress = ({
|
||||||
|
videoId,
|
||||||
|
videoType,
|
||||||
|
duration,
|
||||||
|
onSaveInterval = 5,
|
||||||
|
}: UseVideoProgressOptions) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [progress, setProgress] = useState<VideoProgress | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const lastSavedPosition = useRef<number>(0);
|
||||||
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const userRef = useRef(user);
|
||||||
|
const videoIdRef = useRef(videoId);
|
||||||
|
const videoTypeRef = useRef(videoType);
|
||||||
|
const durationRef = useRef(duration);
|
||||||
|
|
||||||
|
// Update refs when props change
|
||||||
|
useEffect(() => {
|
||||||
|
userRef.current = user;
|
||||||
|
videoIdRef.current = videoId;
|
||||||
|
videoTypeRef.current = videoType;
|
||||||
|
durationRef.current = duration;
|
||||||
|
}, [user, videoId, videoType, duration]);
|
||||||
|
|
||||||
|
// Load existing progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !videoId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProgress = async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('video_progress')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('video_id', videoId)
|
||||||
|
.eq('video_type', videoType)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error loading video progress:', error);
|
||||||
|
} else if (data) {
|
||||||
|
setProgress(data);
|
||||||
|
lastSavedPosition.current = data.last_position;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProgress();
|
||||||
|
}, [user, videoId, videoType]);
|
||||||
|
|
||||||
|
// Save progress directly (not debounced for reliability)
|
||||||
|
const saveProgress = useCallback(async (position: number) => {
|
||||||
|
const currentUser = userRef.current;
|
||||||
|
const currentVideoId = videoIdRef.current;
|
||||||
|
const currentVideoType = videoTypeRef.current;
|
||||||
|
const currentDuration = durationRef.current;
|
||||||
|
|
||||||
|
if (!currentUser || !currentVideoId) return;
|
||||||
|
|
||||||
|
// Don't save if position hasn't changed significantly (less than 1 second)
|
||||||
|
if (Math.abs(position - lastSavedPosition.current) < 1) return;
|
||||||
|
|
||||||
|
const completed = currentDuration ? position / currentDuration >= 0.95 : false;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('video_progress')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
user_id: currentUser.id,
|
||||||
|
video_id: currentVideoId,
|
||||||
|
video_type: currentVideoType,
|
||||||
|
last_position: position,
|
||||||
|
total_duration: currentDuration,
|
||||||
|
completed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onConflict: 'user_id,video_id,video_type',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error saving video progress:', error);
|
||||||
|
} else {
|
||||||
|
lastSavedPosition.current = position;
|
||||||
|
}
|
||||||
|
}, []); // Empty deps - uses refs internally
|
||||||
|
|
||||||
|
// Save on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
// Save final position
|
||||||
|
if (lastSavedPosition.current > 0) {
|
||||||
|
saveProgress(lastSavedPosition.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [saveProgress]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
progress,
|
||||||
|
loading,
|
||||||
|
saveProgress, // Return the direct save function
|
||||||
|
hasProgress: progress !== null && progress.last_position > 5, // Only show if more than 5 seconds watched
|
||||||
|
};
|
||||||
|
};
|
||||||
234
src/index.css
234
src/index.css
@@ -159,3 +159,237 @@ All colors MUST be HSL.
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dynamic brand accent color for badges */
|
||||||
|
@layer utilities {
|
||||||
|
.bg-brand-accent {
|
||||||
|
background-color: var(--brand-accent, hsl(var(--accent)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced prose styling for better content formatting */
|
||||||
|
@layer base {
|
||||||
|
/* Headings */
|
||||||
|
.prose h1 {
|
||||||
|
@apply text-2xl font-bold mt-6 mb-4;
|
||||||
|
}
|
||||||
|
.prose h2 {
|
||||||
|
@apply text-xl font-bold mt-5 mb-3;
|
||||||
|
}
|
||||||
|
.prose h3 {
|
||||||
|
@apply text-lg font-bold mt-4 mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paragraphs */
|
||||||
|
.prose p {
|
||||||
|
@apply my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.prose ul {
|
||||||
|
@apply list-disc pl-6 space-y-1 my-4;
|
||||||
|
}
|
||||||
|
.prose ol {
|
||||||
|
@apply list-decimal pl-6 space-y-1 my-4;
|
||||||
|
}
|
||||||
|
.prose li {
|
||||||
|
@apply marker:text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquotes */
|
||||||
|
.prose blockquote {
|
||||||
|
@apply border-l-4 border-primary pl-4 italic text-muted-foreground my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.prose a {
|
||||||
|
@apply text-primary underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strong/Bold */
|
||||||
|
.prose strong, .prose b {
|
||||||
|
@apply font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emphasis/Italic */
|
||||||
|
.prose em, .prose i {
|
||||||
|
@apply italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
.prose code {
|
||||||
|
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
|
||||||
|
}
|
||||||
|
.prose pre {
|
||||||
|
@apply bg-muted p-4 rounded-lg overflow-x-auto my-4;
|
||||||
|
}
|
||||||
|
.prose pre code {
|
||||||
|
@apply bg-transparent p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Blocks with Syntax Highlighting */
|
||||||
|
.ProseMirror {
|
||||||
|
/* Code block wrapper */
|
||||||
|
.code-block-wrapper {
|
||||||
|
@apply relative my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pre element styling */
|
||||||
|
pre {
|
||||||
|
@apply bg-slate-900 text-slate-50 rounded-lg p-4 overflow-x-auto;
|
||||||
|
font-family: 'Space Mono', ui-monospace, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code styling */
|
||||||
|
code:not(pre code) {
|
||||||
|
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-red-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code inside pre blocks */
|
||||||
|
pre code {
|
||||||
|
@apply bg-transparent p-0 text-slate-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line numbers for code blocks */
|
||||||
|
.ProseMirror pre.line-numbers {
|
||||||
|
counter-reset: line;
|
||||||
|
padding-left: 3.5em;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror pre.line-numbers .line {
|
||||||
|
counter-increment: line;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror pre.line-numbers .line::before {
|
||||||
|
content: counter(line);
|
||||||
|
display: inline-block;
|
||||||
|
width: 2.5em;
|
||||||
|
margin-right: 1em;
|
||||||
|
text-align: right;
|
||||||
|
color: #64748b;
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (dark theme) */
|
||||||
|
.hljs {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #64748b;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-selector-tag,
|
||||||
|
.hljs-literal,
|
||||||
|
.hljs-type {
|
||||||
|
color: #c084fc;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-title,
|
||||||
|
.hljs-name {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-symbol {
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-variable,
|
||||||
|
.hljs-template-variable {
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-builtin-name {
|
||||||
|
color: #f472b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-function {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-class .hljs-title {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-tag {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-regexp {
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-link {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-meta,
|
||||||
|
.hljs-selector-attr,
|
||||||
|
.hljs-selector-pseudo {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-deletion {
|
||||||
|
background: #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-addition {
|
||||||
|
background: #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-emphasis {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bootcamp content display styling */
|
||||||
|
.prose pre {
|
||||||
|
@apply bg-slate-900 text-slate-50 rounded-lg p-4 overflow-x-auto my-4;
|
||||||
|
font-family: 'Space Mono', ui-monospace, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code {
|
||||||
|
@apply bg-transparent p-0 text-slate-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code:not(pre code) {
|
||||||
|
@apply bg-red-50 text-red-600 px-1.5 py-0.5 rounded text-sm font-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
@apply rounded-lg my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline chapter inline code styling */
|
||||||
|
.prose-sm code:not(pre code) {
|
||||||
|
@apply bg-slate-100 text-slate-800 px-1 py-0.5 rounded text-xs font-mono;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex > .flex-1 > code {
|
||||||
|
background-color: #dedede;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/lib/adiloHelper.ts
Normal file
43
src/lib/adiloHelper.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Extract M3U8 and MP4 URLs from Adilo embed code
|
||||||
|
*/
|
||||||
|
export const extractAdiloUrls = (embedCode: string): { m3u8Url?: string; mp4Url?: string } => {
|
||||||
|
const m3u8Match = embedCode.match(/(https:\/\/[^"'\s]+\.m3u8[^"'\s]*)/);
|
||||||
|
const mp4Match = embedCode.match(/(https:\/\/[^"'\s]+\.mp4[^"'\s]*)/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
m3u8Url: m3u8Match?.[1],
|
||||||
|
mp4Url: mp4Match?.[1],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Adilo embed code from URLs
|
||||||
|
*/
|
||||||
|
export const generateAdiloEmbed = (m3u8Url: string, videoId: string): string => {
|
||||||
|
return `<iframe src="https://adilo.bigcommand.com/embed/${videoId}" allowfullscreen></iframe>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is an Adilo URL
|
||||||
|
*/
|
||||||
|
export const isAdiloUrl = (url: string): boolean => {
|
||||||
|
return url.includes('adilo.bigcommand.com') || url.includes('.m3u8');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is a YouTube URL
|
||||||
|
*/
|
||||||
|
export const isYouTubeUrl = (url: string): boolean => {
|
||||||
|
return url.includes('youtube.com') || url.includes('youtu.be');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video host type from URL
|
||||||
|
*/
|
||||||
|
export const getVideoHostType = (url?: string | null): 'youtube' | 'adilo' | 'unknown' => {
|
||||||
|
if (!url) return 'unknown';
|
||||||
|
if (isYouTubeUrl(url)) return 'youtube';
|
||||||
|
if (isAdiloUrl(url)) return 'adilo';
|
||||||
|
return 'unknown';
|
||||||
|
};
|
||||||
11
src/lib/avatar.ts
Normal file
11
src/lib/avatar.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
|
export function resolveAvatarUrl(value?: string | null): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) return value;
|
||||||
|
|
||||||
|
const normalized = value.startsWith("/") ? value.slice(1) : value;
|
||||||
|
const { data } = supabase.storage.from("content").getPublicUrl(normalized);
|
||||||
|
return data.publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
428
src/lib/email-templates/master-template.ts
Normal file
428
src/lib/email-templates/master-template.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
interface EmailTemplateData {
|
||||||
|
subject: string;
|
||||||
|
content: string;
|
||||||
|
brandName?: string;
|
||||||
|
brandLogo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailTemplateRenderer {
|
||||||
|
private static readonly MASTER_TEMPLATE = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{subject}}</title>
|
||||||
|
<style>
|
||||||
|
/* =========================================
|
||||||
|
1. CLIENT RESETS (The Boring Stuff)
|
||||||
|
========================================= */
|
||||||
|
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||||
|
table { border-collapse: collapse !important; }
|
||||||
|
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; background-color: #FFFFFF; }
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
2. MASTER TYPOGRAPHY & VARS
|
||||||
|
========================================= */
|
||||||
|
:root {
|
||||||
|
--color-black: #000000;
|
||||||
|
--color-white: #FFFFFF;
|
||||||
|
--color-gray: #F4F4F5;
|
||||||
|
--color-success: #00A651;
|
||||||
|
--color-danger: #E11D48;
|
||||||
|
--border-thick: 2px solid #000000;
|
||||||
|
--border-thin: 1px solid #000000;
|
||||||
|
--shadow-hard: 4px 4px 0px 0px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
color: #000000;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
3. DYNAMIC CONTENT POLISH
|
||||||
|
========================================= */
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
.email-content h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.email-content h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 25px 0 15px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.email-content h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 20px 0 10px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.email-content p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standard Links */
|
||||||
|
.email-content a {
|
||||||
|
color: #000000;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 700;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.email-content ul, .email-content ol {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.email-content li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TABLES (Brutalist Style) */
|
||||||
|
.email-content table {
|
||||||
|
width: 100%;
|
||||||
|
border: 2px solid #000;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.email-content th {
|
||||||
|
background-color: #000;
|
||||||
|
color: #FFF;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
.email-content td {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
font-size: 15px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
/* Zebra Striping */
|
||||||
|
.email-content tr:nth-child(even) td {
|
||||||
|
background-color: #F8F8F8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BUTTONS (Class: .btn) */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #000;
|
||||||
|
color: #FFF !important;
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 2px solid #000;
|
||||||
|
box-shadow: 4px 4px 0px 0px #000000; /* Hard Shadow */
|
||||||
|
margin: 10px 0;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: 2px 2px 0px 0px #000000;
|
||||||
|
}
|
||||||
|
.btn-full { width: 100%; text-align: center; box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* CODE BLOCKS */
|
||||||
|
.email-content pre {
|
||||||
|
background-color: #F4F4F5;
|
||||||
|
border: 2px solid #000;
|
||||||
|
padding: 15px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.email-content code {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #E11D48;
|
||||||
|
background-color: #F4F4F5;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
/* Special OTP Style */
|
||||||
|
.otp-box {
|
||||||
|
background-color: #F4F4F5;
|
||||||
|
border: 2px dashed #000;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BLOCKQUOTES / ALERTS */
|
||||||
|
.email-content blockquote {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-left: 6px solid #000;
|
||||||
|
background-color: #F9F9F9;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
/* Contextual Alerts */
|
||||||
|
.alert-success { background-color: #E6F4EA; border-left-color: #00A651; color: #005A2B; }
|
||||||
|
.alert-danger { background-color: #FFE4E6; border-left-color: #E11D48; color: #881337; }
|
||||||
|
.alert-info { background-color: #E3F2FD; border-left-color: #1976D2; color: #0D47A1; }
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
4. RESPONSIVE
|
||||||
|
========================================= */
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.email-container { width: 100% !important; border-left: 0 !important; border-right: 0 !important; }
|
||||||
|
.content-padding { padding: 30px 20px !important; }
|
||||||
|
.stack-mobile { display: block !important; width: 100% !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #FFFFFF;">
|
||||||
|
|
||||||
|
<!-- 100% WRAPPER -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #FFFFFF;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 0;">
|
||||||
|
|
||||||
|
<!-- MAIN CONTAINER (600px) -->
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="600" class="email-container" style="background-color: #FFFFFF; border: 2px solid #000000; width: 600px; min-width: 320px;">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="background-color: #000000; padding: 25px 40px; border-bottom: 2px solid #000000;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<!-- LOGO (White text) -->
|
||||||
|
<div style="font-family: 'Helvetica Neue', sans-serif; font-size: 24px; font-weight: 900; color: #FFFFFF; letter-spacing: -1px; text-transform: uppercase;">
|
||||||
|
{{brandName}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td align="right">
|
||||||
|
<!-- Optional: Small Date or Tag -->
|
||||||
|
<div style="font-family: monospace; font-size: 12px; color: #888;">
|
||||||
|
NOTIF #{{timestamp}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- BODY CONTENT -->
|
||||||
|
<tr>
|
||||||
|
<td class="content-padding" style="padding: 40px 40px 60px 40px;">
|
||||||
|
|
||||||
|
<!-- DYNAMIC CONTENT -->
|
||||||
|
<div class="email-content">
|
||||||
|
{{content}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 30px 40px; border-top: 2px solid #000000; background-color: #F4F4F5; color: #000;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size: 12px; line-height: 18px; font-family: monospace; color: #555;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-weight: bold;">{{brandName}}</p>
|
||||||
|
<p style="margin: 0 0 15px 0;">Email ini dikirim otomatis. Jangan membalas email ini.</p>
|
||||||
|
|
||||||
|
<p style="margin: 0;">
|
||||||
|
<a href="#" style="color: #000; text-decoration: underline;">Ubah Preferensi</a> |
|
||||||
|
<a href="#" style="color: #000; text-decoration: underline;">Unsubscribe</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<!-- END MAIN CONTAINER -->
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
static render(data: EmailTemplateData): string {
|
||||||
|
let html = this.MASTER_TEMPLATE;
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
html = html.replace(/{{subject}}/g, data.subject || 'Notification');
|
||||||
|
html = html.replace(/{{brandName}}/g, data.brandName || 'ACCESS HUB');
|
||||||
|
html = html.replace(/{{brandLogo}}/g, data.brandLogo || '');
|
||||||
|
html = html.replace(/{{timestamp}}/g, Date.now().toString().slice(-6));
|
||||||
|
html = html.replace(/{{content}}/g, data.content);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable Email Components
|
||||||
|
export const EmailComponents = {
|
||||||
|
// Buttons
|
||||||
|
button: (text: string, url: string, fullwidth = false) =>
|
||||||
|
`<p style="margin-top: 20px; text-align: ${fullwidth ? 'center' : 'left'};">
|
||||||
|
<a href="${url}" class="${fullwidth ? 'btn-full' : 'btn'}">${text}</a>
|
||||||
|
</p>`,
|
||||||
|
|
||||||
|
// Alert boxes
|
||||||
|
alert: (type: 'success' | 'danger' | 'info', content: string) =>
|
||||||
|
`<blockquote class="alert-${type}">
|
||||||
|
${content}
|
||||||
|
</blockquote>`,
|
||||||
|
|
||||||
|
// Code blocks
|
||||||
|
codeBlock: (code: string, language = '') =>
|
||||||
|
`<pre><code>${code}</code></pre>`,
|
||||||
|
|
||||||
|
// OTP boxes
|
||||||
|
otpBox: (code: string) =>
|
||||||
|
`<div class="otp-box">${code}</div>`,
|
||||||
|
|
||||||
|
// Info card
|
||||||
|
infoCard: (title: string, items: Array<{label: string; value: string}>) => {
|
||||||
|
const rows = items.map(item =>
|
||||||
|
`<tr>
|
||||||
|
<td>${item.label}</td>
|
||||||
|
<td>${item.value}</td>
|
||||||
|
</tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<h2>${title}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Parameter</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
divider: () => '<hr style="border: 1px solid #000; margin: 30px 0;">',
|
||||||
|
|
||||||
|
// Spacing
|
||||||
|
spacing: (size: 'small' | 'medium' | 'large' = 'medium') => {
|
||||||
|
const sizes = { small: '15px', medium: '25px', large: '40px' };
|
||||||
|
return `<div style="height: ${sizes[size]};"></div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shortcode processor
|
||||||
|
export class ShortcodeProcessor {
|
||||||
|
private static readonly DEFAULT_DATA = {
|
||||||
|
// User information
|
||||||
|
nama: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
|
||||||
|
// Order information
|
||||||
|
order_id: 'ORD-123456',
|
||||||
|
tanggal_pesanan: '22 Desember 2025',
|
||||||
|
total: 'Rp 1.500.000',
|
||||||
|
metode_pembayaran: 'Transfer Bank',
|
||||||
|
status_pesanan: 'Diproses',
|
||||||
|
invoice_url: 'https://with.dwindi.com/orders/ORD-123456',
|
||||||
|
|
||||||
|
// Product information
|
||||||
|
produk: 'Digital Marketing Masterclass',
|
||||||
|
kategori_produk: 'Digital Marketing',
|
||||||
|
harga_produk: 'Rp 1.500.000',
|
||||||
|
deskripsi_produk: 'Kelas lengkap digital marketing dari pemula hingga mahir',
|
||||||
|
|
||||||
|
// Access information
|
||||||
|
link_akses: 'https://with.dwindi.com/access',
|
||||||
|
username_akses: 'john.doe',
|
||||||
|
password_akses: 'Temp123!',
|
||||||
|
kadaluarsa_akses: '22 Desember 2026',
|
||||||
|
|
||||||
|
// Consulting information
|
||||||
|
tanggal_konsultasi: '22 Desember 2025',
|
||||||
|
jam_konsultasi: '14:00',
|
||||||
|
durasi_konsultasi: '60 menit',
|
||||||
|
link_meet: 'https://meet.google.com/example',
|
||||||
|
jenis_konsultasi: 'Digital Marketing Strategy',
|
||||||
|
topik_konsultasi: 'Social Media Marketing for Beginners',
|
||||||
|
|
||||||
|
// Event information
|
||||||
|
judul_event: 'Workshop Digital Marketing',
|
||||||
|
tanggal_event: '25 Desember 2025',
|
||||||
|
jam_event: '19:00',
|
||||||
|
link_event: 'https://with.dwindi.com/events',
|
||||||
|
lokasi_event: 'Zoom Online',
|
||||||
|
kapasitas_event: '100 peserta',
|
||||||
|
|
||||||
|
// Bootcamp/Course information
|
||||||
|
judul_bootcamp: 'Digital Marketing Bootcamp',
|
||||||
|
progres_bootcamp: '75%',
|
||||||
|
modul_selesai: '15 dari 20 modul',
|
||||||
|
modul_selanjutnya: 'Final Assessment',
|
||||||
|
link_progress: 'https://with.dwindi.com/bootcamp/progress',
|
||||||
|
|
||||||
|
// Company information
|
||||||
|
nama_perusahaan: 'ACCESS HUB',
|
||||||
|
website_perusahaan: 'https://with.dwindi.com',
|
||||||
|
email_support: 'support@with.dwindi.com',
|
||||||
|
telepon_support: '+62 812-3456-7890',
|
||||||
|
|
||||||
|
// Payment information
|
||||||
|
bank_tujuan: 'BCA',
|
||||||
|
nomor_rekening: '123-456-7890',
|
||||||
|
atas_nama: 'PT Access Hub Indonesia',
|
||||||
|
jumlah_pembayaran: 'Rp 1.500.000',
|
||||||
|
batas_pembayaran: '22 Desember 2025 23:59',
|
||||||
|
payment_link: 'https://with.dwindi.com/checkout',
|
||||||
|
thank_you_page: 'https://with.dwindi.com/orders/{order_id}'
|
||||||
|
};
|
||||||
|
|
||||||
|
static process(content: string, customData: Record<string, string> = {}): string {
|
||||||
|
const data = { ...this.DEFAULT_DATA, ...customData };
|
||||||
|
|
||||||
|
let processed = content;
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||||
|
processed = processed.replace(regex, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDummyData(): Record<string, string> {
|
||||||
|
return this.DEFAULT_DATA;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/lib/exportCSV.ts
Normal file
62
src/lib/exportCSV.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Export utility functions for CSV export
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert data array to CSV format
|
||||||
|
*/
|
||||||
|
export const convertToCSV = (data: Record<string, any>[], headers: string[]): string => {
|
||||||
|
// Add headers
|
||||||
|
const csvRows = [headers.join(',')];
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
data.forEach((row) => {
|
||||||
|
const values = headers.map((header) => {
|
||||||
|
const value = row[header];
|
||||||
|
// Escape values that contain commas, quotes, or newlines
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const stringValue = String(value);
|
||||||
|
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
||||||
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return stringValue;
|
||||||
|
});
|
||||||
|
csvRows.push(values.join(','));
|
||||||
|
});
|
||||||
|
|
||||||
|
return csvRows.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger CSV download in browser
|
||||||
|
*/
|
||||||
|
export const downloadCSV = (csv: string, filename: string) => {
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for export (YYYY-MM-DD HH:mm:ss)
|
||||||
|
*/
|
||||||
|
export const formatExportDate = (date: string | Date): string => {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toISOString().replace('T', ' ').substring(0, 19);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format IDR for export (without "Rp" prefix for easier Excel processing)
|
||||||
|
*/
|
||||||
|
export const formatExportIDR = (amount: number): string => {
|
||||||
|
return (amount / 100).toLocaleString('id-ID');
|
||||||
|
};
|
||||||
124
src/lib/statusHelpers.ts
Normal file
124
src/lib/statusHelpers.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Centralized status management for consistent labels, colors, and badges
|
||||||
|
* Single source of truth for all status-related UI
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PaymentStatus = 'paid' | 'pending' | 'failed' | 'cancelled' | 'refunded' | 'partially_refunded';
|
||||||
|
export type ConsultingSlotStatus = 'pending_payment' | 'confirmed' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Indonesian label for payment status
|
||||||
|
*/
|
||||||
|
export const getPaymentStatusLabel = (status: PaymentStatus | string | null): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return 'Lunas';
|
||||||
|
case 'pending':
|
||||||
|
return 'Pending';
|
||||||
|
case 'failed':
|
||||||
|
return 'Gagal';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Dibatalkan';
|
||||||
|
case 'refunded':
|
||||||
|
return 'Refund';
|
||||||
|
case 'partially_refunded':
|
||||||
|
return 'Refund Sebagian';
|
||||||
|
default:
|
||||||
|
return status || 'Pending';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSS class for payment status badge
|
||||||
|
*/
|
||||||
|
export const getPaymentStatusColor = (status: PaymentStatus | string | null): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return 'bg-brand-accent text-white';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-amber-500 text-white';
|
||||||
|
case 'failed':
|
||||||
|
return 'bg-red-500 text-white';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-destructive text-white';
|
||||||
|
case 'refunded':
|
||||||
|
return 'bg-purple-500 text-white';
|
||||||
|
case 'partially_refunded':
|
||||||
|
return 'bg-purple-500/80 text-white';
|
||||||
|
default:
|
||||||
|
return 'bg-secondary text-primary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get label for consulting slot status
|
||||||
|
*/
|
||||||
|
export const getConsultingSlotStatusLabel = (status: ConsultingSlotStatus | string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending_payment':
|
||||||
|
return 'Pending';
|
||||||
|
case 'confirmed':
|
||||||
|
return 'Terkonfirmasi';
|
||||||
|
case 'completed':
|
||||||
|
return 'Selesai';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Dibatalkan';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSS class for consulting slot status badge
|
||||||
|
*/
|
||||||
|
export const getConsultingSlotStatusColor = (status: ConsultingSlotStatus | string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending_payment':
|
||||||
|
return 'bg-amber-500 text-white';
|
||||||
|
case 'confirmed':
|
||||||
|
return 'bg-green-500 text-white';
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-blue-500 text-white';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-destructive text-white';
|
||||||
|
default:
|
||||||
|
return 'bg-secondary text-primary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get label for product type
|
||||||
|
*/
|
||||||
|
export const getProductTypeLabel = (type: string): string => {
|
||||||
|
switch (type) {
|
||||||
|
case 'consulting':
|
||||||
|
return 'Konsultasi';
|
||||||
|
case 'webinar':
|
||||||
|
return 'Webinar';
|
||||||
|
case 'bootcamp':
|
||||||
|
return 'Bootcamp';
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if order can be refunded
|
||||||
|
*/
|
||||||
|
export const canRefundOrder = (paymentStatus: PaymentStatus | string | null, refundedAt: string | null = null): boolean => {
|
||||||
|
return paymentStatus === 'paid' && !refundedAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if order can be cancelled
|
||||||
|
*/
|
||||||
|
export const canCancelOrder = (paymentStatus: PaymentStatus | string | null, refundedAt: string | null = null): boolean => {
|
||||||
|
return !refundedAt && paymentStatus !== 'cancelled' && paymentStatus !== 'refunded' && paymentStatus !== 'partially_refunded';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if order can be marked as paid
|
||||||
|
*/
|
||||||
|
export const canMarkAsPaid = (paymentStatus: PaymentStatus | string | null, refundedAt: string | null = null): boolean => {
|
||||||
|
return !refundedAt && paymentStatus !== 'paid' && paymentStatus !== 'refunded' && paymentStatus !== 'partially_refunded';
|
||||||
|
};
|
||||||
17
src/lib/storageUpload.ts
Normal file
17
src/lib/storageUpload.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
|
export async function uploadToContentStorage(
|
||||||
|
file: File,
|
||||||
|
path: string,
|
||||||
|
options?: { upsert?: boolean }
|
||||||
|
): Promise<string> {
|
||||||
|
const { error } = await supabase.storage.from("content").upload(path, file, {
|
||||||
|
cacheControl: "3600",
|
||||||
|
upsert: options?.upsert ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const { data } = supabase.storage.from("content").getPublicUrl(path);
|
||||||
|
return data.publicUrl;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -7,24 +7,48 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { ArrowLeft, Mail } from 'lucide-react';
|
||||||
|
|
||||||
const emailSchema = z.string().email('Invalid email address');
|
const emailSchema = z.string().email('Invalid email address');
|
||||||
const passwordSchema = z.string().min(6, 'Password must be at least 6 characters');
|
const passwordSchema = z.string().min(6, 'Password must be at least 6 characters');
|
||||||
|
|
||||||
export default function Auth() {
|
export default function Auth() {
|
||||||
const [isLogin, setIsLogin] = useState(true);
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [showOTP, setShowOTP] = useState(false);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [otpCode, setOtpCode] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { signIn, signUp, user } = useAuth();
|
const [pendingUserId, setPendingUserId] = useState<string | null>(null);
|
||||||
|
const [resendCountdown, setResendCountdown] = useState(0);
|
||||||
|
const [isResendOTP, setIsResendOTP] = useState(false); // Track if this is resend OTP for existing user
|
||||||
|
const { signIn, signUp, user, isAdmin, sendAuthOTP, verifyAuthOTP, getUserByEmail } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
navigate('/dashboard');
|
// Check if there's a saved redirect path
|
||||||
|
const savedRedirect = sessionStorage.getItem('redirectAfterLogin');
|
||||||
|
if (savedRedirect) {
|
||||||
|
sessionStorage.removeItem('redirectAfterLogin');
|
||||||
|
navigate(savedRedirect);
|
||||||
|
} else {
|
||||||
|
// Default redirect based on user role (use isAdmin flag from context)
|
||||||
|
const defaultRedirect = isAdmin ? '/admin' : '/dashboard';
|
||||||
|
navigate(defaultRedirect);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, isAdmin, navigate]);
|
||||||
|
|
||||||
|
// Countdown timer for resend OTP
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCountdown > 0) {
|
||||||
|
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [resendCountdown]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -44,9 +68,51 @@ export default function Auth() {
|
|||||||
if (isLogin) {
|
if (isLogin) {
|
||||||
const { error } = await signIn(email, password);
|
const { error } = await signIn(email, password);
|
||||||
if (error) {
|
if (error) {
|
||||||
|
console.log('Login error:', error.message);
|
||||||
|
|
||||||
|
// Check if error is due to unconfirmed email
|
||||||
|
// Supabase returns various error messages for unconfirmed email
|
||||||
|
const isUnconfirmedEmail =
|
||||||
|
error.message.includes('Email not confirmed') ||
|
||||||
|
error.message.includes('Email not verified') ||
|
||||||
|
error.message.includes('Email not confirmed') ||
|
||||||
|
error.message.toLowerCase().includes('email') && error.message.toLowerCase().includes('not confirmed') ||
|
||||||
|
error.message.toLowerCase().includes('unconfirmed');
|
||||||
|
|
||||||
|
console.log('Is unconfirmed email?', isUnconfirmedEmail);
|
||||||
|
|
||||||
|
if (isUnconfirmedEmail) {
|
||||||
|
// Get user by email to fetch user_id
|
||||||
|
console.log('Fetching user by email for OTP resend...');
|
||||||
|
const userResult = await getUserByEmail(email);
|
||||||
|
|
||||||
|
console.log('User lookup result:', userResult);
|
||||||
|
|
||||||
|
if (userResult.success && userResult.user_id) {
|
||||||
|
setPendingUserId(userResult.user_id);
|
||||||
|
setIsResendOTP(true);
|
||||||
|
setShowOTP(true);
|
||||||
|
setResendCountdown(0); // Allow immediate resend on first attempt
|
||||||
|
toast({
|
||||||
|
title: 'Email Belum Dikonfirmasi',
|
||||||
|
description: 'Silakan verifikasi email Anda. Kami akan mengirimkan kode OTP.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'User tidak ditemukan. Silakan daftar terlebih dahulu.',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||||
|
setLoading(false);
|
||||||
} else {
|
} else {
|
||||||
navigate('/dashboard');
|
// Login successful - the useEffect watching 'user' will handle the redirect
|
||||||
|
// This ensures we have the full user metadata including role
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -54,16 +120,98 @@ export default function Auth() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { error } = await signUp(email, password, name);
|
|
||||||
|
const { error, data } = await signUp(email, password, name);
|
||||||
|
|
||||||
|
console.log('SignUp result:', { error, data, hasUser: !!data?.user, hasSession: !!data?.session });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.message.includes('already registered')) {
|
if (error.message.includes('already registered')) {
|
||||||
toast({ title: 'Error', description: 'This email is already registered. Please login instead.', variant: 'destructive' });
|
toast({ title: 'Error', description: 'This email is already registered. Please login instead.', variant: 'destructive' });
|
||||||
} else {
|
} else {
|
||||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||||
}
|
}
|
||||||
} else {
|
setLoading(false);
|
||||||
toast({ title: 'Success', description: 'Check your email to confirm your account' });
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data?.user) {
|
||||||
|
toast({ title: 'Error', description: 'Failed to create user account. Please try again.', variant: 'destructive' });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User created, now send OTP
|
||||||
|
const userId = data.user.id;
|
||||||
|
console.log('User created successfully:', { userId, email, session: data.session });
|
||||||
|
|
||||||
|
const result = await sendAuthOTP(userId, email);
|
||||||
|
|
||||||
|
console.log('OTP send result:', result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setPendingUserId(userId);
|
||||||
|
setShowOTP(true);
|
||||||
|
setResendCountdown(60); // 60 seconds cooldown
|
||||||
|
toast({
|
||||||
|
title: 'OTP Terkirim',
|
||||||
|
description: 'Kode verifikasi telah dikirim ke email Anda. Silakan cek inbox.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Error', description: result.message, variant: 'destructive' });
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOTPSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!pendingUserId) {
|
||||||
|
toast({ title: 'Error', description: 'Session expired. Please try again.', variant: 'destructive' });
|
||||||
|
setShowOTP(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otpCode.length !== 6) {
|
||||||
|
toast({ title: 'Error', description: 'OTP harus 6 digit', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await verifyAuthOTP(pendingUserId, otpCode);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: 'Verifikasi Berhasil',
|
||||||
|
description: 'Email Anda telah terverifikasi. Silakan login.',
|
||||||
|
});
|
||||||
|
setShowOTP(false);
|
||||||
|
setIsLogin(true);
|
||||||
|
// Reset form
|
||||||
|
setName('');
|
||||||
|
setOtpCode('');
|
||||||
|
setPendingUserId(null);
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Error', description: result.message, variant: 'destructive' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendOTP = async () => {
|
||||||
|
if (resendCountdown > 0 || !pendingUserId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await sendAuthOTP(pendingUserId, email);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setResendCountdown(60);
|
||||||
|
toast({ title: 'OTP Terkirim Ulang', description: 'Kode verifikasi baru telah dikirim ke email Anda.' });
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Error', description: result.message, variant: 'destructive' });
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -71,66 +219,151 @@ export default function Auth() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<Card className="w-full max-w-md border-2 border-border shadow-md">
|
<div className="w-full max-w-md space-y-4">
|
||||||
<CardHeader>
|
{/* Back to Home Button */}
|
||||||
<CardTitle className="text-2xl">{isLogin ? 'Login' : 'Sign Up'}</CardTitle>
|
<Link to="/">
|
||||||
<CardDescription>
|
<Button variant="ghost" className="gap-2">
|
||||||
{isLogin ? 'Enter your credentials to access your account' : 'Create a new account to get started'}
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</CardDescription>
|
Kembali ke Beranda
|
||||||
</CardHeader>
|
</Button>
|
||||||
<CardContent>
|
</Link>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{!isLogin && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Your name"
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="your@email.com"
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" className="w-full shadow-sm" disabled={loading}>
|
|
||||||
{loading ? 'Loading...' : isLogin ? 'Login' : 'Sign Up'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
{!showOTP ? (
|
||||||
<button
|
<Card className="border-2 border-border shadow-md">
|
||||||
type="button"
|
<CardHeader>
|
||||||
onClick={() => setIsLogin(!isLogin)}
|
<CardTitle className="text-2xl">{isLogin ? 'Login' : 'Daftar'}</CardTitle>
|
||||||
className="text-sm text-muted-foreground hover:underline"
|
<CardDescription>
|
||||||
>
|
{isLogin ? 'Masuk untuk mengakses akun Anda' : 'Buat akun baru untuk memulai'}
|
||||||
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Login'}
|
</CardDescription>
|
||||||
</button>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
</CardContent>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
</Card>
|
{!isLogin && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Nama</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Nama lengkap"
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="email@anda.com"
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full shadow-sm" disabled={loading}>
|
||||||
|
{loading ? 'Memuat...' : isLogin ? 'Masuk' : 'Daftar'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsLogin(!isLogin)}
|
||||||
|
className="text-sm text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{isLogin ? 'Belum punya akun? Daftar' : 'Sudah punya akun? Masuk'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="border-2 border-border shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl flex items-center gap-2">
|
||||||
|
<Mail className="w-6 h-6" />
|
||||||
|
Verifikasi Email
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Masukkan kode 6 digit yang telah dikirim ke <strong>{email}</strong>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleOTPSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="otp">Kode OTP</Label>
|
||||||
|
<Input
|
||||||
|
id="otp"
|
||||||
|
type="text"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Only allow numbers, max 6 digits
|
||||||
|
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||||
|
setOtpCode(value);
|
||||||
|
}}
|
||||||
|
placeholder="123456"
|
||||||
|
className="border-2 text-center text-2xl tracking-widest font-mono"
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Masukkan 6 digit kode dari email Anda
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full shadow-sm" disabled={loading || otpCode.length !== 6}>
|
||||||
|
{loading ? 'Memverifikasi...' : 'Verifikasi'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tidak menerima kode?
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
onClick={handleResendOTP}
|
||||||
|
disabled={resendCountdown > 0 || loading}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{resendCountdown > 0
|
||||||
|
? `Kirim ulang dalam ${resendCountdown} detik`
|
||||||
|
: 'Kirim ulang kode'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowOTP(false);
|
||||||
|
setOtpCode('');
|
||||||
|
setPendingUserId(null);
|
||||||
|
setResendCountdown(0);
|
||||||
|
}}
|
||||||
|
className="w-full text-sm"
|
||||||
|
>
|
||||||
|
Kembali ke form pendaftaran
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { formatDuration } from '@/lib/format';
|
import { formatDuration } from '@/lib/format';
|
||||||
@@ -11,6 +12,14 @@ import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, Ch
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||||
|
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
|
||||||
|
import { TimelineChapters } from '@/components/TimelineChapters';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,9 +39,15 @@ interface Lesson {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
|
youtube_url: string | null;
|
||||||
|
embed_code: string | null;
|
||||||
|
m3u8_url?: string | null;
|
||||||
|
mp4_url?: string | null;
|
||||||
|
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
position: number;
|
position: number;
|
||||||
release_at: string | null;
|
release_at: string | null;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Progress {
|
interface Progress {
|
||||||
@@ -40,8 +55,187 @@ interface Progress {
|
|||||||
completed_at: string;
|
completed_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get YouTube embed URL
|
||||||
|
const getYouTubeEmbedUrl = (url: string): string => {
|
||||||
|
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||||
|
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move VideoPlayer component outside main component to prevent re-creation on every render
|
||||||
|
const VideoPlayer = ({
|
||||||
|
lesson,
|
||||||
|
playerRef,
|
||||||
|
currentTime,
|
||||||
|
accentColor,
|
||||||
|
setCurrentTime
|
||||||
|
}: {
|
||||||
|
lesson: Lesson;
|
||||||
|
playerRef: React.RefObject<VideoPlayerRef>;
|
||||||
|
currentTime: number;
|
||||||
|
accentColor: string;
|
||||||
|
setCurrentTime: (time: number) => void;
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||||
|
|
||||||
|
// Get video based on lesson's video_host (prioritize Adilo)
|
||||||
|
const getVideoSource = () => {
|
||||||
|
// If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority.
|
||||||
|
const lessonVideoHost = lesson.video_host || (
|
||||||
|
lesson.m3u8_url ? 'adilo' :
|
||||||
|
lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' :
|
||||||
|
lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' :
|
||||||
|
'unknown'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lessonVideoHost === 'adilo') {
|
||||||
|
// Adilo M3U8 streaming
|
||||||
|
if (lesson.m3u8_url && lesson.m3u8_url.trim()) {
|
||||||
|
return {
|
||||||
|
type: 'adilo',
|
||||||
|
m3u8Url: lesson.m3u8_url,
|
||||||
|
mp4Url: lesson.mp4_url || undefined,
|
||||||
|
videoHost: 'adilo'
|
||||||
|
};
|
||||||
|
} else if (lesson.mp4_url && lesson.mp4_url.trim()) {
|
||||||
|
// Fallback to MP4 only
|
||||||
|
return {
|
||||||
|
type: 'adilo',
|
||||||
|
mp4Url: lesson.mp4_url,
|
||||||
|
videoHost: 'adilo'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube or fallback
|
||||||
|
if (lessonVideoHost === 'youtube') {
|
||||||
|
if (lesson.youtube_url && lesson.youtube_url.trim()) {
|
||||||
|
return {
|
||||||
|
type: 'youtube',
|
||||||
|
url: lesson.youtube_url,
|
||||||
|
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
|
||||||
|
};
|
||||||
|
} else if (lesson.video_url && lesson.video_url.trim()) {
|
||||||
|
// Fallback to old video_url for backward compatibility
|
||||||
|
return {
|
||||||
|
type: 'youtube',
|
||||||
|
url: lesson.video_url,
|
||||||
|
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: try embed code
|
||||||
|
return lesson.embed_code && lesson.embed_code.trim() ? {
|
||||||
|
type: 'embed',
|
||||||
|
html: lesson.embed_code
|
||||||
|
} : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize video source to prevent unnecessary re-renders
|
||||||
|
const video = useMemo(getVideoSource, [lesson.id, lesson.video_host, lesson.m3u8_url, lesson.mp4_url, lesson.youtube_url, lesson.video_url, lesson.embed_code]);
|
||||||
|
|
||||||
|
// Determine video type - must be computed before conditional returns
|
||||||
|
const isYouTube = video?.type === 'youtube';
|
||||||
|
const isAdilo = video?.type === 'adilo';
|
||||||
|
const isEmbed = video?.type === 'embed';
|
||||||
|
|
||||||
|
// Memoize URL values BEFORE any conditional returns (Rules of Hooks)
|
||||||
|
const videoUrl = useMemo(() => (isYouTube ? video?.url : undefined), [isYouTube, video?.url]);
|
||||||
|
const m3u8Url = useMemo(() => (isAdilo ? video?.m3u8Url : undefined), [isAdilo, video?.m3u8Url]);
|
||||||
|
const mp4Url = useMemo(() => (isAdilo ? video?.mp4Url : undefined), [isAdilo, video?.mp4Url]);
|
||||||
|
|
||||||
|
// Show warning if no video available
|
||||||
|
if (!video) {
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-destructive font-medium">Konten tidak tersedia</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render based on video type
|
||||||
|
if (isEmbed) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: video.html }} />
|
||||||
|
</div>
|
||||||
|
{hasChapters && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<TimelineChapters
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
currentTime={currentTime}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Video Player - Full Width */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<VideoPlayerWithChapters
|
||||||
|
ref={playerRef}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
m3u8Url={m3u8Url}
|
||||||
|
mp4Url={mp4Url}
|
||||||
|
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
accentColor={accentColor}
|
||||||
|
onTimeUpdate={setCurrentTime}
|
||||||
|
videoId={lesson.id}
|
||||||
|
videoType="lesson"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Chapters - Below video like WebinarRecording */}
|
||||||
|
{hasChapters && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<TimelineChapters
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
onChapterClick={(time) => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
playerRef.current.jumpToTime(time);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
currentTime={currentTime}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Bootcamp() {
|
export default function Bootcamp() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug, lessonId } = useParams<{ slug: string; lessonId?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
@@ -52,8 +246,11 @@ export default function Bootcamp() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [hasReviewed, setHasReviewed] = useState(false);
|
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [accentColor, setAccentColor] = useState<string>('');
|
||||||
|
const playerRef = useRef<VideoPlayerRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
@@ -79,6 +276,16 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
setProduct(productData);
|
setProduct(productData);
|
||||||
|
|
||||||
|
// Fetch accent color from settings
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from('platform_settings')
|
||||||
|
.select('brand_accent_color')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (settings?.brand_accent_color) {
|
||||||
|
setAccentColor(settings.brand_accent_color);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: accessData } = await supabase
|
const { data: accessData } = await supabase
|
||||||
.from('user_access')
|
.from('user_access')
|
||||||
.select('id')
|
.select('id')
|
||||||
@@ -103,9 +310,15 @@ export default function Bootcamp() {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
video_url,
|
video_url,
|
||||||
|
youtube_url,
|
||||||
|
embed_code,
|
||||||
|
m3u8_url,
|
||||||
|
mp4_url,
|
||||||
|
video_host,
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
position,
|
position,
|
||||||
release_at
|
release_at,
|
||||||
|
chapters
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('product_id', productData.id)
|
.eq('product_id', productData.id)
|
||||||
@@ -118,7 +331,20 @@ export default function Bootcamp() {
|
|||||||
}));
|
}));
|
||||||
setModules(sortedModules);
|
setModules(sortedModules);
|
||||||
|
|
||||||
if (sortedModules.length > 0 && sortedModules[0].lessons.length > 0) {
|
// Select lesson based on URL parameter or default to first lesson
|
||||||
|
const allLessons = sortedModules.flatMap(m => m.lessons);
|
||||||
|
|
||||||
|
if (lessonId) {
|
||||||
|
// Find the lesson by ID from URL
|
||||||
|
const lessonFromUrl = allLessons.find(l => l.id === lessonId);
|
||||||
|
if (lessonFromUrl) {
|
||||||
|
setSelectedLesson(lessonFromUrl);
|
||||||
|
} else if (allLessons.length > 0) {
|
||||||
|
// If lesson not found, default to first lesson
|
||||||
|
setSelectedLesson(allLessons[0]);
|
||||||
|
}
|
||||||
|
} else if (allLessons.length > 0 && sortedModules[0].lessons.length > 0) {
|
||||||
|
// No lessonId in URL, select first lesson
|
||||||
setSelectedLesson(sortedModules[0].lessons[0]);
|
setSelectedLesson(sortedModules[0].lessons[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,12 +361,17 @@ export default function Bootcamp() {
|
|||||||
// Check if user has already reviewed this bootcamp
|
// Check if user has already reviewed this bootcamp
|
||||||
const { data: reviewData } = await supabase
|
const { data: reviewData } = await supabase
|
||||||
.from('reviews')
|
.from('reviews')
|
||||||
.select('id')
|
.select('id, rating, title, body, is_approved, created_at')
|
||||||
.eq('user_id', user!.id)
|
.eq('user_id', user!.id)
|
||||||
.eq('product_id', productData.id)
|
.eq('product_id', productData.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
setHasReviewed(!!(reviewData && reviewData.length > 0));
|
if (reviewData && reviewData.length > 0) {
|
||||||
|
setUserReview(reviewData[0] as UserReview);
|
||||||
|
} else {
|
||||||
|
setUserReview(null);
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -149,6 +380,12 @@ export default function Bootcamp() {
|
|||||||
return progress.some(p => p.lesson_id === lessonId);
|
return progress.some(p => p.lesson_id === lessonId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectLesson = (lesson: Lesson) => {
|
||||||
|
setSelectedLesson(lesson);
|
||||||
|
// Update URL without full page reload
|
||||||
|
navigate(`/bootcamp/${slug}/${lesson.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
const markAsCompleted = async () => {
|
const markAsCompleted = async () => {
|
||||||
if (!selectedLesson || !user || !product) return;
|
if (!selectedLesson || !user || !product) return;
|
||||||
|
|
||||||
@@ -170,6 +407,7 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
// Calculate completion percentage for notification
|
// Calculate completion percentage for notification
|
||||||
const completedCount = newProgress.length;
|
const completedCount = newProgress.length;
|
||||||
|
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||||
const completionPercent = Math.round((completedCount / totalLessons) * 100);
|
const completionPercent = Math.round((completedCount / totalLessons) * 100);
|
||||||
|
|
||||||
// Trigger progress notification at milestones
|
// Trigger progress notification at milestones
|
||||||
@@ -215,14 +453,6 @@ export default function Bootcamp() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVideoEmbed = (url: string) => {
|
|
||||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
|
||||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
|
||||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
|
|
||||||
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
const completedCount = progress.length;
|
const completedCount = progress.length;
|
||||||
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||||
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
||||||
@@ -234,7 +464,7 @@ export default function Bootcamp() {
|
|||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
|
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
{module.title}
|
{module.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 ml-2">
|
||||||
{module.lessons.map((lesson) => {
|
{module.lessons.map((lesson) => {
|
||||||
const isCompleted = isLessonCompleted(lesson.id);
|
const isCompleted = isLessonCompleted(lesson.id);
|
||||||
const isSelected = selectedLesson?.id === lesson.id;
|
const isSelected = selectedLesson?.id === lesson.id;
|
||||||
@@ -245,7 +475,7 @@ export default function Bootcamp() {
|
|||||||
key={lesson.id}
|
key={lesson.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isReleased) {
|
if (isReleased) {
|
||||||
setSelectedLesson(lesson);
|
handleSelectLesson(lesson);
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -258,7 +488,7 @@ export default function Bootcamp() {
|
|||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<Check className="w-4 h-4 shrink-0 text-accent" />
|
<Check className="w-4 h-4 shrink-0 text-accent" />
|
||||||
) : lesson.video_url ? (
|
) : (lesson.video_url?.trim() || lesson.youtube_url?.trim() || lesson.embed_code?.trim()) ? (
|
||||||
<Play className="w-4 h-4 shrink-0" />
|
<Play className="w-4 h-4 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<BookOpen className="w-4 h-4 shrink-0" />
|
<BookOpen className="w-4 h-4 shrink-0" />
|
||||||
@@ -367,23 +597,29 @@ export default function Bootcamp() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedLesson.video_url && (
|
<VideoPlayer
|
||||||
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
lesson={selectedLesson}
|
||||||
<iframe
|
playerRef={playerRef}
|
||||||
src={getVideoEmbed(selectedLesson.video_url)}
|
currentTime={currentTime}
|
||||||
className="w-full h-full"
|
accentColor={accentColor}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
setCurrentTime={setCurrentTime}
|
||||||
allowFullScreen
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedLesson.content && (
|
{selectedLesson.content && (
|
||||||
<Card className="border-2 border-border mb-6">
|
<Card className="border-2 border-border mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div
|
<div
|
||||||
className="prose max-w-none"
|
className="prose prose-slate max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: selectedLesson.content }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(selectedLesson.content, {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'a', 'ul', 'ol', 'li',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
|
||||||
|
'img', 'div', 'span', 'iframe', 'table', 'thead', 'tbody', 'tr', 'td', 'th'],
|
||||||
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'width', 'height', 'class', 'style',
|
||||||
|
'target', 'rel', 'title', 'id', 'data-*'],
|
||||||
|
ALLOW_DATA_ATTR: true
|
||||||
|
})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -408,34 +644,86 @@ export default function Bootcamp() {
|
|||||||
{isLessonCompleted(selectedLesson.id) ? (
|
{isLessonCompleted(selectedLesson.id) ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-4 h-4 mr-2" />
|
<Check className="w-4 h-4 mr-2" />
|
||||||
Sudah Selesai
|
Selesai
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Tandai Selesai'
|
'Tandai Selesai'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
{isBootcampCompleted ? (
|
||||||
variant="outline"
|
<Button onClick={() => setReviewModalOpen(true)} className="shadow-sm">
|
||||||
onClick={goToNextLesson}
|
<Star className="w-4 h-4 mr-2" />
|
||||||
disabled={modules.flatMap(m => m.lessons).findIndex(l => l.id === selectedLesson.id) === modules.flatMap(m => m.lessons).length - 1}
|
Beri Ulasan
|
||||||
className="border-2"
|
</Button>
|
||||||
>
|
) : (
|
||||||
Selanjutnya
|
<Button
|
||||||
<ChevronRight className="w-4 h-4 ml-2" />
|
variant="outline"
|
||||||
</Button>
|
onClick={goToNextLesson}
|
||||||
|
disabled={modules.flatMap(m => m.lessons).findIndex(l => l.id === selectedLesson.id) === modules.flatMap(m => m.lessons).length - 1}
|
||||||
|
className="border-2"
|
||||||
|
>
|
||||||
|
Selanjutnya
|
||||||
|
<ChevronRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bootcamp completion review prompt */}
|
{/* Bootcamp completion review prompt */}
|
||||||
{isBootcampCompleted && (
|
{isBootcampCompleted && (
|
||||||
<Card className="border-2 border-primary/20 mt-6">
|
<Card className={`border-2 mt-6 ${userReview?.is_approved ? 'bg-gradient-to-br from-brand-accent/10 to-primary/10 border-brand-accent/30' : 'border-primary/20'}`}>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-6">
|
||||||
{hasReviewed ? (
|
{userReview ? (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
userReview.is_approved ? (
|
||||||
<CheckCircle className="w-5 h-5 text-accent" />
|
// Approved review - celebratory display
|
||||||
<span>Terima kasih atas ulasan Anda (menunggu moderasi)</span>
|
<div className="space-y-4">
|
||||||
</div>
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-full bg-brand-accent p-2">
|
||||||
|
<CheckCircle className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-lg font-bold">Ulasan Anda Terbit!</h3>
|
||||||
|
<Badge className="bg-brand-accent text-white rounded-full">Disetujui</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Terima kasih telah berbagi pengalaman Anda. Ulasan Anda membantu peserta lain!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User's review display */}
|
||||||
|
<div className="bg-background/50 backdrop-blur rounded-lg p-4 border border-brand-accent/20">
|
||||||
|
<div className="flex gap-0.5 mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`w-5 h-5 ${i <= userReview.rating ? 'fill-brand-accent text-brand-accent' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-base mb-1">{userReview.title}</h4>
|
||||||
|
{userReview.body && (
|
||||||
|
<p className="text-sm text-muted-foreground">{userReview.body}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Diterbitkan pada {new Date(userReview.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Pending review
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<div className="rounded-full bg-amber-500/10 p-2">
|
||||||
|
<Clock className="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Ulasan Anda sedang ditinjau</p>
|
||||||
|
<p className="text-sm">Terima kasih! Ulasan akan muncul setelah disetujui admin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
|
// No review yet - prompt to review
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">🎉 Selamat menyelesaikan bootcamp!</p>
|
<p className="font-medium">🎉 Selamat menyelesaikan bootcamp!</p>
|
||||||
@@ -475,7 +763,28 @@ export default function Bootcamp() {
|
|||||||
productId={product.id}
|
productId={product.id}
|
||||||
type="bootcamp"
|
type="bootcamp"
|
||||||
contextLabel={product.title}
|
contextLabel={product.title}
|
||||||
onSuccess={() => setHasReviewed(true)}
|
existingReview={userReview ? {
|
||||||
|
id: userReview.id,
|
||||||
|
rating: userReview.rating,
|
||||||
|
title: userReview.title,
|
||||||
|
body: userReview.body,
|
||||||
|
} : undefined}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Refresh review data
|
||||||
|
const refreshReview = async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.select('id, rating, title, body, is_approved, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('product_id', product.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
setUserReview(data[0] as UserReview);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
refreshReview();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function Calendar() {
|
|||||||
// Fetch webinar events
|
// Fetch webinar events
|
||||||
const { data: webinars } = await supabase
|
const { data: webinars } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
.select('id, title, event_start, duration')
|
.select('id, title, event_start, duration_minutes')
|
||||||
.eq('type', 'webinar')
|
.eq('type', 'webinar')
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.gte('event_start', start)
|
.gte('event_start', start)
|
||||||
@@ -76,12 +76,16 @@ export default function Calendar() {
|
|||||||
webinars?.forEach(w => {
|
webinars?.forEach(w => {
|
||||||
if (w.event_start) {
|
if (w.event_start) {
|
||||||
const eventDate = new Date(w.event_start);
|
const eventDate = new Date(w.event_start);
|
||||||
|
const durationMs = (w.duration_minutes || 60) * 60 * 1000;
|
||||||
|
const endDate = new Date(eventDate.getTime() + durationMs);
|
||||||
|
|
||||||
allEvents.push({
|
allEvents.push({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
title: w.title,
|
title: w.title,
|
||||||
type: 'webinar',
|
type: 'webinar',
|
||||||
date: format(eventDate, 'yyyy-MM-dd'),
|
date: format(eventDate, 'yyyy-MM-dd'),
|
||||||
start_time: format(eventDate, 'HH:mm'),
|
start_time: format(eventDate, 'HH:mm'),
|
||||||
|
end_time: format(endDate, 'HH:mm'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { AppLayout } from "@/components/AppLayout";
|
import { AppLayout } from "@/components/AppLayout";
|
||||||
import { useCart } from "@/contexts/CartContext";
|
import { useCart } from "@/contexts/CartContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import { formatIDR } from "@/lib/format";
|
import { formatIDR } from "@/lib/format";
|
||||||
import { Trash2, CreditCard, Loader2, QrCode, Wallet } from "lucide-react";
|
import { Trash2, CreditCard, Loader2, QrCode, ArrowLeft } from "lucide-react";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
// Pakasir configuration
|
|
||||||
const PAKASIR_PROJECT_SLUG = import.meta.env.VITE_PAKASIR_PROJECT_SLUG || "dewengoding";
|
|
||||||
const SANDBOX_API_KEY = "iP13osgh7lAzWWIPsj7TbW5M3iGEAQMo";
|
|
||||||
|
|
||||||
// Centralized API key retrieval - uses env var with sandbox fallback
|
|
||||||
const getPakasirApiKey = (): string => {
|
|
||||||
return import.meta.env.VITE_PAKASIR_API_KEY || SANDBOX_API_KEY;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Edge function base URL - configurable via env with sensible default
|
// Edge function base URL - configurable via env with sensible default
|
||||||
const getEdgeFunctionBaseUrl = (): string => {
|
const getEdgeFunctionBaseUrl = (): string => {
|
||||||
@@ -29,55 +21,41 @@ const getEdgeFunctionBaseUrl = (): string => {
|
|||||||
|
|
||||||
const PAKASIR_CALLBACK_URL = `${getEdgeFunctionBaseUrl()}/pakasir-webhook`;
|
const PAKASIR_CALLBACK_URL = `${getEdgeFunctionBaseUrl()}/pakasir-webhook`;
|
||||||
|
|
||||||
type PaymentMethod = "qris" | "paypal";
|
type CheckoutStep = "cart" | "payment";
|
||||||
type CheckoutStep = "cart" | "payment" | "waiting";
|
|
||||||
|
|
||||||
interface PaymentData {
|
|
||||||
qr_string?: string;
|
|
||||||
payment_url?: string;
|
|
||||||
expired_at?: string;
|
|
||||||
order_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Checkout() {
|
export default function Checkout() {
|
||||||
const { items, removeItem, clearCart, total } = useCart();
|
const { items, removeItem, clearCart, total } = useCart();
|
||||||
const { user } = useAuth();
|
const { user, signIn, signUp, sendAuthOTP, verifyAuthOTP } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [step, setStep] = useState<CheckoutStep>("cart");
|
const [step, setStep] = useState<CheckoutStep>("cart");
|
||||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("qris");
|
|
||||||
const [paymentData, setPaymentData] = useState<PaymentData | null>(null);
|
|
||||||
const [orderId, setOrderId] = useState<string | null>(null);
|
|
||||||
const [checkingStatus, setCheckingStatus] = useState(false);
|
|
||||||
|
|
||||||
// Check for returning from PayPal
|
// Auth modal state
|
||||||
useEffect(() => {
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
const returnedOrderId = searchParams.get("order_id");
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
if (returnedOrderId) {
|
const [authEmail, setAuthEmail] = useState("");
|
||||||
setOrderId(returnedOrderId);
|
const [authPassword, setAuthPassword] = useState("");
|
||||||
checkPaymentStatus(returnedOrderId);
|
const [authName, setAuthName] = useState("");
|
||||||
}
|
const [showOTP, setShowOTP] = useState(false);
|
||||||
}, [searchParams]);
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [pendingUserId, setPendingUserId] = useState<string | null>(null);
|
||||||
|
const [resendCountdown, setResendCountdown] = useState(0);
|
||||||
|
|
||||||
const checkPaymentStatus = async (oid: string) => {
|
const checkPaymentStatus = async (oid: string) => {
|
||||||
setCheckingStatus(true);
|
|
||||||
const { data: order } = await supabase.from("orders").select("payment_status").eq("id", oid).single();
|
const { data: order } = await supabase.from("orders").select("payment_status").eq("id", oid).single();
|
||||||
|
|
||||||
if (order?.payment_status === "paid") {
|
if (order?.payment_status === "paid") {
|
||||||
toast({ title: "Pembayaran berhasil!", description: "Akses produk sudah aktif" });
|
toast({ title: "Pembayaran berhasil!", description: "Akses produk sudah aktif" });
|
||||||
navigate("/access");
|
navigate(`/orders/${oid}`);
|
||||||
} else {
|
|
||||||
toast({ title: "Pembayaran pending", description: "Menunggu konfirmasi pembayaran" });
|
|
||||||
}
|
}
|
||||||
setCheckingStatus(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckout = async () => {
|
const handleCheckout = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast({ title: "Login diperlukan", description: "Silakan login untuk melanjutkan pembayaran" });
|
toast({ title: "Login diperlukan", description: "Silakan login untuk melanjutkan pembayaran" });
|
||||||
navigate("/auth");
|
// Pass current location for redirect after login
|
||||||
|
navigate("/auth", { state: { redirectTo: window.location.pathname } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +85,7 @@ export default function Checkout() {
|
|||||||
payment_provider: "pakasir",
|
payment_provider: "pakasir",
|
||||||
payment_reference: orderRef,
|
payment_reference: orderRef,
|
||||||
payment_status: "pending",
|
payment_status: "pending",
|
||||||
payment_method: paymentMethod,
|
payment_method: "qris",
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -127,55 +105,62 @@ export default function Checkout() {
|
|||||||
const { error: itemsError } = await supabase.from("order_items").insert(orderItems);
|
const { error: itemsError } = await supabase.from("order_items").insert(orderItems);
|
||||||
if (itemsError) throw new Error("Gagal menambahkan item order");
|
if (itemsError) throw new Error("Gagal menambahkan item order");
|
||||||
|
|
||||||
setOrderId(order.id);
|
// Send order_created email IMMEDIATELY after order is created (before payment QR)
|
||||||
|
console.log('[CHECKOUT] About to send order_created email for order:', order.id);
|
||||||
|
console.log('[CHECKOUT] User email:', user.email);
|
||||||
|
|
||||||
// Build description from product titles
|
try {
|
||||||
const productTitles = items.map(item => item.title).join(", ");
|
const result = await supabase.functions.invoke('send-notification', {
|
||||||
|
body: {
|
||||||
if (paymentMethod === "qris") {
|
template_key: 'order_created',
|
||||||
// Call Pakasir API for QRIS
|
recipient_email: user.email,
|
||||||
try {
|
recipient_name: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan',
|
||||||
const response = await fetch(`https://app.pakasir.com/api/transactioncreate/qris`, {
|
variables: {
|
||||||
method: "POST",
|
nama: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan',
|
||||||
headers: { "Content-Type": "application/json" },
|
email: user.email,
|
||||||
body: JSON.stringify({
|
|
||||||
project: PAKASIR_PROJECT_SLUG,
|
|
||||||
order_id: order.id,
|
order_id: order.id,
|
||||||
amount: amountInRupiah,
|
order_id_short: order.id.substring(0, 8),
|
||||||
api_key: getPakasirApiKey(),
|
tanggal_pesanan: new Date().toLocaleDateString('id-ID', {
|
||||||
description: productTitles,
|
day: '2-digit',
|
||||||
callback_url: PAKASIR_CALLBACK_URL,
|
month: 'short',
|
||||||
}),
|
year: 'numeric'
|
||||||
});
|
}),
|
||||||
|
total: formatIDR(total),
|
||||||
const result = await response.json();
|
metode_pembayaran: 'QRIS',
|
||||||
|
produk: items.map(item => item.title).join(', '),
|
||||||
if (result.qr_string || result.qr) {
|
payment_link: `${window.location.origin}/orders/${order.id}`,
|
||||||
setPaymentData({
|
thank_you_page: `${window.location.origin}/orders/${order.id}`
|
||||||
qr_string: result.qr_string || result.qr,
|
}
|
||||||
expired_at: result.expired_at,
|
|
||||||
order_id: order.id,
|
|
||||||
});
|
|
||||||
setStep("waiting");
|
|
||||||
clearCart();
|
|
||||||
} else {
|
|
||||||
// Fallback to redirect if API doesn't return QR
|
|
||||||
const pakasirUrl = `https://app.pakasir.com/pay/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`;
|
|
||||||
clearCart();
|
|
||||||
window.location.href = pakasirUrl;
|
|
||||||
}
|
}
|
||||||
} catch {
|
});
|
||||||
// Fallback to redirect
|
console.log('[CHECKOUT] send-notification called successfully:', result);
|
||||||
const pakasirUrl = `https://app.pakasir.com/pay/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`;
|
} catch (emailErr) {
|
||||||
clearCart();
|
console.error('[CHECKOUT] Failed to send order_created email:', emailErr);
|
||||||
window.location.href = pakasirUrl;
|
// Don't block checkout flow if email fails
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// PayPal - redirect to Pakasir PayPal URL
|
|
||||||
clearCart();
|
|
||||||
const paypalUrl = `https://app.pakasir.com/paypal/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`;
|
|
||||||
window.location.href = paypalUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[CHECKOUT] Order creation email call completed');
|
||||||
|
|
||||||
|
// Build description from product titles
|
||||||
|
const productTitles = items.map(item => item.title).join(", ");
|
||||||
|
|
||||||
|
// Call edge function to create QRIS payment
|
||||||
|
const { data: paymentData, error: paymentError } = await supabase.functions.invoke('create-payment', {
|
||||||
|
body: {
|
||||||
|
order_id: order.id,
|
||||||
|
amount: amountInRupiah,
|
||||||
|
description: productTitles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paymentError) {
|
||||||
|
console.error('Payment creation error:', paymentError);
|
||||||
|
throw new Error(paymentError.message || 'Gagal membuat pembayaran');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cart and redirect to order detail page to show QR code
|
||||||
|
clearCart();
|
||||||
|
navigate(`/orders/${order.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Checkout error:", error);
|
console.error("Checkout error:", error);
|
||||||
toast({
|
toast({
|
||||||
@@ -189,64 +174,172 @@ export default function Checkout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshPaymentStatus = async () => {
|
const refreshPaymentStatus = async () => {
|
||||||
if (!orderId) return;
|
// This function is now handled in OrderDetail page
|
||||||
setCheckingStatus(true);
|
// Kept for backwards compatibility but no longer used
|
||||||
|
toast({ title: "Info", description: "Status pembayaran diupdate otomatis" });
|
||||||
const { data: order } = await supabase.from("orders").select("payment_status").eq("id", orderId).single();
|
|
||||||
|
|
||||||
if (order?.payment_status === "paid") {
|
|
||||||
toast({ title: "Pembayaran berhasil!", description: "Akses produk sudah aktif" });
|
|
||||||
navigate("/access");
|
|
||||||
} else {
|
|
||||||
toast({ title: "Belum ada pembayaran", description: "Silakan selesaikan pembayaran" });
|
|
||||||
}
|
|
||||||
setCheckingStatus(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Waiting for QRIS payment
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
if (step === "waiting" && paymentData) {
|
e.preventDefault();
|
||||||
return (
|
if (!authEmail || !authPassword) {
|
||||||
<AppLayout>
|
toast({ title: "Error", description: "Email dan password wajib diisi", variant: "destructive" });
|
||||||
<div className="container mx-auto px-4 py-8 max-w-md">
|
return;
|
||||||
<Card className="border-2 border-border">
|
}
|
||||||
<CardHeader className="text-center">
|
|
||||||
<CardTitle>Scan QR Code untuk Bayar</CardTitle>
|
setAuthLoading(true);
|
||||||
</CardHeader>
|
const { error } = await signIn(authEmail, authPassword);
|
||||||
<CardContent className="flex flex-col items-center space-y-6">
|
|
||||||
<div className="bg-white p-4 rounded-lg">
|
if (error) {
|
||||||
<QRCodeSVG value={paymentData.qr_string || ""} size={200} />
|
toast({
|
||||||
</div>
|
title: "Login gagal",
|
||||||
<div className="text-center">
|
description: error.message || "Email atau password salah",
|
||||||
<p className="text-2xl font-bold">{formatIDR(total)}</p>
|
variant: "destructive",
|
||||||
{paymentData.expired_at && (
|
});
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
setAuthLoading(false);
|
||||||
Berlaku hingga: {new Date(paymentData.expired_at).toLocaleString("id-ID")}
|
} else {
|
||||||
</p>
|
toast({ title: "Login berhasil", description: "Silakan lanjutkan pembayaran" });
|
||||||
)}
|
setAuthModalOpen(false);
|
||||||
</div>
|
setAuthLoading(false);
|
||||||
<div className="w-full space-y-2">
|
}
|
||||||
<Button
|
};
|
||||||
onClick={refreshPaymentStatus}
|
|
||||||
variant="outline"
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
className="w-full border-2"
|
e.preventDefault();
|
||||||
disabled={checkingStatus}
|
if (!authEmail || !authPassword || !authName) {
|
||||||
>
|
toast({ title: "Error", description: "Semua field wajib diisi", variant: "destructive" });
|
||||||
{checkingStatus ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
return;
|
||||||
Cek Status Pembayaran
|
}
|
||||||
</Button>
|
|
||||||
<Button onClick={() => navigate("/dashboard")} variant="ghost" className="w-full">
|
if (authPassword.length < 6) {
|
||||||
Kembali ke Dashboard
|
toast({ title: "Error", description: "Password minimal 6 karakter", variant: "destructive" });
|
||||||
</Button>
|
return;
|
||||||
</div>
|
}
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
|
||||||
Pembayaran diproses melalui Pakasir dan akan dikonfirmasi otomatis setelah berhasil.
|
setAuthLoading(true);
|
||||||
</p>
|
|
||||||
</CardContent>
|
try {
|
||||||
</Card>
|
const { data, error } = await signUp(authEmail, authPassword, authName);
|
||||||
</div>
|
|
||||||
</AppLayout>
|
if (error) {
|
||||||
);
|
toast({
|
||||||
}
|
title: "Registrasi gagal",
|
||||||
|
description: error.message || "Gagal membuat akun",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setAuthLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.user) {
|
||||||
|
toast({ title: "Error", description: "Failed to create user account. Please try again.", variant: "destructive" });
|
||||||
|
setAuthLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User created, now send OTP
|
||||||
|
const userId = data.user.id;
|
||||||
|
const result = await sendAuthOTP(userId, authEmail);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setPendingUserId(userId);
|
||||||
|
setShowOTP(true);
|
||||||
|
setResendCountdown(60);
|
||||||
|
toast({
|
||||||
|
title: "OTP Terkirim",
|
||||||
|
description: "Kode verifikasi telah dikirim ke email Anda. Silakan cek inbox.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: "Error", description: error.message || "Terjadi kesalahan", variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOTPSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!pendingUserId) {
|
||||||
|
toast({ title: "Error", description: "Session expired. Please try again.", variant: "destructive" });
|
||||||
|
setShowOTP(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otpCode.length !== 6) {
|
||||||
|
toast({ title: "Error", description: "OTP harus 6 digit", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifyAuthOTP(pendingUserId, otpCode);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: "Verifikasi Berhasil",
|
||||||
|
description: "Akun Anda telah terverifikasi. Mengalihkan...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-login after OTP verification
|
||||||
|
const loginResult = await signIn(authEmail, authPassword);
|
||||||
|
|
||||||
|
if (loginResult.error) {
|
||||||
|
toast({
|
||||||
|
title: "Peringatan",
|
||||||
|
description: "Akun terverifikasi tapi gagal login otomatis. Silakan login manual.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowOTP(false);
|
||||||
|
setAuthModalOpen(false);
|
||||||
|
// Reset form
|
||||||
|
setAuthName("");
|
||||||
|
setAuthEmail("");
|
||||||
|
setAuthPassword("");
|
||||||
|
setOtpCode("");
|
||||||
|
setPendingUserId(null);
|
||||||
|
} else {
|
||||||
|
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: "Error", description: error.message || "Verifikasi gagal", variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendOTP = async () => {
|
||||||
|
if (resendCountdown > 0 || !pendingUserId) return;
|
||||||
|
|
||||||
|
setAuthLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendAuthOTP(pendingUserId, authEmail);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setResendCountdown(60);
|
||||||
|
toast({ title: "OTP Terkirim Ulang", description: "Kode verifikasi baru telah dikirim ke email Anda." });
|
||||||
|
} else {
|
||||||
|
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: "Error", description: error.message || "Gagal mengirim OTP", variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resend countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCountdown > 0) {
|
||||||
|
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [resendCountdown]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -289,34 +382,15 @@ export default function Checkout() {
|
|||||||
<CardTitle>Metode Pembayaran</CardTitle>
|
<CardTitle>Metode Pembayaran</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RadioGroup
|
<div className="flex items-center space-x-3 p-3 border-2 border-border rounded-none bg-muted">
|
||||||
value={paymentMethod}
|
<QrCode className="w-5 h-5" />
|
||||||
onValueChange={(v) => setPaymentMethod(v as PaymentMethod)}
|
<div>
|
||||||
className="space-y-3"
|
<p className="font-medium">QRIS</p>
|
||||||
>
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center space-x-3 p-3 border-2 border-border rounded-none hover:bg-muted cursor-pointer">
|
Scan QR dengan aplikasi e-wallet atau mobile banking
|
||||||
<RadioGroupItem value="qris" id="qris" />
|
</p>
|
||||||
<Label htmlFor="qris" className="flex items-center gap-2 cursor-pointer flex-1">
|
|
||||||
<QrCode className="w-5 h-5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">QRIS</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Scan QR dengan aplikasi e-wallet atau mobile banking
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3 p-3 border-2 border-border rounded-none hover:bg-muted cursor-pointer">
|
</div>
|
||||||
<RadioGroupItem value="paypal" id="paypal" />
|
|
||||||
<Label htmlFor="paypal" className="flex items-center gap-2 cursor-pointer flex-1">
|
|
||||||
<Wallet className="w-5 h-5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">PayPal</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Bayar dengan akun PayPal Anda</p>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,22 +405,215 @@ export default function Checkout() {
|
|||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span className="font-bold">{formatIDR(total)}</span>
|
<span className="font-bold">{formatIDR(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
<div className="space-y-3 pt-2 border-t">
|
||||||
{loading ? (
|
{user ? (
|
||||||
<>
|
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
{loading ? (
|
||||||
Memproses...
|
<>
|
||||||
</>
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
) : user ? (
|
Memproses...
|
||||||
<>
|
</>
|
||||||
<CreditCard className="w-4 h-4 mr-2" />
|
) : (
|
||||||
Bayar dengan {paymentMethod === "qris" ? "QRIS" : "PayPal"}
|
<>
|
||||||
</>
|
<CreditCard className="w-4 h-4 mr-2" />
|
||||||
|
Bayar dengan QRIS
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
"Login untuk Checkout"
|
<Dialog open={authModalOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
// Reset state when closing
|
||||||
|
setShowOTP(false);
|
||||||
|
setOtpCode("");
|
||||||
|
setPendingUserId(null);
|
||||||
|
setResendCountdown(0);
|
||||||
|
}
|
||||||
|
setAuthModalOpen(open);
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="w-full shadow-sm">
|
||||||
|
Login atau Daftar untuk Checkout
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md" onPointerDownOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{showOTP ? "Verifikasi Email" : "Login atau Daftar"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!showOTP ? (
|
||||||
|
<Tabs defaultValue="login" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="login">Login</TabsTrigger>
|
||||||
|
<TabsTrigger value="register">Daftar</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="login">
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="login-email" className="text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="nama@email.com"
|
||||||
|
value={authEmail}
|
||||||
|
onChange={(e) => setAuthEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="login-password" className="text-sm font-medium">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={authPassword}
|
||||||
|
onChange={(e) => setAuthPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={authLoading}>
|
||||||
|
{authLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Memproses...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Login"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="register">
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="register-name" className="text-sm font-medium">
|
||||||
|
Nama Lengkap
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="register-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={authName}
|
||||||
|
onChange={(e) => setAuthName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="register-email" className="text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="register-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="nama@email.com"
|
||||||
|
value={authEmail}
|
||||||
|
onChange={(e) => setAuthEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="register-password" className="text-sm font-medium">
|
||||||
|
Password (minimal 6 karakter)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="register-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={authPassword}
|
||||||
|
onChange={(e) => setAuthPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={authLoading}>
|
||||||
|
{authLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Memproses...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Daftar"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleOTPSubmit} className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Masukkan kode 6 digit yang telah dikirim ke <strong>{authEmail}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="otp-code" className="text-sm font-medium">
|
||||||
|
Kode Verifikasi
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="otp-code"
|
||||||
|
type="text"
|
||||||
|
placeholder="123456"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-2xl tracking-widest"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={authLoading || otpCode.length !== 6}>
|
||||||
|
{authLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Memverifikasi...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verifikasi"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendOTP}
|
||||||
|
disabled={resendCountdown > 0 || authLoading}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resendCountdown > 0
|
||||||
|
? `Kirim ulang dalam ${resendCountdown} detik`
|
||||||
|
: "Belum menerima kode? Kirim ulang"}
|
||||||
|
</button>
|
||||||
|
{pendingUserId && authEmail && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Modal tertutup tidak sengaja?{" "}
|
||||||
|
<a
|
||||||
|
href={`/confirm-otp?user_id=${pendingUserId}&email=${encodeURIComponent(authEmail)}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowOTP(false);
|
||||||
|
setAuthModalOpen(false);
|
||||||
|
window.location.href = `/confirm-otp?user_id=${pendingUserId}&email=${encodeURIComponent(authEmail)}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Buka halaman verifikasi khusus
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</Button>
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-muted-foreground text-center">Pembayaran diproses melalui Pakasir</p>
|
<p className="text-xs text-muted-foreground text-center">Pembayaran aman dengan standar QRIS dari Bank Indonesia</p>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">Diproses oleh mitra pembayaran terpercaya</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground text-center pt-1">Didukung oleh Pakasir | QRIS terdaftar oleh Bank Indonesia</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
255
src/pages/ConfirmOTP.tsx
Normal file
255
src/pages/ConfirmOTP.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { AppLayout } from "@/components/AppLayout";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { Loader2, ArrowLeft, Mail } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ConfirmOTP() {
|
||||||
|
const { user, signIn, sendAuthOTP, verifyAuthOTP } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [resendCountdown, setResendCountdown] = useState(0);
|
||||||
|
|
||||||
|
// Get user_id and email from URL params or from user state
|
||||||
|
const userId = searchParams.get('user_id') || user?.id;
|
||||||
|
const email = searchParams.get('email') || user?.email;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId && !user) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Sesi tidak valid. Silakan mendaftar ulang.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
navigate('/auth');
|
||||||
|
}
|
||||||
|
}, [userId, user]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
toast({ title: "Error", description: "Session expired. Please try again.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otpCode.length !== 6) {
|
||||||
|
toast({ title: "Error", description: "OTP harus 6 digit", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifyAuthOTP(userId, otpCode);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: "Verifikasi Berhasil",
|
||||||
|
description: "Akun Anda telah terverifikasi. Mengalihkan...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// If user is already logged in, just redirect
|
||||||
|
if (user) {
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/dashboard');
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get email from URL params or use a default
|
||||||
|
const userEmail = email || searchParams.get('email');
|
||||||
|
|
||||||
|
if (userEmail) {
|
||||||
|
// Auto-login after OTP verification
|
||||||
|
// We need the password, which should have been stored or we need to ask user
|
||||||
|
// For now, redirect to login with success message
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/auth', {
|
||||||
|
state: {
|
||||||
|
message: "Email berhasil diverifikasi. Silakan login dengan email dan password Anda.",
|
||||||
|
email: userEmail
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/auth', {
|
||||||
|
state: {
|
||||||
|
message: "Email berhasil diverifikasi. Silakan login."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: "Error", description: error.message || "Verifikasi gagal", variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendOTP = async () => {
|
||||||
|
if (resendCountdown > 0 || !userId || !email) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendAuthOTP(userId, email);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setResendCountdown(60);
|
||||||
|
toast({ title: "OTP Terkirim Ulang", description: "Kode verifikasi baru telah dikirim ke email Anda." });
|
||||||
|
} else {
|
||||||
|
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: "Error", description: error.message || "Gagal mengirim OTP", variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resend countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCountdown > 0) {
|
||||||
|
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [resendCountdown]);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Card className="max-w-md mx-auto border-2 border-border">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">Sesi tidak valid atau telah kedaluwarsa.</p>
|
||||||
|
<Link to="/auth">
|
||||||
|
<Button variant="outline" className="mt-4 border-2">
|
||||||
|
Kembali ke Halaman Auth
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-md mx-auto space-y-4">
|
||||||
|
{/* Back Button */}
|
||||||
|
<Link to="/auth">
|
||||||
|
<Button variant="ghost" className="gap-2">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Kembali ke Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<Card className="border-2 border-border shadow-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
|
<Mail className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Konfirmasi Email</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Masukkan kode 6 digit yang telah dikirim ke <strong>{email}</strong>
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="otp-code" className="text-sm font-medium">
|
||||||
|
Kode Verifikasi
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="otp-code"
|
||||||
|
type="text"
|
||||||
|
placeholder="123456"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-2xl tracking-widest"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading || otpCode.length !== 6}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Memverifikasi...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verifikasi Email"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendOTP}
|
||||||
|
disabled={resendCountdown > 0 || loading}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resendCountdown > 0
|
||||||
|
? `Kirim ulang dalam ${resendCountdown} detik`
|
||||||
|
: "Belum menerima kode? Kirim ulang"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<p className="text-xs text-center text-muted-foreground space-y-1">
|
||||||
|
<p>💡 <strong>Tips:</strong> Kode berlaku selama 15 menit.</p>
|
||||||
|
<p>Cek folder spam jika email tidak muncul di inbox.</p>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Help Box */}
|
||||||
|
<Card className="border-2 border-border bg-muted/50">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
<p className="font-medium">Tidak menerima email?</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||||
|
<li>Pastikan email yang dimasukkan benar</li>
|
||||||
|
<li>Cek folder spam/junk email</li>
|
||||||
|
<li>Tunggu beberapa saat, email mungkin memerlukan waktu untuk sampai</li>
|
||||||
|
</ul>
|
||||||
|
{email && (
|
||||||
|
<p className="mt-2">
|
||||||
|
Belum mendaftar?{" "}
|
||||||
|
<Link to="/auth" className="text-primary hover:underline font-medium">
|
||||||
|
Kembali ke pendaftaran
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { useEffect, useState, useMemo } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
|
||||||
import { AppLayout } from '@/components/AppLayout';
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -32,12 +31,19 @@ interface Workhour {
|
|||||||
end_time: string;
|
end_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfirmedSlot {
|
interface ConfirmedSession {
|
||||||
date: string;
|
session_date: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Webinar {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
event_start: string;
|
||||||
|
duration_minutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface TimeSlot {
|
interface TimeSlot {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
@@ -51,16 +57,24 @@ interface Profile {
|
|||||||
export default function ConsultingBooking() {
|
export default function ConsultingBooking() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addItem } = useCart();
|
|
||||||
|
|
||||||
const [settings, setSettings] = useState<ConsultingSettings | null>(null);
|
const [settings, setSettings] = useState<ConsultingSettings | null>(null);
|
||||||
const [workhours, setWorkhours] = useState<Workhour[]>([]);
|
const [workhours, setWorkhours] = useState<Workhour[]>([]);
|
||||||
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
|
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
|
||||||
|
const [webinars, setWebinars] = useState<Webinar[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
|
||||||
const [selectedSlots, setSelectedSlots] = useState<string[]>([]);
|
|
||||||
|
// Range selection with pending slot
|
||||||
|
interface TimeRange {
|
||||||
|
start: string | null;
|
||||||
|
end: string | null;
|
||||||
|
}
|
||||||
|
const [selectedRange, setSelectedRange] = useState<TimeRange>({ start: null, end: null });
|
||||||
|
const [pendingSlot, setPendingSlot] = useState<string | null>(null);
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('');
|
const [selectedCategory, setSelectedCategory] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [whatsappInput, setWhatsappInput] = useState('');
|
const [whatsappInput, setWhatsappInput] = useState('');
|
||||||
@@ -68,11 +82,37 @@ export default function ConsultingBooking() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|
||||||
|
// Check for pre-filled data from expired order
|
||||||
|
const expiredOrderData = sessionStorage.getItem('expiredConsultingOrder');
|
||||||
|
if (expiredOrderData) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(expiredOrderData);
|
||||||
|
if (data.fromExpiredOrder) {
|
||||||
|
// Prefill form with expired order data
|
||||||
|
if (data.topicCategory) setSelectedCategory(data.topicCategory);
|
||||||
|
if (data.notes) setNotes(data.notes);
|
||||||
|
|
||||||
|
// Show notification to user
|
||||||
|
setTimeout(() => {
|
||||||
|
// You could add a toast notification here if you have toast set up
|
||||||
|
console.log('Pre-filled data from expired order:', data);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Clear the stored data after using it
|
||||||
|
sessionStorage.removeItem('expiredConsultingOrder');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing expired order data:', err);
|
||||||
|
sessionStorage.removeItem('expiredConsultingOrder');
|
||||||
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
fetchConfirmedSlots(selectedDate);
|
fetchConfirmedSlots(selectedDate);
|
||||||
|
fetchWebinars(selectedDate);
|
||||||
}
|
}
|
||||||
}, [selectedDate]);
|
}, [selectedDate]);
|
||||||
|
|
||||||
@@ -92,14 +132,26 @@ export default function ConsultingBooking() {
|
|||||||
const fetchConfirmedSlots = async (date: Date) => {
|
const fetchConfirmedSlots = async (date: Date) => {
|
||||||
const dateStr = format(date, 'yyyy-MM-dd');
|
const dateStr = format(date, 'yyyy-MM-dd');
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('consulting_slots')
|
.from('consulting_sessions')
|
||||||
.select('date, start_time, end_time')
|
.select('session_date, start_time, end_time')
|
||||||
.eq('date', dateStr)
|
.eq('session_date', dateStr)
|
||||||
.in('status', ['pending_payment', 'confirmed']);
|
.in('status', ['pending_payment', 'confirmed']);
|
||||||
|
|
||||||
if (data) setConfirmedSlots(data);
|
if (data) setConfirmedSlots(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchWebinars = async (date: Date) => {
|
||||||
|
const dateStr = format(date, 'yyyy-MM-dd');
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('id, title, event_start, duration_minutes')
|
||||||
|
.eq('type', 'webinar')
|
||||||
|
.eq('is_active', true)
|
||||||
|
.like('event_start', `${dateStr}%`);
|
||||||
|
|
||||||
|
if (data) setWebinars(data);
|
||||||
|
};
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
if (!settings?.consulting_categories) return [];
|
if (!settings?.consulting_categories) return [];
|
||||||
return settings.consulting_categories.split(',').map(c => c.trim()).filter(Boolean);
|
return settings.consulting_categories.split(',').map(c => c.trim()).filter(Boolean);
|
||||||
@@ -126,20 +178,36 @@ export default function ConsultingBooking() {
|
|||||||
const slotStart = format(current, 'HH:mm');
|
const slotStart = format(current, 'HH:mm');
|
||||||
const slotEnd = format(addMinutes(current, duration), 'HH:mm');
|
const slotEnd = format(addMinutes(current, duration), 'HH:mm');
|
||||||
|
|
||||||
// Check if slot conflicts with confirmed/pending slots
|
// Check if slot conflicts with confirmed/pending consulting slots
|
||||||
const isConflict = confirmedSlots.some(cs => {
|
const isConflict = confirmedSlots.some(cs => {
|
||||||
const csStart = cs.start_time.substring(0, 5);
|
const csStart = cs.start_time.substring(0, 5);
|
||||||
const csEnd = cs.end_time.substring(0, 5);
|
const csEnd = cs.end_time.substring(0, 5);
|
||||||
return !(slotEnd <= csStart || slotStart >= csEnd);
|
return !(slotEnd <= csStart || slotStart >= csEnd);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if slot conflicts with webinars
|
||||||
|
const webinarConflict = webinars.some(w => {
|
||||||
|
const webinarStart = new Date(w.event_start);
|
||||||
|
const webinarDurationMs = (w.duration_minutes || 60) * 60 * 1000;
|
||||||
|
const webinarEnd = new Date(webinarStart.getTime() + webinarDurationMs);
|
||||||
|
|
||||||
|
const slotStartTime = new Date(selectedDate);
|
||||||
|
slotStartTime.setHours(parseInt(slotStart.split(':')[0]), parseInt(slotStart.split(':')[1]), 0);
|
||||||
|
|
||||||
|
const slotEndTime = new Date(selectedDate);
|
||||||
|
slotEndTime.setHours(parseInt(slotEnd.split(':')[0]), parseInt(slotEnd.split(':')[1]), 0);
|
||||||
|
|
||||||
|
// Block if slot overlaps with webinar time
|
||||||
|
return slotStartTime < webinarEnd && slotEndTime > webinarStart;
|
||||||
|
});
|
||||||
|
|
||||||
// Check if slot is in the past for today
|
// Check if slot is in the past for today
|
||||||
const isPassed = isToday && isBefore(current, now);
|
const isPassed = isToday && isBefore(current, now);
|
||||||
|
|
||||||
slots.push({
|
slots.push({
|
||||||
start: slotStart,
|
start: slotStart,
|
||||||
end: slotEnd,
|
end: slotEnd,
|
||||||
available: !isConflict && !isPassed,
|
available: !isConflict && !webinarConflict && !isPassed,
|
||||||
});
|
});
|
||||||
|
|
||||||
current = addMinutes(current, duration);
|
current = addMinutes(current, duration);
|
||||||
@@ -147,17 +215,97 @@ export default function ConsultingBooking() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
}, [selectedDate, workhours, confirmedSlots, settings]);
|
}, [selectedDate, workhours, confirmedSlots, webinars, settings]);
|
||||||
|
|
||||||
const toggleSlot = (slotStart: string) => {
|
// Helper: Get all slots between start and end (inclusive)
|
||||||
setSelectedSlots(prev =>
|
// Now supports single slot selection where start = end
|
||||||
prev.includes(slotStart)
|
const getSlotsInRange = useMemo(() => {
|
||||||
? prev.filter(s => s !== slotStart)
|
// If there's a pending slot but no confirmed range, don't show any slots as selected
|
||||||
: [...prev, slotStart]
|
if (pendingSlot && !selectedRange.start) return [];
|
||||||
);
|
|
||||||
|
// If only start is set (no end), don't show any slots as selected yet
|
||||||
|
if (!selectedRange.start || !selectedRange.end) return [];
|
||||||
|
|
||||||
|
const startIndex = availableSlots.findIndex(s => s.start === selectedRange.start);
|
||||||
|
const endIndex = availableSlots.findIndex(s => s.start === selectedRange.end);
|
||||||
|
|
||||||
|
if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) return [];
|
||||||
|
|
||||||
|
return availableSlots
|
||||||
|
.slice(startIndex, endIndex + 1)
|
||||||
|
.map(s => s.start);
|
||||||
|
}, [selectedRange, availableSlots, pendingSlot]);
|
||||||
|
|
||||||
|
// Range selection handler with pending slot UX
|
||||||
|
const handleSlotClick = (slotStart: string) => {
|
||||||
|
const slot = availableSlots.find(s => s.start === slotStart);
|
||||||
|
if (!slot || !slot.available) return;
|
||||||
|
|
||||||
|
// If there's a pending slot
|
||||||
|
if (pendingSlot) {
|
||||||
|
if (slotStart === pendingSlot) {
|
||||||
|
// Clicked same slot again → Confirm single slot selection
|
||||||
|
setSelectedRange({ start: slotStart, end: slotStart });
|
||||||
|
setPendingSlot(null);
|
||||||
|
} else {
|
||||||
|
// Clicked different slot → First becomes start, second becomes end
|
||||||
|
const pendingIndex = availableSlots.findIndex(s => s.start === pendingSlot);
|
||||||
|
const clickIndex = availableSlots.findIndex(s => s.start === slotStart);
|
||||||
|
|
||||||
|
if (clickIndex < pendingIndex) {
|
||||||
|
// Clicked before pending → Make clicked slot start, pending becomes end
|
||||||
|
setSelectedRange({ start: slotStart, end: pendingSlot });
|
||||||
|
} else {
|
||||||
|
// Clicked after pending → Pending is start, clicked is end
|
||||||
|
setSelectedRange({ start: pendingSlot, end: slotStart });
|
||||||
|
}
|
||||||
|
setPendingSlot(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No pending slot - check if we're modifying existing selection
|
||||||
|
if (selectedRange.start && selectedRange.end) {
|
||||||
|
const startIndex = availableSlots.findIndex(s => s.start === selectedRange.start);
|
||||||
|
const endIndex = availableSlots.findIndex(s => s.start === selectedRange.end);
|
||||||
|
const clickIndex = availableSlots.findIndex(s => s.start === slotStart);
|
||||||
|
|
||||||
|
// Clicked start time → Clear all
|
||||||
|
if (slotStart === selectedRange.start) {
|
||||||
|
setSelectedRange({ start: null, end: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicked end time → Remove end, keep start as pending
|
||||||
|
if (slotStart === selectedRange.end) {
|
||||||
|
setPendingSlot(selectedRange.start);
|
||||||
|
setSelectedRange({ start: null, end: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicked before start → New start, old start becomes end
|
||||||
|
if (clickIndex < startIndex) {
|
||||||
|
setSelectedRange({ start: slotStart, end: selectedRange.start });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicked after end → New end
|
||||||
|
if (clickIndex > endIndex) {
|
||||||
|
setSelectedRange({ start: selectedRange.start, end: slotStart });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicked within range → Update end to clicked slot
|
||||||
|
setSelectedRange({ start: selectedRange.start, end: slotStart });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No selection at all → Set as pending
|
||||||
|
setPendingSlot(slotStart);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalBlocks = selectedSlots.length;
|
// Calculate total blocks from range
|
||||||
|
const totalBlocks = getSlotsInRange.length;
|
||||||
const totalPrice = totalBlocks * (settings?.consulting_block_price || 0);
|
const totalPrice = totalBlocks * (settings?.consulting_block_price || 0);
|
||||||
const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30);
|
const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30);
|
||||||
|
|
||||||
@@ -168,7 +316,7 @@ export default function ConsultingBooking() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedSlots.length === 0) {
|
if (getSlotsInRange.length === 0) {
|
||||||
toast({ title: 'Pilih slot', description: 'Pilih minimal satu slot waktu', variant: 'destructive' });
|
toast({ title: 'Pilih slot', description: 'Pilih minimal satu slot waktu', variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -201,44 +349,80 @@ export default function ConsultingBooking() {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
payment_status: 'pending',
|
payment_status: 'pending',
|
||||||
payment_provider: 'pakasir',
|
payment_provider: 'pakasir',
|
||||||
|
payment_method: 'qris',
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (orderError) throw orderError;
|
if (orderError) throw orderError;
|
||||||
|
|
||||||
// Create consulting slots
|
// Create consulting session and time slots
|
||||||
const slotsToInsert = selectedSlots.map(slotStart => {
|
const firstSlotStart = getSlotsInRange[0];
|
||||||
|
const lastSlotEnd = format(
|
||||||
|
addMinutes(parse(getSlotsInRange[getSlotsInRange.length - 1], 'HH:mm', new Date()), settings.consulting_block_duration_minutes),
|
||||||
|
'HH:mm'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate session duration in minutes
|
||||||
|
const sessionDurationMinutes = totalBlocks * settings.consulting_block_duration_minutes;
|
||||||
|
|
||||||
|
// Create the session record (ONE row per booking)
|
||||||
|
const { data: session, error: sessionError } = await supabase
|
||||||
|
.from('consulting_sessions')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
order_id: order.id,
|
||||||
|
session_date: format(selectedDate, 'yyyy-MM-dd'),
|
||||||
|
start_time: firstSlotStart + ':00',
|
||||||
|
end_time: lastSlotEnd + ':00',
|
||||||
|
total_duration_minutes: sessionDurationMinutes,
|
||||||
|
topic_category: selectedCategory,
|
||||||
|
notes: notes,
|
||||||
|
status: 'pending_payment',
|
||||||
|
total_blocks: totalBlocks,
|
||||||
|
total_price: totalPrice,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (sessionError) throw sessionError;
|
||||||
|
|
||||||
|
// Create time slots for availability tracking (MULTIPLE rows per booking)
|
||||||
|
const timeSlotsToInsert = getSlotsInRange.map(slotStart => {
|
||||||
const slotEnd = format(
|
const slotEnd = format(
|
||||||
addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes),
|
addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes),
|
||||||
'HH:mm'
|
'HH:mm'
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
user_id: user.id,
|
session_id: session.id,
|
||||||
order_id: order.id,
|
slot_date: format(selectedDate, 'yyyy-MM-dd'),
|
||||||
date: format(selectedDate, 'yyyy-MM-dd'),
|
|
||||||
start_time: slotStart + ':00',
|
start_time: slotStart + ':00',
|
||||||
end_time: slotEnd + ':00',
|
end_time: slotEnd + ':00',
|
||||||
status: 'pending_payment',
|
is_available: false,
|
||||||
topic_category: selectedCategory,
|
booked_at: new Date().toISOString(),
|
||||||
notes: notes,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { error: slotsError } = await supabase.from('consulting_slots').insert(slotsToInsert);
|
const { error: timeSlotsError } = await supabase.from('consulting_time_slots').insert(timeSlotsToInsert);
|
||||||
if (slotsError) throw slotsError;
|
if (timeSlotsError) throw timeSlotsError;
|
||||||
|
|
||||||
// Add to cart for Pakasir checkout
|
// Call edge function to create payment with QR code
|
||||||
addItem({
|
const { data: paymentData, error: paymentError } = await supabase.functions.invoke('create-payment', {
|
||||||
id: `consulting-${order.id}`,
|
body: {
|
||||||
title: `Konsultasi 1-on-1 (${totalBlocks} blok)`,
|
order_id: order.id,
|
||||||
price: totalPrice,
|
amount: totalPrice,
|
||||||
sale_price: null,
|
description: `Konsultasi 1-on-1 (${totalBlocks} blok)`,
|
||||||
type: 'consulting',
|
method: 'qris',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({ title: 'Berhasil', description: 'Silakan lanjutkan ke pembayaran' });
|
if (paymentError) {
|
||||||
navigate('/checkout');
|
console.error('Payment creation error:', paymentError);
|
||||||
|
throw new Error(paymentError.message || 'Gagal membuat pembayaran');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to order detail page to show QR code
|
||||||
|
navigate(`/orders/${order.id}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -257,6 +441,26 @@ export default function ConsultingBooking() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require authentication to access consulting booking
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-16 text-center">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<Video className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Login Diperlukan</h1>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Anda harus login untuk memesan jadwal konsultasi.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/auth')} size="lg">
|
||||||
|
Login Sekarang
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!settings?.is_consulting_enabled) {
|
if (!settings?.is_consulting_enabled) {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -312,7 +516,12 @@ export default function ConsultingBooking() {
|
|||||||
Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })}
|
Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Klik slot untuk memilih. {settings.consulting_block_duration_minutes} menit per blok.
|
Klik satu slot untuk memilih, klik lagi untuk konfirmasi. Atau klik dua slot berbeda untuk rentang waktu. {settings.consulting_block_duration_minutes} menit per blok.
|
||||||
|
{webinars.length > 0 && (
|
||||||
|
<span className="block mt-1 text-amber-600 dark:text-amber-400">
|
||||||
|
⚠️ {webinars.length} webinar terjadwal - beberapa slot mungkin tidak tersedia
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -321,18 +530,54 @@ export default function ConsultingBooking() {
|
|||||||
Tidak ada slot tersedia pada hari ini
|
Tidak ada slot tersedia pada hari ini
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-0">
|
||||||
{availableSlots.map((slot) => (
|
{availableSlots.map((slot, index) => {
|
||||||
<Button
|
const isSelected = getSlotsInRange.includes(slot.start);
|
||||||
key={slot.start}
|
const isPending = slot.start === pendingSlot;
|
||||||
variant={selectedSlots.includes(slot.start) ? 'default' : 'outline'}
|
const isStart = slot.start === selectedRange.start;
|
||||||
disabled={!slot.available}
|
const isEnd = slot.start === selectedRange.end;
|
||||||
onClick={() => slot.available && toggleSlot(slot.start)}
|
const isMiddle = isSelected && !isStart && !isEnd;
|
||||||
className="border-2"
|
|
||||||
>
|
// Determine button variant
|
||||||
{slot.start}
|
let variant: "default" | "outline" = "outline";
|
||||||
</Button>
|
if (isSelected) variant = "default";
|
||||||
))}
|
|
||||||
|
// Determine border radius for seamless connection
|
||||||
|
let className = "border-2 h-10";
|
||||||
|
|
||||||
|
// Add special styling for pending slot
|
||||||
|
if (isPending) {
|
||||||
|
className += " bg-amber-500 hover:bg-amber-600 text-white border-amber-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStart) {
|
||||||
|
// First selected slot - right side should connect
|
||||||
|
className += index < availableSlots.length - 1 && availableSlots[index + 1]?.start === getSlotsInRange[1]
|
||||||
|
? " rounded-r-none border-r-0"
|
||||||
|
: "";
|
||||||
|
} else if (isEnd) {
|
||||||
|
// Last selected slot - left side should connect
|
||||||
|
className += " rounded-l-none border-l-0";
|
||||||
|
} else if (isMiddle) {
|
||||||
|
// Middle slot - seamless
|
||||||
|
className += " rounded-none border-x-0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={slot.start}
|
||||||
|
variant={isPending ? "default" : variant}
|
||||||
|
disabled={!slot.available}
|
||||||
|
onClick={() => slot.available && handleSlotClick(slot.start)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{isPending && <span className="text-xs opacity-70">Pilih</span>}
|
||||||
|
{isStart && !isPending && <span className="text-xs opacity-70">Mulai</span>}
|
||||||
|
{!isPending && !isStart && !isEnd && slot.start}
|
||||||
|
{isEnd && !isPending && <span className="text-xs opacity-70">Selesai</span>}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -406,28 +651,58 @@ export default function ConsultingBooking() {
|
|||||||
{selectedDate ? format(selectedDate, 'd MMM yyyy', { locale: id }) : '-'}
|
{selectedDate ? format(selectedDate, 'd MMM yyyy', { locale: id }) : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Jumlah Blok</span>
|
|
||||||
<span className="font-medium">{totalBlocks} blok</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Total Durasi</span>
|
|
||||||
<span className="font-medium">{totalDuration} menit</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Kategori</span>
|
<span className="text-muted-foreground">Kategori</span>
|
||||||
<span className="font-medium">{selectedCategory || '-'}</span>
|
<span className="font-medium">{selectedCategory || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedSlots.length > 0 && (
|
{selectedRange.start && selectedRange.end && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Waktu dipilih:</p>
|
||||||
|
|
||||||
|
{/* Show range */}
|
||||||
|
<div className="bg-primary/10 p-3 rounded-lg border-2 border-primary/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Mulai</p>
|
||||||
|
<p className="font-bold text-lg">{selectedRange.start}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl">→</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{totalBlocks} blok</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-muted-foreground">Selesai</p>
|
||||||
|
<p className="font-bold text-lg">
|
||||||
|
{(() => {
|
||||||
|
const start = parse(selectedRange.end, 'HH:mm', new Date());
|
||||||
|
const end = addMinutes(start, settings?.consulting_block_duration_minutes || 30);
|
||||||
|
return format(end, 'HH:mm');
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm mt-2 text-primary font-medium">
|
||||||
|
{totalDuration} menit ({formatIDR(totalPrice)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingSlot && !selectedRange.start && (
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
<p className="text-sm text-muted-foreground mb-2">Slot dipilih:</p>
|
<p className="text-sm text-muted-foreground mb-2">Slot dipilih:</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedSlots.sort().map((slot) => (
|
{/* Show pending slot */}
|
||||||
<span key={slot} className="px-2 py-1 bg-primary/10 text-primary rounded text-sm">
|
<div className="bg-amber-500/10 p-3 rounded-lg border-2 border-amber-500/20">
|
||||||
{slot}
|
<div className="text-center">
|
||||||
</span>
|
<p className="text-xs text-muted-foreground">Klik lagi untuk konfirmasi, atau pilih slot lain</p>
|
||||||
))}
|
<p className="font-bold text-lg text-amber-600">{pendingSlot}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">1 blok = {settings.consulting_block_duration_minutes} menit ({formatIDR(settings.consulting_block_price)})</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -444,7 +719,7 @@ export default function ConsultingBooking() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBookNow}
|
onClick={handleBookNow}
|
||||||
disabled={submitting || selectedSlots.length === 0 || !selectedCategory}
|
disabled={submitting || getSlotsInRange.length === 0 || !selectedCategory}
|
||||||
className="w-full shadow-sm"
|
className="w-full shadow-sm"
|
||||||
>
|
>
|
||||||
{submitting ? 'Memproses...' : 'Booking Sekarang'}
|
{submitting ? 'Memproses...' : 'Booking Sekarang'}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { formatIDR, formatDate } from '@/lib/format';
|
import { formatIDR, formatDate } from '@/lib/format';
|
||||||
import { Video, Calendar, BookOpen, ArrowRight } from 'lucide-react';
|
import { Video, Calendar, BookOpen, ArrowRight } from 'lucide-react';
|
||||||
|
import { getPaymentStatusLabel, getPaymentStatusColor } from '@/lib/statusHelpers';
|
||||||
|
|
||||||
interface UserAccess {
|
interface UserAccess {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,24 +57,6 @@ export default function Dashboard() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'paid': return 'bg-accent';
|
|
||||||
case 'pending': return 'bg-secondary';
|
|
||||||
case 'cancelled': return 'bg-destructive';
|
|
||||||
default: return 'bg-secondary';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentStatusLabel = (status: string | null) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'paid': return 'Lunas';
|
|
||||||
case 'pending': return 'Menunggu Pembayaran';
|
|
||||||
case 'failed': return 'Gagal';
|
|
||||||
default: return status || 'Pending';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAccessActions = (item: UserAccess) => {
|
const renderAccessActions = (item: UserAccess) => {
|
||||||
switch (item.product.type) {
|
switch (item.product.type) {
|
||||||
case 'consulting':
|
case 'consulting':
|
||||||
@@ -97,11 +80,10 @@ export default function Dashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.product.recording_url && (
|
{item.product.recording_url && (
|
||||||
<Button asChild variant="outline" className="border-2">
|
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
||||||
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
|
<Video className="w-4 h-4 mr-2" />
|
||||||
<Video className="w-4 h-4 mr-2" />
|
Tonton Rekaman
|
||||||
Tonton Rekaman
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +146,7 @@ export default function Dashboard() {
|
|||||||
<CardTitle>{item.product.title}</CardTitle>
|
<CardTitle>{item.product.title}</CardTitle>
|
||||||
<CardDescription className="capitalize">{item.product.type}</CardDescription>
|
<CardDescription className="capitalize">{item.product.type}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge className="bg-accent">Aktif</Badge>
|
<Badge className="bg-brand-accent text-white rounded-full">Aktif</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -195,7 +177,7 @@ export default function Dashboard() {
|
|||||||
<p className="text-sm text-muted-foreground">{formatDate(order.created_at)}</p>
|
<p className="text-sm text-muted-foreground">{formatDate(order.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Badge className={getStatusColor(order.payment_status || order.status)}>
|
<Badge className={`${getPaymentStatusColor(order.payment_status || order.status)} rounded-full`}>
|
||||||
{getPaymentStatusLabel(order.payment_status || order.status)}
|
{getPaymentStatusLabel(order.payment_status || order.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ import { AppLayout } from '@/components/AppLayout';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { formatIDR, formatDuration } from '@/lib/format';
|
import { formatIDR, formatDuration } from '@/lib/format';
|
||||||
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight, Star, CheckCircle } from 'lucide-react';
|
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight, Star, CheckCircle, Lock, User } from 'lucide-react';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||||
import { ProductReviews } from '@/components/reviews/ProductReviews';
|
import { ProductReviews } from '@/components/reviews/ProductReviews';
|
||||||
|
import { useOwnerIdentity } from '@/hooks/useOwnerIdentity';
|
||||||
|
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,9 +29,14 @@ interface Product {
|
|||||||
sale_price: number | null;
|
sale_price: number | null;
|
||||||
meeting_link: string | null;
|
meeting_link: string | null;
|
||||||
recording_url: string | null;
|
recording_url: string | null;
|
||||||
|
m3u8_url: string | null;
|
||||||
|
mp4_url: string | null;
|
||||||
|
video_host: 'youtube' | 'adilo' | 'unknown' | null;
|
||||||
event_start: string | null;
|
event_start: string | null;
|
||||||
duration_minutes: number | null;
|
duration_minutes: number | null;
|
||||||
|
chapters?: { time: number; title: string; }[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
collaborator_user_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
@@ -43,6 +51,16 @@ interface Lesson {
|
|||||||
title: string;
|
title: string;
|
||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
position: number;
|
position: number;
|
||||||
|
chapters?: { time: number; title: string; }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductDetail() {
|
export default function ProductDetail() {
|
||||||
@@ -54,10 +72,13 @@ export default function ProductDetail() {
|
|||||||
const [hasAccess, setHasAccess] = useState(false);
|
const [hasAccess, setHasAccess] = useState(false);
|
||||||
const [checkingAccess, setCheckingAccess] = useState(true);
|
const [checkingAccess, setCheckingAccess] = useState(true);
|
||||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
||||||
const [hasReviewed, setHasReviewed] = useState(false);
|
const [expandedLessonChapters, setExpandedLessonChapters] = useState<Set<string>>(new Set());
|
||||||
|
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||||
|
const [collaborator, setCollaborator] = useState<{ name: string; avatar_url: string | null } | null>(null);
|
||||||
const { addItem, items } = useCart();
|
const { addItem, items } = useCart();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { owner } = useOwnerIdentity();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (slug) fetchProduct();
|
if (slug) fetchProduct();
|
||||||
@@ -78,6 +99,28 @@ export default function ProductDetail() {
|
|||||||
}
|
}
|
||||||
}, [product]);
|
}, [product]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCollaborator = async () => {
|
||||||
|
if (!product?.collaborator_user_id) {
|
||||||
|
setCollaborator(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('name, avatar_url')
|
||||||
|
.eq('id', product.collaborator_user_id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
setCollaborator({
|
||||||
|
name: data?.name || 'Builder',
|
||||||
|
avatar_url: data?.avatar_url || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchCollaborator();
|
||||||
|
}, [product?.collaborator_user_id]);
|
||||||
|
|
||||||
const fetchProduct = async () => {
|
const fetchProduct = async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
@@ -107,7 +150,8 @@ export default function ProductDetail() {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
position
|
position,
|
||||||
|
chapters
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('product_id', product.id)
|
.eq('product_id', product.id)
|
||||||
@@ -123,6 +167,9 @@ export default function ProductDetail() {
|
|||||||
if (sorted.length > 0) {
|
if (sorted.length > 0) {
|
||||||
setExpandedModules(new Set([sorted[0].id]));
|
setExpandedModules(new Set([sorted[0].id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep all lesson timelines collapsed by default for cleaner UX
|
||||||
|
setExpandedLessonChapters(new Set());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,12 +212,17 @@ export default function ProductDetail() {
|
|||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('reviews')
|
.from('reviews')
|
||||||
.select('id')
|
.select('id, rating, title, body, is_approved, created_at')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.eq('product_id', product.id)
|
.eq('product_id', product.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
setHasReviewed(!!(data && data.length > 0));
|
if (data && data.length > 0) {
|
||||||
|
setUserReview(data[0] as UserReview);
|
||||||
|
} else {
|
||||||
|
setUserReview(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if webinar has ended (eligible for review)
|
// Check if webinar has ended (eligible for review)
|
||||||
@@ -182,6 +234,17 @@ export default function ProductDetail() {
|
|||||||
return new Date() > eventEnd;
|
return new Date() > eventEnd;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if webinar is currently running or about to start (can join)
|
||||||
|
const isWebinarJoinable = () => {
|
||||||
|
if (!product || product.type !== 'webinar' || !product.event_start) return false;
|
||||||
|
const eventStart = new Date(product.event_start);
|
||||||
|
const durationMs = (product.duration_minutes || 60) * 60 * 1000;
|
||||||
|
const eventEnd = new Date(eventStart.getTime() + durationMs);
|
||||||
|
const now = new Date();
|
||||||
|
// Can join if webinar hasn't ended yet (even if it's already started)
|
||||||
|
return now <= eventEnd;
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
const handleAddToCart = () => {
|
||||||
if (!product) return;
|
if (!product) return;
|
||||||
addItem({ id: product.id, title: product.title, price: product.price, sale_price: product.sale_price, type: product.type });
|
addItem({ id: product.id, title: product.title, price: product.price, sale_price: product.sale_price, type: product.type });
|
||||||
@@ -190,6 +253,53 @@ export default function ProductDetail() {
|
|||||||
|
|
||||||
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
||||||
|
|
||||||
|
const formatChapterTime = (seconds: number) => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLastTimelineItem = (length: number, chapterIndex: number)=> {
|
||||||
|
const calcLength = length - 1;
|
||||||
|
return calcLength !== chapterIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderWebinarChapters = () => {
|
||||||
|
if (product?.type !== 'webinar' || !product.chapters || product.chapters.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<h3 className="text-xl font-bold mb-4">Daftar isi Webinar</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{product.chapters.map((chapter, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg transition-colors cursor-not-allowed opacity-75"
|
||||||
|
title="Beli webinar untuk mengakses konten ini"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-12 text-center">
|
||||||
|
<span className="text-sm font-mono text-muted-foreground">
|
||||||
|
{formatChapterTime(chapter.time)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{chapter.title}</p>
|
||||||
|
</div>
|
||||||
|
<Lock className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getVideoEmbed = (url: string) => {
|
const getVideoEmbed = (url: string) => {
|
||||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||||
@@ -210,6 +320,22 @@ export default function ProductDetail() {
|
|||||||
setExpandedModules(newSet);
|
setExpandedModules(newSet);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleLessonChapters = (lessonId: string) => {
|
||||||
|
const newSet = new Set(expandedLessonChapters);
|
||||||
|
if (newSet.has(lessonId)) {
|
||||||
|
newSet.delete(lessonId);
|
||||||
|
} else {
|
||||||
|
newSet.add(lessonId);
|
||||||
|
}
|
||||||
|
setExpandedLessonChapters(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if product has any recording (YouTube, M3U8, or MP4)
|
||||||
|
const hasRecording = () => {
|
||||||
|
if (!product) return false;
|
||||||
|
return !!(product.recording_url || product.m3u8_url || product.mp4_url);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (<AppLayout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/2 mb-4" /><Skeleton className="h-6 w-1/4 mb-8" /><Skeleton className="h-64 w-full" /></div></AppLayout>);
|
return (<AppLayout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/2 mb-4" /><Skeleton className="h-6 w-1/4 mb-8" /><Skeleton className="h-64 w-full" /></div></AppLayout>);
|
||||||
}
|
}
|
||||||
@@ -240,34 +366,50 @@ export default function ProductDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
case 'webinar':
|
case 'webinar':
|
||||||
if (product.recording_url) {
|
if (hasRecording()) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
<Card className="border-2 border-primary/20 bg-primary/5">
|
||||||
<iframe
|
<CardContent className="pt-6">
|
||||||
src={getVideoEmbed(product.recording_url)}
|
<div className="flex items-start gap-4">
|
||||||
className="w-full h-full"
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
<Play className="w-6 h-6 text-primary" />
|
||||||
allowFullScreen
|
</div>
|
||||||
/>
|
<div className="flex-1">
|
||||||
</div>
|
<h3 className="font-semibold text-lg mb-1">Rekaman webinar tersedia</h3>
|
||||||
<Button asChild variant="outline" className="border-2">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
<a href={product.recording_url} target="_blank" rel="noopener noreferrer">
|
Akses rekaman webinar kapan saja. Pelajari materi sesuai kecepatan Anda.
|
||||||
<Video className="w-4 h-4 mr-2" />
|
</p>
|
||||||
Tonton Rekaman
|
<Button onClick={() => navigate(`/webinar/${product.slug}`)} size="lg">
|
||||||
</a>
|
<Video className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Tonton Sekarang
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return product.meeting_link ? (
|
|
||||||
<Button asChild size="lg" className="shadow-sm">
|
// Show "Gabung Webinar" if webinar hasn't ended yet (can join even if already started)
|
||||||
<a href={product.meeting_link} target="_blank" rel="noopener noreferrer">
|
if (isWebinarJoinable() && product.meeting_link) {
|
||||||
<Video className="w-4 h-4 mr-2" />
|
return (
|
||||||
Gabung Webinar
|
<Button asChild size="lg" className="shadow-sm">
|
||||||
</a>
|
<a href={product.meeting_link} target="_blank" rel="noopener noreferrer">
|
||||||
</Button>
|
<Video className="w-4 h-4 mr-2" />
|
||||||
) : <Badge className="bg-secondary">Rekaman segera tersedia</Badge>;
|
Gabung Webinar
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webinar has ended but no recording yet
|
||||||
|
if (isWebinarEnded()) {
|
||||||
|
return <Badge className="bg-muted text-primary">Rekaman segera tersedia</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
case 'bootcamp':
|
case 'bootcamp':
|
||||||
return (
|
return (
|
||||||
<Button onClick={() => navigate(`/bootcamp/${product.slug}`)} size="lg" className="shadow-sm">
|
<Button onClick={() => navigate(`/bootcamp/${product.slug}`)} size="lg" className="shadow-sm">
|
||||||
@@ -316,15 +458,55 @@ export default function ProductDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-2">
|
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-3">
|
||||||
{module.lessons.map((lesson) => (
|
{module.lessons.map((lesson) => (
|
||||||
<div key={lesson.id} className="flex items-center justify-between py-1 text-sm">
|
<div key={lesson.id} className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
{/* Lesson header */}
|
||||||
<Play className="w-3 h-3 text-muted-foreground" />
|
<div className="flex items-center justify-between py-1 text-sm">
|
||||||
<span>{lesson.title}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{lesson.title}</span>
|
||||||
|
</div>
|
||||||
|
{lesson.duration_seconds && (
|
||||||
|
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{lesson.duration_seconds && (
|
|
||||||
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
{/* Lesson chapters (if any) */}
|
||||||
|
{lesson.chapters && lesson.chapters.length > 0 && (
|
||||||
|
<Collapsible
|
||||||
|
open={expandedLessonChapters.has(lesson.id)}
|
||||||
|
onOpenChange={() => toggleLessonChapters(lesson.id)}
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-2 ml-5 mb-2 py-1 px-2 text-xs bg-muted text-muted-foreground hover:bg-accent rounded transition-colors w-full">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span className="flex-1 text-left">
|
||||||
|
{lesson.chapters.length} timeline item{lesson.chapters.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{expandedLessonChapters.has(lesson.id) ? (
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="ml-5 space-y-1">
|
||||||
|
{lesson.chapters.map((chapter, chapterIndex) => (
|
||||||
|
<div
|
||||||
|
key={chapterIndex}
|
||||||
|
className={`flex items-start gap-2 py-1 px-2 text-xs text-muted-foreground rounded transition-colors cursor-not-allowed opacity-60${isLastTimelineItem(lesson.chapters.length, chapterIndex) ? ' border-b-2 border-[#dedede] rounded-none' : ''}`}
|
||||||
|
title="Beli bootcamp untuk mengakses materi ini"
|
||||||
|
>
|
||||||
|
<span className="font-mono w-12 text-center">
|
||||||
|
{formatChapterTime(chapter.time)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1" dangerouslySetInnerHTML={{ __html: chapter.title }} />
|
||||||
|
<Lock className="w-3 h-3 flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -342,12 +524,81 @@ export default function ProductDetail() {
|
|||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Ownership Banner - shown at top for purchased users */}
|
||||||
|
{hasAccess && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-950 border-2 border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-900 dark:text-green-100">
|
||||||
|
Anda memiliki akses ke produk ini
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-300">
|
||||||
|
{product.type === 'webinar' && 'Selamat menonton rekaman webinar!'}
|
||||||
|
{product.type === 'bootcamp' && 'Mulai belajar sekarang!'}
|
||||||
|
{product.type === 'consulting' && 'Jadwalkan sesi konsultasi Anda.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (product.type === 'webinar') {
|
||||||
|
navigate(`/webinar/${product.slug}`);
|
||||||
|
} else if (product.type === 'bootcamp') {
|
||||||
|
navigate(`/bootcamp/${product.slug}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white shadow-sm"
|
||||||
|
>
|
||||||
|
Tonton Sekarang →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge className="bg-secondary capitalize">{product.type}</Badge>
|
<Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge>
|
||||||
{hasAccess && <Badge className="bg-accent">Anda memiliki akses</Badge>}
|
{product.collaborator_user_id && <Badge variant="secondary">Collab</Badge>}
|
||||||
|
{product.type === 'webinar' && hasRecording() && (
|
||||||
|
<Badge className="bg-secondary text-primary">Rekaman Tersedia</Badge>
|
||||||
|
)}
|
||||||
|
{product.type === 'webinar' && !hasRecording() && product.event_start && new Date(product.event_start) > new Date() && (
|
||||||
|
<Badge className="bg-brand-accent text-white">Segera Hadir</Badge>
|
||||||
|
)}
|
||||||
|
{product.type === 'webinar' && !hasRecording() && product.event_start && new Date(product.event_start) <= new Date() && (
|
||||||
|
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
{product.collaborator_user_id ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
<Avatar className="h-8 w-8 border-2 border-background">
|
||||||
|
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
|
||||||
|
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar className="h-8 w-8 border-2 border-background">
|
||||||
|
<AvatarImage src={resolveAvatarUrl(collaborator?.avatar_url) || undefined} alt={collaborator?.name || 'Builder'} />
|
||||||
|
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
Hosted by {owner.owner_name} • with {collaborator?.name || 'Builder'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Avatar className="h-8 w-8 border border-border">
|
||||||
|
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
|
||||||
|
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>Hosted by {owner.owner_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
@@ -387,20 +638,67 @@ export default function ProductDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderWebinarChapters()}
|
||||||
|
|
||||||
<div className="flex gap-4 flex-wrap">
|
<div className="flex gap-4 flex-wrap">
|
||||||
{renderActionButtons()}
|
{renderActionButtons()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Webinar review prompt */}
|
{/* Webinar review prompt */}
|
||||||
{hasAccess && product.type === 'webinar' && isWebinarEnded() && (
|
{hasAccess && product.type === 'webinar' && isWebinarEnded() && (
|
||||||
<Card className="border-2 border-primary/20 mt-6">
|
<Card className={`border-2 mt-6 ${userReview?.is_approved ? 'bg-gradient-to-br from-brand-accent/10 to-primary/10 border-brand-accent/30' : 'border-primary/20'}`}>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-6">
|
||||||
{hasReviewed ? (
|
{userReview ? (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
userReview.is_approved ? (
|
||||||
<CheckCircle className="w-5 h-5 text-accent" />
|
// Approved review - celebratory display
|
||||||
<span>Terima kasih atas ulasan Anda (menunggu moderasi)</span>
|
<div className="space-y-4">
|
||||||
</div>
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-full bg-brand-accent p-2">
|
||||||
|
<CheckCircle className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-lg font-bold">Ulasan Anda Terbit!</h3>
|
||||||
|
<Badge className="bg-brand-accent text-white rounded-full">Disetujui</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Terima kasih telah berbagi pengalaman Anda. Ulasan Anda membantu peserta lain!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User's review display */}
|
||||||
|
<div className="bg-background/50 backdrop-blur rounded-lg p-4 border border-brand-accent/20">
|
||||||
|
<div className="flex gap-0.5 mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`w-5 h-5 ${i <= userReview.rating ? 'fill-brand-accent text-brand-accent' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-base mb-1">{userReview.title}</h4>
|
||||||
|
{userReview.body && (
|
||||||
|
<p className="text-sm text-muted-foreground">{userReview.body}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Diterbitkan pada {new Date(userReview.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Pending review
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<div className="rounded-full bg-amber-500/10 p-2">
|
||||||
|
<Clock className="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Ulasan Anda sedang ditinjau</p>
|
||||||
|
<p className="text-sm">Terima kasih! Ulasan akan muncul setelah disetujui admin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
|
// No review yet - prompt to review
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Bagaimana pengalaman webinar ini?</p>
|
<p className="font-medium">Bagaimana pengalaman webinar ini?</p>
|
||||||
@@ -432,7 +730,7 @@ export default function ProductDetail() {
|
|||||||
productId={product.id}
|
productId={product.id}
|
||||||
type="webinar"
|
type="webinar"
|
||||||
contextLabel={product.title}
|
contextLabel={product.title}
|
||||||
onSuccess={() => setHasReviewed(true)}
|
onSuccess={() => checkUserReview()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { AppLayout } from '@/components/AppLayout';
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { formatIDR } from '@/lib/format';
|
import { formatIDR } from '@/lib/format';
|
||||||
import { Video } from 'lucide-react';
|
import { Video, Package, Check, Search, X, User } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useOwnerIdentity } from '@/hooks/useOwnerIdentity';
|
||||||
|
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +24,13 @@ interface Product {
|
|||||||
price: number;
|
price: number;
|
||||||
sale_price: number | null;
|
sale_price: number | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
collaborator_user_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollaboratorProfile {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConsultingSettings {
|
interface ConsultingSettings {
|
||||||
@@ -32,7 +43,11 @@ export default function Products() {
|
|||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [consultingSettings, setConsultingSettings] = useState<ConsultingSettings | null>(null);
|
const [consultingSettings, setConsultingSettings] = useState<ConsultingSettings | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedType, setSelectedType] = useState<string>('all');
|
||||||
|
const [collaborators, setCollaborators] = useState<Record<string, CollaboratorProfile>>({});
|
||||||
const { addItem, items } = useCart();
|
const { addItem, items } = useCart();
|
||||||
|
const { owner } = useOwnerIdentity();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -54,7 +69,33 @@ export default function Products() {
|
|||||||
if (productsRes.error) {
|
if (productsRes.error) {
|
||||||
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
|
||||||
} else {
|
} else {
|
||||||
setProducts(productsRes.data || []);
|
const productsData = productsRes.data || [];
|
||||||
|
setProducts(productsData);
|
||||||
|
|
||||||
|
const collaboratorIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
productsData
|
||||||
|
.map((p) => p.collaborator_user_id)
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collaboratorIds.length > 0) {
|
||||||
|
const { data: collaboratorRows } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, name, avatar_url')
|
||||||
|
.in('id', collaboratorIds);
|
||||||
|
|
||||||
|
if (collaboratorRows) {
|
||||||
|
const byId = collaboratorRows.reduce<Record<string, CollaboratorProfile>>((acc, row) => {
|
||||||
|
acc[row.id] = row;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
setCollaborators(byId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCollaborators({});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (consultingRes.data) {
|
if (consultingRes.data) {
|
||||||
@@ -93,11 +134,84 @@ export default function Products() {
|
|||||||
return tmp.textContent || tmp.innerText || '';
|
return tmp.textContent || tmp.innerText || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter products based on search and type
|
||||||
|
const filteredProducts = products.filter((product) => {
|
||||||
|
const matchesSearch = product.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
stripHtml(product.description).toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesType = selectedType === 'all' || product.type === selectedType;
|
||||||
|
return matchesSearch && matchesType;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get unique product types for filter
|
||||||
|
const productTypes: string[] = ['all', ...Array.from(new Set(products.map(p => p.type as string)))];
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedType('all');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-4xl font-bold mb-2">Produk</h1>
|
<h1 className="text-4xl font-bold mb-2">Produk</h1>
|
||||||
<p className="text-muted-foreground mb-8">Jelajahi konsultasi, webinar, dan bootcamp kami</p>
|
<p className="text-muted-foreground mb-4">Jelajahi konsultasi, webinar, dan bootcamp kami</p>
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
{!loading && products.length > 0 && (
|
||||||
|
<div className="mb-6 space-y-4">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari produk..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-2"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Kategori:</span>
|
||||||
|
{productTypes.map((type) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant={selectedType === type ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedType(type)}
|
||||||
|
className={selectedType === type ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
{type === 'all' ? 'Semua' : getTypeLabel(type)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{(searchQuery || selectedType !== 'all') && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Count */}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Menampilkan {filteredProducts.length} dari {products.length} produk
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -114,34 +228,37 @@ export default function Products() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
{/* Consulting Card - Only show when enabled */}
|
{/* Consulting Card - Only show when enabled */}
|
||||||
{consultingSettings?.is_consulting_enabled && (
|
{consultingSettings?.is_consulting_enabled && (
|
||||||
<Card className="border-2 border-primary shadow-sm hover:shadow-md transition-shadow bg-primary/5">
|
<Card className="border-2 border-primary shadow-md hover:shadow-lg transition-all bg-gradient-to-br from-primary/10 to-primary/5 relative overflow-hidden h-full flex flex-col">
|
||||||
<CardHeader>
|
{/* Decorative element */}
|
||||||
<div className="flex justify-between items-start">
|
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
|
||||||
<Video className="w-5 h-5" />
|
<CardHeader className="relative pb-4">
|
||||||
|
<div className="flex justify-between items-start gap-2 mb-2">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2 line-clamp-1">
|
||||||
|
<Video className="w-5 h-5 text-primary shrink-0" />
|
||||||
Konsultasi 1-on-1
|
Konsultasi 1-on-1
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge className="bg-primary">Konsultasi</Badge>
|
<Badge variant="default" className="shrink-0">
|
||||||
|
Konsultasi
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="line-clamp-2">
|
<CardDescription className="line-clamp-2">
|
||||||
Sesi konsultasi pribadi dengan mentor. Pilih waktu dan durasi sesuai kebutuhan Anda.
|
Sesi konsultasi pribadi dengan mentor. Diskusikan masalah spesifik dan dapatkan solusi langsung.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="relative flex-1 flex flex-col justify-end">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-baseline gap-2 mb-4">
|
||||||
<span className="text-2xl font-bold">
|
<span className="text-3xl font-bold text-primary">
|
||||||
{formatIDR(consultingSettings.consulting_block_price)}
|
{formatIDR(consultingSettings.consulting_block_price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">/ sesi</span>
|
||||||
/ {consultingSettings.consulting_block_duration_minutes} menit
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Link to="/consulting">
|
<Link to="/consulting">
|
||||||
<Button className="w-full shadow-sm">
|
<Button className="w-full shadow-md hover:shadow-lg transition-shadow">
|
||||||
Booking Sekarang
|
Booking Jadwal
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -149,48 +266,109 @@ export default function Products() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Regular Products */}
|
{/* Regular Products */}
|
||||||
{products.map((product) => (
|
{filteredProducts.map((product: Product) => (
|
||||||
<Card key={product.id} className="border-2 border-border shadow-sm hover:shadow-md transition-shadow">
|
<Card key={product.id} className="border-2 border-border shadow-sm hover:shadow-md transition-shadow h-full flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader className="pb-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start gap-2 mb-2">
|
||||||
<CardTitle className="text-xl">{product.title}</CardTitle>
|
<CardTitle className="text-xl line-clamp-2 leading-tight min-h-[3rem]">{product.title}</CardTitle>
|
||||||
<Badge className="bg-secondary">{getTypeLabel(product.type)}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge className="shrink-0">{getTypeLabel(product.type)}</Badge>
|
||||||
|
{product.collaborator_user_id && <Badge variant="secondary">Collab</Badge>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription
|
<div className="mb-2">
|
||||||
className="line-clamp-2"
|
{product.collaborator_user_id ? (
|
||||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
/>
|
<div className="flex -space-x-2">
|
||||||
|
<Avatar className="h-7 w-7 border-2 border-background">
|
||||||
|
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
|
||||||
|
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar className="h-7 w-7 border-2 border-background">
|
||||||
|
<AvatarImage src={resolveAvatarUrl(collaborators[product.collaborator_user_id]?.avatar_url) || undefined} alt={collaborators[product.collaborator_user_id]?.name || 'Collaborator'} />
|
||||||
|
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{owner.owner_name} (Host) • {(collaborators[product.collaborator_user_id]?.name || 'Builder')} (Builder)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Avatar className="h-7 w-7 border border-border">
|
||||||
|
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
|
||||||
|
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{owner.owner_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{stripHtml(product.description)}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 flex flex-col justify-end">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-baseline gap-2 mb-4">
|
||||||
{product.sale_price ? (
|
{product.sale_price ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-2xl font-bold">{formatIDR(product.sale_price)}</span>
|
<span className="text-3xl font-bold text-primary">
|
||||||
<span className="text-muted-foreground line-through">{formatIDR(product.price)}</span>
|
{formatIDR(product.sale_price)}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg text-muted-foreground line-through">
|
||||||
|
{formatIDR(product.price)}
|
||||||
|
</span>
|
||||||
|
<Badge variant="destructive" className="ml-2">
|
||||||
|
-{Math.round((1 - product.sale_price / product.price) * 100)}%
|
||||||
|
</Badge>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-2xl font-bold">{formatIDR(product.price)}</span>
|
<span className="text-3xl font-bold">{formatIDR(product.price)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link to={`/products/${product.slug}`} className="flex-1">
|
<Link to={`/products/${product.slug}`} className="flex-1">
|
||||||
<Button variant="outline" className="w-full border-2">Lihat Detail</Button>
|
<Button variant="outline" size="default" className="w-full border-2">
|
||||||
|
Lihat Detail
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleAddToCart(product)}
|
onClick={() => handleAddToCart(product)}
|
||||||
disabled={isInCart(product.id)}
|
disabled={isInCart(product.id)}
|
||||||
className="shadow-xs"
|
size="default"
|
||||||
|
className={isInCart(product.id)
|
||||||
|
? "bg-green-500 hover:bg-green-600 text-white"
|
||||||
|
: "shadow-sm"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isInCart(product.id) ? 'Di Keranjang' : 'Tambah'}
|
{isInCart(product.id) ? (
|
||||||
|
<><Check className="w-4 h-4 mr-1" /> Di Keranjang</>
|
||||||
|
) : (
|
||||||
|
"Tambah"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredProducts.length === 0 && products.length > 0 && (
|
||||||
|
<div className="col-span-full text-center py-16">
|
||||||
|
<Search className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">Tidak Ada Produk Ditemukan</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">Coba kata kunci atau kategori lain.</p>
|
||||||
|
<Button onClick={clearFilters} variant="outline">
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{products.length === 0 && !consultingSettings?.is_consulting_enabled && (
|
{products.length === 0 && !consultingSettings?.is_consulting_enabled && (
|
||||||
<div className="col-span-full text-center py-12">
|
<div className="col-span-full text-center py-16">
|
||||||
<p className="text-muted-foreground">Belum ada produk tersedia.</p>
|
<Package className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">Belum Ada Produk</h3>
|
||||||
|
<p className="text-muted-foreground">Kami sedang mempersiapkan produk menarik untuk Anda.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
374
src/pages/WebinarRecording.tsx
Normal file
374
src/pages/WebinarRecording.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useVideoProgress } from '@/hooks/useVideoProgress';
|
||||||
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { ChevronLeft, Play, Star, Clock, CheckCircle } from 'lucide-react';
|
||||||
|
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
|
||||||
|
import { TimelineChapters } from '@/components/TimelineChapters';
|
||||||
|
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
recording_url: string | null;
|
||||||
|
m3u8_url?: string | null;
|
||||||
|
mp4_url?: string | null;
|
||||||
|
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||||
|
description: string | null;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebinarRecording() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [accentColor, setAccentColor] = useState<string>('');
|
||||||
|
const [hasPurchased, setHasPurchased] = useState(false);
|
||||||
|
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||||
|
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||||
|
const playerRef = useRef<VideoPlayerRef>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
navigate('/auth');
|
||||||
|
} else if (user && slug) {
|
||||||
|
checkAccessAndFetch();
|
||||||
|
}
|
||||||
|
}, [user, authLoading, slug]);
|
||||||
|
|
||||||
|
const checkAccessAndFetch = async () => {
|
||||||
|
const { data: productData, error: productError } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('id, title, slug, recording_url, m3u8_url, mp4_url, video_host, description, chapters')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.eq('type', 'webinar')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (productError || !productData) {
|
||||||
|
toast({ title: 'Error', description: 'Webinar tidak ditemukan', variant: 'destructive' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProduct(productData);
|
||||||
|
|
||||||
|
// Check if any recording exists (YouTube, M3U8, or MP4)
|
||||||
|
const hasRecording = productData.recording_url || productData.m3u8_url || productData.mp4_url;
|
||||||
|
if (!hasRecording) {
|
||||||
|
toast({ title: 'Info', description: 'Rekaman webinar belum tersedia', variant: 'destructive' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch accent color from settings
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from('platform_settings')
|
||||||
|
.select('brand_accent_color')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (settings?.brand_accent_color) {
|
||||||
|
setAccentColor(settings.brand_accent_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access via user_access or paid orders
|
||||||
|
const [accessRes, paidOrdersRes] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('user_access')
|
||||||
|
.select('id')
|
||||||
|
.eq('user_id', user!.id)
|
||||||
|
.eq('product_id', productData.id)
|
||||||
|
.maybeSingle(),
|
||||||
|
supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('order_items!inner(product_id)')
|
||||||
|
.eq('user_id', user!.id)
|
||||||
|
.eq('payment_status', 'paid')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasDirectAccess = !!accessRes.data;
|
||||||
|
const hasPaidOrderAccess = paidOrdersRes.data?.some((order: any) =>
|
||||||
|
order.order_items?.some((item: any) => item.product_id === productData.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAccess = hasDirectAccess || hasPaidOrderAccess;
|
||||||
|
setHasPurchased(hasAccess);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke webinar ini', variant: 'destructive' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// Check if user has already reviewed this webinar
|
||||||
|
checkUserReview();
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkUserReview = async () => {
|
||||||
|
if (!product || !user) return;
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.select('id, rating, title, body, is_approved, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('product_id', product.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
setUserReview(data[0] as UserReview);
|
||||||
|
} else {
|
||||||
|
setUserReview(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user has submitted a review (regardless of approval status)
|
||||||
|
const hasSubmittedReview = userReview !== null;
|
||||||
|
|
||||||
|
// Determine video host (prioritize Adilo over YouTube)
|
||||||
|
const detectedVideoHost = product?.video_host || (
|
||||||
|
product?.m3u8_url ? 'adilo' :
|
||||||
|
product?.recording_url?.includes('adilo.bigcommand.com') ? 'adilo' :
|
||||||
|
product?.recording_url?.includes('youtube.com') || product?.recording_url?.includes('youtu.be')
|
||||||
|
? 'youtube'
|
||||||
|
: 'unknown'
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChapterClick = useCallback((time: number) => {
|
||||||
|
// VideoPlayerWithChapters will handle the jump
|
||||||
|
if (playerRef.current && playerRef.current.jumpToTime) {
|
||||||
|
playerRef.current.jumpToTime(time);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTimeUpdate = useCallback((time: number) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch progress data for review trigger
|
||||||
|
const { progress, hasProgress: hasWatchProgress } = useVideoProgress({
|
||||||
|
videoId: product?.id || '',
|
||||||
|
videoType: 'webinar',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show review prompt if user has watched more than 5 seconds (any engagement)
|
||||||
|
const shouldShowReviewPrompt = hasWatchProgress;
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-10 w-1/3 mb-4" />
|
||||||
|
<Skeleton className="aspect-video w-full" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
const hasChapters = product.chapters && product.chapters.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-5xl">
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/dashboard')} className="mb-6">
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
|
Kembali ke Dashboard
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-6">{product.title}</h1>
|
||||||
|
|
||||||
|
{/* Video Player */}
|
||||||
|
<div className="mb-6">
|
||||||
|
{(product.recording_url || product.m3u8_url) && (
|
||||||
|
<VideoPlayerWithChapters
|
||||||
|
ref={playerRef}
|
||||||
|
videoUrl={product.recording_url || undefined}
|
||||||
|
m3u8Url={product.m3u8_url || undefined}
|
||||||
|
mp4Url={product.mp4_url || undefined}
|
||||||
|
videoHost={detectedVideoHost}
|
||||||
|
chapters={product.chapters}
|
||||||
|
accentColor={accentColor}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
videoId={product.id}
|
||||||
|
videoType="webinar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Chapters - video track for navigation */}
|
||||||
|
{hasChapters && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<TimelineChapters
|
||||||
|
chapters={product.chapters}
|
||||||
|
onChapterClick={handleChapterClick}
|
||||||
|
currentTime={currentTime}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{product.description && (
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<Card className="bg-muted border-2 border-border mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
Panduan Menonton
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||||
|
<li>Gunakan tombol fullscreen di pojok kanan bawah video untuk tampilan terbaik</li>
|
||||||
|
<li>Anda dapat memutar ulang video kapan saja</li>
|
||||||
|
<li>Pastikan koneksi internet stabil untuk pengalaman menonton yang lancar</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Review Section - Show after any engagement, but only if user hasn't submitted a review yet */}
|
||||||
|
{shouldShowReviewPrompt && !hasSubmittedReview && (
|
||||||
|
<Card className="border-2 border-primary/20 bg-primary/5 mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
|
<Star className="w-6 h-6 text-primary fill-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">Bagaimana webinar ini?</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Berikan ulasan Anda untuk membantu peserta lain memilih webinar yang tepat.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setReviewModalOpen(true)}>
|
||||||
|
<Star className="w-4 h-4 mr-2" />
|
||||||
|
Beri ulasan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User's Existing Review */}
|
||||||
|
{userReview && (
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckCircle className={`w-5 h-5 ${userReview.is_approved ? 'text-green-600' : 'text-yellow-600'}`} />
|
||||||
|
Ulasan Anda{!userReview.is_approved && ' (Menunggu Persetujuan)'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
star <= userReview.rating
|
||||||
|
? 'text-yellow-500 fill-yellow-500'
|
||||||
|
: 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{new Date(userReview.created_at).toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
{!userReview.is_approved && (
|
||||||
|
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-300">
|
||||||
|
Menunggu persetujuan admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{userReview.title && (
|
||||||
|
<h4 className="font-semibold text-lg mb-2">{userReview.title}</h4>
|
||||||
|
)}
|
||||||
|
{userReview.body && (
|
||||||
|
<p className="text-muted-foreground">{userReview.body}</p>
|
||||||
|
)}
|
||||||
|
{!userReview.is_approved && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 italic">
|
||||||
|
Ulasan Anda sedang ditinjau oleh admin dan akan segera ditampilkan setelah disetujui.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setReviewModalOpen(true)}
|
||||||
|
>
|
||||||
|
Edit ulasan
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review Modal */}
|
||||||
|
{product && user && (
|
||||||
|
<ReviewModal
|
||||||
|
open={reviewModalOpen}
|
||||||
|
onOpenChange={setReviewModalOpen}
|
||||||
|
userId={user.id}
|
||||||
|
productId={product.id}
|
||||||
|
type="webinar"
|
||||||
|
contextLabel={product.title}
|
||||||
|
existingReview={userReview ? {
|
||||||
|
id: userReview.id,
|
||||||
|
rating: userReview.rating,
|
||||||
|
title: userReview.title,
|
||||||
|
body: userReview.body,
|
||||||
|
} : undefined}
|
||||||
|
onSuccess={() => {
|
||||||
|
checkUserReview();
|
||||||
|
toast({
|
||||||
|
title: 'Terima kasih!',
|
||||||
|
description: userReview
|
||||||
|
? 'Ulasan Anda berhasil diperbarui.'
|
||||||
|
: 'Ulasan Anda berhasil disimpan.',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||||
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
||||||
import { BookOpen } from 'lucide-react';
|
import { BookOpen, Search } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,14 +22,13 @@ export default function AdminBootcamp() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [bootcamps, setBootcamps] = useState<Product[]>([]);
|
const [bootcamps, setBootcamps] = useState<Product[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading) {
|
if (user && isAdmin) {
|
||||||
if (!user) navigate('/auth');
|
fetchBootcamps();
|
||||||
else if (!isAdmin) navigate('/dashboard');
|
|
||||||
else fetchBootcamps();
|
|
||||||
}
|
}
|
||||||
}, [user, isAdmin, authLoading]);
|
}, [user, isAdmin]);
|
||||||
|
|
||||||
const fetchBootcamps = async () => {
|
const fetchBootcamps = async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -40,6 +40,11 @@ export default function AdminBootcamp() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter bootcamps based on search
|
||||||
|
const filteredBootcamps = bootcamps.filter((bootcamp) =>
|
||||||
|
bootcamp.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
if (authLoading || loading) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -62,18 +67,40 @@ export default function AdminBootcamp() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{bootcamps.length === 0 ? (
|
{/* Search */}
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Cari bootcamp..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Menampilkan {filteredBootcamps.length} dari {bootcamps.length} bootcamp
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{filteredBootcamps.length === 0 ? (
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
<p className="text-muted-foreground mb-4">Belum ada bootcamp. Buat produk dengan tipe bootcamp terlebih dahulu.</p>
|
<p className="text-muted-foreground mb-4">
|
||||||
<Button onClick={() => navigate('/admin/products')} variant="outline" className="border-2">
|
{searchQuery ? 'Tidak ada bootcamp yang cocok dengan pencarian' : 'Belum ada bootcamp. Buat produk dengan tipe bootcamp terlebih dahulu.'}
|
||||||
Ke Manajemen Produk
|
</p>
|
||||||
</Button>
|
{!searchQuery && (
|
||||||
|
<Button onClick={() => navigate('/admin/products')} variant="outline" className="border-2">
|
||||||
|
Ke Manajemen Produk
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Accordion type="single" collapsible className="space-y-4">
|
<Accordion type="single" collapsible className="space-y-4">
|
||||||
{bootcamps.map((bootcamp) => (
|
{filteredBootcamps.map((bootcamp) => (
|
||||||
<AccordionItem key={bootcamp.id} value={bootcamp.id} className="border-2 border-border bg-card">
|
<AccordionItem key={bootcamp.id} value={bootcamp.id} className="border-2 border-border bg-card">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<span className="font-bold">{bootcamp.title}</span>
|
<span className="font-bold">{bootcamp.title}</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { supabase } from "@/integrations/supabase/client";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { AppLayout } from "@/components/AppLayout";
|
import { AppLayout } from "@/components/AppLayout";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { formatIDR } from "@/lib/format";
|
import { formatIDR } from "@/lib/format";
|
||||||
import { Package, Users, Receipt, TrendingUp, BookOpen, Calendar } from "lucide-react";
|
import { Package, Users, Receipt, TrendingUp, BookOpen, Calendar } from "lucide-react";
|
||||||
@@ -124,12 +125,10 @@ export default function AdminDashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="font-bold">{formatIDR(order.total_amount)}</p>
|
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white" : "bg-amber-500 text-white"} rounded-full>
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-0.5 ${order.payment_status === "paid" ? "bg-accent text-accent-foreground" : "bg-muted text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
||||||
</span>
|
</Badge>
|
||||||
|
<p className="font-bold mt-1">{formatIDR(order.total_amount)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -75,12 +75,10 @@ export default function AdminEvents() {
|
|||||||
const [blockForm, setBlockForm] = useState(emptyBlock);
|
const [blockForm, setBlockForm] = useState(emptyBlock);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading) {
|
if (user && isAdmin) {
|
||||||
if (!user) navigate('/auth');
|
fetchData();
|
||||||
else if (!isAdmin) navigate('/dashboard');
|
|
||||||
else fetchData();
|
|
||||||
}
|
}
|
||||||
}, [user, isAdmin, authLoading]);
|
}, [user, isAdmin]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const [eventsRes, blocksRes, productsRes] = await Promise.all([
|
const [eventsRes, blocksRes, productsRes] = await Promise.all([
|
||||||
@@ -235,16 +233,18 @@ export default function AdminEvents() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
{/* Desktop Table */}
|
||||||
<TableHeader>
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Judul</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Tipe</TableHead>
|
<TableRow>
|
||||||
<TableHead>Mulai</TableHead>
|
<TableHead className="whitespace-nowrap">Judul</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className="whitespace-nowrap">Tipe</TableHead>
|
||||||
<TableHead className="text-right">Aksi</TableHead>
|
<TableHead className="whitespace-nowrap">Mulai</TableHead>
|
||||||
</TableRow>
|
<TableHead className="whitespace-nowrap">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="text-right whitespace-nowrap">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<TableRow key={event.id}>
|
<TableRow key={event.id}>
|
||||||
@@ -275,6 +275,47 @@ export default function AdminEvents() {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Card Layout */}
|
||||||
|
<div className="md:hidden space-y-3">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div key={event.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-base line-clamp-1">{event.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground capitalize">{event.type}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={event.status === 'confirmed' ? 'bg-accent' : 'bg-muted shrink-0'}>
|
||||||
|
{event.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Mulai:</span>
|
||||||
|
<span className="text-sm">{formatDateTime(event.starts_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-border">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEditEvent(event)} className="flex-1">
|
||||||
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleDeleteEvent(event.id)} className="flex-1 text-destructive">
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Belum ada event
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -289,16 +330,18 @@ export default function AdminEvents() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
{/* Desktop Table */}
|
||||||
<TableHeader>
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Tipe</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Mulai</TableHead>
|
<TableRow>
|
||||||
<TableHead>Selesai</TableHead>
|
<TableHead className="whitespace-nowrap">Tipe</TableHead>
|
||||||
<TableHead>Catatan</TableHead>
|
<TableHead className="whitespace-nowrap">Mulai</TableHead>
|
||||||
<TableHead className="text-right">Aksi</TableHead>
|
<TableHead className="whitespace-nowrap">Selesai</TableHead>
|
||||||
</TableRow>
|
<TableHead className="whitespace-nowrap">Catatan</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="text-right whitespace-nowrap">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{blocks.map((block) => (
|
{blocks.map((block) => (
|
||||||
<TableRow key={block.id}>
|
<TableRow key={block.id}>
|
||||||
@@ -329,13 +372,71 @@ export default function AdminEvents() {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Card Layout */}
|
||||||
|
<div className="md:hidden space-y-3">
|
||||||
|
{blocks.map((block) => (
|
||||||
|
<div key={block.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-base">
|
||||||
|
{block.kind === 'available' ? 'Tersedia' : 'Tidak Tersedia'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Badge className={block.kind === 'available' ? 'bg-accent' : 'bg-destructive shrink-0'}>
|
||||||
|
{block.kind === 'available' ? 'Tersedia' : 'Tidak'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Mulai:</span>
|
||||||
|
<span className="text-sm">{formatDateTime(block.starts_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Selesai:</span>
|
||||||
|
<span className="text-sm">{formatDateTime(block.ends_at)}</span>
|
||||||
|
</div>
|
||||||
|
{block.note && (
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Catatan:</span>
|
||||||
|
<span className="text-sm text-right flex-1 ml-4">{block.note}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-border">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEditBlock(block)} className="flex-1">
|
||||||
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleDeleteBlock(block.id)} className="flex-1 text-destructive">
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{blocks.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Belum ada blok ketersediaan
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Event Dialog */}
|
{/* Event Dialog */}
|
||||||
<Dialog open={eventDialogOpen} onOpenChange={setEventDialogOpen}>
|
<Dialog open={eventDialogOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
const confirmed = window.confirm('Tutup dialog? Data yang belum disimpan akan hilang.');
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
setEventDialogOpen(open);
|
||||||
|
}}>
|
||||||
<DialogContent className="max-w-md border-2 border-border">
|
<DialogContent className="max-w-md border-2 border-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingEvent ? 'Edit Event' : 'Buat Event Baru'}</DialogTitle>
|
<DialogTitle>{editingEvent ? 'Edit Event' : 'Buat Event Baru'}</DialogTitle>
|
||||||
@@ -371,7 +472,7 @@ export default function AdminEvents() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Mulai *</Label>
|
<Label>Mulai *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -407,7 +508,13 @@ export default function AdminEvents() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Block Dialog */}
|
{/* Block Dialog */}
|
||||||
<Dialog open={blockDialogOpen} onOpenChange={setBlockDialogOpen}>
|
<Dialog open={blockDialogOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
const confirmed = window.confirm('Tutup dialog? Data yang belum disimpan akan hilang.');
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
setBlockDialogOpen(open);
|
||||||
|
}}>
|
||||||
<DialogContent className="max-w-md border-2 border-border">
|
<DialogContent className="max-w-md border-2 border-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingBlock ? 'Edit Blok' : 'Tambah Blok Ketersediaan'}</DialogTitle>
|
<DialogTitle>{editingBlock ? 'Edit Blok' : 'Tambah Blok Ketersediaan'}</DialogTitle>
|
||||||
@@ -423,7 +530,7 @@ export default function AdminEvents() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Mulai *</Label>
|
<Label>Mulai *</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -9,9 +9,20 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
import { Eye, Shield, ShieldOff } from "lucide-react";
|
import { Eye, Shield, ShieldOff, Search, X, Trash2 } from "lucide-react";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +47,11 @@ export default function AdminMembers() {
|
|||||||
const [selectedMember, setSelectedMember] = useState<Member | null>(null);
|
const [selectedMember, setSelectedMember] = useState<Member | null>(null);
|
||||||
const [memberAccess, setMemberAccess] = useState<UserAccess[]>([]);
|
const [memberAccess, setMemberAccess] = useState<UserAccess[]>([]);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterRole, setFilterRole] = useState<string>('all');
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading) {
|
if (!authLoading) {
|
||||||
@@ -60,6 +76,25 @@ export default function AdminMembers() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter members based on search and role
|
||||||
|
const filteredMembers = members.filter((member) => {
|
||||||
|
const matchesSearch =
|
||||||
|
member.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
member.email?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const matchesRole =
|
||||||
|
filterRole === 'all' ||
|
||||||
|
(filterRole === 'admin' && adminIds.has(member.id)) ||
|
||||||
|
(filterRole === 'member' && !adminIds.has(member.id));
|
||||||
|
|
||||||
|
return matchesSearch && matchesRole;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setFilterRole('all');
|
||||||
|
};
|
||||||
|
|
||||||
const viewMemberDetails = async (member: Member) => {
|
const viewMemberDetails = async (member: Member) => {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
const { data } = await supabase.from("user_access").select("*, product:products(title)").eq("user_id", member.id);
|
const { data } = await supabase.from("user_access").select("*, product:products(title)").eq("user_id", member.id);
|
||||||
@@ -85,6 +120,89 @@ export default function AdminMembers() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDeleteMember = (member: Member) => {
|
||||||
|
if (member.id === user?.id) {
|
||||||
|
toast({ title: "Error", description: "Tidak bisa menghapus akun sendiri", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMemberToDelete(member);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMember = async () => {
|
||||||
|
if (!memberToDelete) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const userId = memberToDelete.id;
|
||||||
|
|
||||||
|
// Step 1: Delete auth_otps
|
||||||
|
await supabase.from("auth_otps").delete().eq("user_id", userId);
|
||||||
|
|
||||||
|
// Step 2: Delete order_items (first to avoid FK issues)
|
||||||
|
const { data: orders } = await supabase.from("orders").select("id").eq("user_id", userId);
|
||||||
|
if (orders && orders.length > 0) {
|
||||||
|
const orderIds = orders.map(o => o.id);
|
||||||
|
await supabase.from("order_items").delete().in("order_id", orderIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Delete orders
|
||||||
|
await supabase.from("orders").delete().eq("user_id", userId);
|
||||||
|
|
||||||
|
// Step 4: Delete user_access
|
||||||
|
await supabase.from("user_access").delete().eq("user_id", userId);
|
||||||
|
|
||||||
|
// Step 5: Delete video_progress
|
||||||
|
await supabase.from("video_progress").delete().eq("user_id", userId);
|
||||||
|
|
||||||
|
// Step 6: Delete collaboration withdrawals + wallet records
|
||||||
|
await supabase.from("withdrawals").delete().eq("user_id", userId);
|
||||||
|
await supabase.from("wallet_transactions").delete().eq("user_id", userId);
|
||||||
|
await supabase.from("collaborator_wallets").delete().eq("user_id", userId);
|
||||||
|
|
||||||
|
// Step 7: Delete consulting_slots
|
||||||
|
await supabase.from("consulting_slots").delete().eq("user_id", userId);
|
||||||
|
|
||||||
|
// Step 8: Delete calendar_events
|
||||||
|
await supabase.from("calendar_events").delete().eq("user_id", userId);
|
||||||
|
|
||||||
|
// Step 9: Delete user_roles
|
||||||
|
await supabase.from("user_roles").delete().eq("user_id", userId);
|
||||||
|
|
||||||
|
// Step 10: Delete profile
|
||||||
|
await supabase.from("profiles").delete().eq("id", userId);
|
||||||
|
|
||||||
|
// Step 11: Delete from auth.users using edge function
|
||||||
|
const { error: deleteError } = await supabase.functions.invoke('delete-user', {
|
||||||
|
body: { user_id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleteError) {
|
||||||
|
console.error('Error deleting from auth.users:', deleteError);
|
||||||
|
throw new Error(`Gagal menghapus user dari auth: ${deleteError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Berhasil",
|
||||||
|
description: `Member ${memberToDelete.email || memberToDelete.name} berhasil dihapus beserta semua data terkait`
|
||||||
|
});
|
||||||
|
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setMemberToDelete(null);
|
||||||
|
fetchMembers();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Gagal menghapus member";
|
||||||
|
console.error('Delete member error:', error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: message,
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (authLoading || loading) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -102,20 +220,102 @@ export default function AdminMembers() {
|
|||||||
<h1 className="text-4xl font-bold mb-2">Manajemen Member</h1>
|
<h1 className="text-4xl font-bold mb-2">Manajemen Member</h1>
|
||||||
<p className="text-muted-foreground mb-8">Kelola semua pengguna</p>
|
<p className="text-muted-foreground mb-8">Kelola semua pengguna</p>
|
||||||
|
|
||||||
<Card className="border-2 border-border">
|
{/* Search & Filter */}
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Cari nama atau email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-2"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Role:</span>
|
||||||
|
<Button
|
||||||
|
variant={filterRole === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterRole('all')}
|
||||||
|
className={filterRole === 'all' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Semua
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterRole === 'admin' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterRole('admin')}
|
||||||
|
className={filterRole === 'admin' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterRole === 'member' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterRole('member')}
|
||||||
|
className={filterRole === 'member' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Member
|
||||||
|
</Button>
|
||||||
|
{(searchQuery || filterRole !== 'all') && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Menampilkan {filteredMembers.length} dari {members.length} member
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{filteredMembers.length === 0 ? (
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{searchQuery || filterRole !== 'all'
|
||||||
|
? 'Tidak ada member yang cocok dengan filter'
|
||||||
|
: 'Belum ada member'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className="border-2 border-border hidden md:block">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
{/* Desktop Table */}
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Email</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Nama</TableHead>
|
<TableRow>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead className="whitespace-nowrap">Email</TableHead>
|
||||||
<TableHead>Bergabung</TableHead>
|
<TableHead className="whitespace-nowrap">Nama</TableHead>
|
||||||
<TableHead className="text-right">Aksi</TableHead>
|
<TableHead className="whitespace-nowrap">Role</TableHead>
|
||||||
</TableRow>
|
<TableHead className="whitespace-nowrap">Bergabung</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="text-right whitespace-nowrap">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{members.map((member) => (
|
{filteredMembers.map((member) => (
|
||||||
<TableRow key={member.id}>
|
<TableRow key={member.id}>
|
||||||
<TableCell>{member.email || "-"}</TableCell>
|
<TableCell>{member.email || "-"}</TableCell>
|
||||||
<TableCell>{member.name || "-"}</TableCell>
|
<TableCell>{member.name || "-"}</TableCell>
|
||||||
@@ -139,21 +339,79 @@ export default function AdminMembers() {
|
|||||||
>
|
>
|
||||||
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
|
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => confirmDeleteMember(member)}
|
||||||
|
disabled={member.id === user?.id}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{members.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
||||||
Belum ada member
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile Card Layout */}
|
||||||
|
<div className="md:hidden space-y-3">
|
||||||
|
{filteredMembers.map((member) => (
|
||||||
|
<div key={member.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-base line-clamp-1">{member.name || "Tanpa Nama"}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{member.email || "-"}</p>
|
||||||
|
</div>
|
||||||
|
{adminIds.has(member.id) ? (
|
||||||
|
<Badge className="bg-primary shrink-0">Admin</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-secondary text-primary shrink-0">Member</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Bergabung:</span>
|
||||||
|
<span className="text-sm">{formatDateTime(member.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-border">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => viewMemberDetails(member)} className="flex-1">
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleAdminRole(member.id, adminIds.has(member.id))}
|
||||||
|
disabled={member.id === user?.id}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4 mr-1" /> : <Shield className="w-4 h-4 mr-1" />}
|
||||||
|
{adminIds.has(member.id) ? "Hapus Admin" : "Jadikan Admin"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => confirmDeleteMember(member)}
|
||||||
|
disabled={member.id === user?.id}
|
||||||
|
className="flex-1 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="max-w-lg border-2 border-border">
|
<DialogContent className="max-w-lg border-2 border-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -191,6 +449,57 @@ export default function AdminMembers() {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent className="border-2 border-border">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Hapus Member?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
Anda akan menghapus member <strong>{memberToDelete?.email || memberToDelete?.name}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-destructive font-medium">
|
||||||
|
Tindakan ini akan menghapus SEMUA data terkait member ini:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>Order dan item order</li>
|
||||||
|
<li>Akses produk</li>
|
||||||
|
<li>Progress video</li>
|
||||||
|
<li>Jadwal konsultasi</li>
|
||||||
|
<li>Event kalender</li>
|
||||||
|
<li>Role admin (jika ada)</li>
|
||||||
|
<li>Profil user</li>
|
||||||
|
<li>Akun autentikasi</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tindakan ini <strong>TIDAK BISA dibatalkan</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Batal</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={deleteMember}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin mr-2">⏳</span>
|
||||||
|
Menghapus...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Ya, Hapus Member
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { formatIDR, formatDateTime } from "@/lib/format";
|
import { formatIDR, formatDateTime } from "@/lib/format";
|
||||||
import { Eye, CheckCircle, XCircle } from "lucide-react";
|
import { Eye, CheckCircle, XCircle, Video, ExternalLink, Trash2, AlertTriangle, RefreshCw, Link as LinkIcon, Download, Search, X } from "lucide-react";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { getPaymentStatusLabel, getPaymentStatusColor, canRefundOrder, canCancelOrder, canMarkAsPaid } from "@/lib/statusHelpers";
|
||||||
|
import { convertToCSV, downloadCSV, formatExportDate, formatExportIDR } from "@/lib/exportCSV";
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,16 +27,29 @@ interface Order {
|
|||||||
payment_method: string | null;
|
payment_method: string | null;
|
||||||
payment_reference: string | null;
|
payment_reference: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
refunded_amount?: number | null;
|
||||||
|
refunded_at?: string | null;
|
||||||
profile?: { email: string } | null;
|
profile?: { email: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrderItem {
|
interface OrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
product: { title: string };
|
product: { title: string; type?: string };
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConsultingSlot {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
status: string;
|
||||||
|
meet_link?: string;
|
||||||
|
topic_category?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminOrders() {
|
export default function AdminOrders() {
|
||||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -39,7 +57,19 @@ export default function AdminOrders() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
||||||
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
|
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
|
||||||
|
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
|
||||||
|
const [refundAmount, setRefundAmount] = useState("");
|
||||||
|
const [refundReason, setRefundReason] = useState("");
|
||||||
|
const [processingRefund, setProcessingRefund] = useState(false);
|
||||||
|
const [meetLinkDialogOpen, setMeetLinkDialogOpen] = useState(false);
|
||||||
|
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null);
|
||||||
|
const [newMeetLink, setNewMeetLink] = useState("");
|
||||||
|
const [creatingMeetLink, setCreatingMeetLink] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading) {
|
if (!authLoading) {
|
||||||
@@ -58,10 +88,48 @@ export default function AdminOrders() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter orders based on search and status
|
||||||
|
const filteredOrders = orders.filter((order) => {
|
||||||
|
const matchesSearch =
|
||||||
|
order.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
order.profile?.email?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const matchesStatus =
|
||||||
|
filterStatus === 'all' ||
|
||||||
|
(filterStatus === 'paid' && order.payment_status === 'paid') ||
|
||||||
|
(filterStatus === 'pending' && order.payment_status === 'pending') ||
|
||||||
|
(filterStatus === 'refunded' && order.refunded_at);
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setFilterStatus('all');
|
||||||
|
};
|
||||||
|
|
||||||
const viewOrderDetails = async (order: Order) => {
|
const viewOrderDetails = async (order: Order) => {
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
const { data } = await supabase.from("order_items").select("*, product:products(title)").eq("order_id", order.id);
|
const { data: itemsData } = await supabase.from("order_items").select("*, product:products(title, type)").eq("order_id", order.id);
|
||||||
setOrderItems((data as unknown as OrderItem[]) || []);
|
setOrderItems((itemsData as unknown as OrderItem[]) || []);
|
||||||
|
|
||||||
|
// Check if any item is a consulting product and fetch slots
|
||||||
|
// Also fetch slots if no order_items exist (consulting-only order)
|
||||||
|
const hasConsulting = (itemsData as unknown as OrderItem[])?.some(item => item.product?.type === "consulting");
|
||||||
|
const hasNoItems = !itemsData || itemsData.length === 0;
|
||||||
|
|
||||||
|
if (hasConsulting || hasNoItems) {
|
||||||
|
const { data: slotsData } = await supabase
|
||||||
|
.from("consulting_slots")
|
||||||
|
.select("*")
|
||||||
|
.eq("order_id", order.id)
|
||||||
|
.order("date", { ascending: true })
|
||||||
|
.order("start_time", { ascending: true });
|
||||||
|
setConsultingSlots((slotsData as ConsultingSlot[]) || []);
|
||||||
|
} else {
|
||||||
|
setConsultingSlots([]);
|
||||||
|
}
|
||||||
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,16 +161,208 @@ export default function AdminOrders() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteOrder = async (orderId: string) => {
|
||||||
|
// Confirm deletion
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"Apakah Anda yakin ingin menghapus order ini? Semua data terkait (review, slot konsultasi, akses produk) akan dihapus secara permanen."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke("delete-order", {
|
||||||
|
body: { order_id: orderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data?.success) {
|
||||||
|
throw new Error(data?.error || error?.message || "Gagal menghapus order");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ title: "Berhasil", description: "Order dan semua data terkait berhasil dihapus" });
|
||||||
|
fetchOrders();
|
||||||
|
setDialogOpen(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || "Gagal menghapus order",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processRefund = async () => {
|
||||||
|
if (!selectedOrder || !refundAmount || !refundReason) {
|
||||||
|
toast({ title: "Error", description: "Mohon lengkapi jumlah dan alasan refund", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refundAmountCents = parseInt(refundAmount);
|
||||||
|
if (isNaN(refundAmountCents) || refundAmountCents <= 0 || refundAmountCents > selectedOrder.total_amount) {
|
||||||
|
toast({ title: "Error", description: "Jumlah refund tidak valid", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessingRefund(true);
|
||||||
|
try {
|
||||||
|
// Update order with refund info
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("orders")
|
||||||
|
.update({
|
||||||
|
refunded_amount: refundAmountCents,
|
||||||
|
refund_reason: refundReason,
|
||||||
|
refunded_at: new Date().toISOString(),
|
||||||
|
refunded_by: user?.id,
|
||||||
|
payment_status: refundAmountCents >= selectedOrder.total_amount ? "refunded" : "partially_refunded",
|
||||||
|
})
|
||||||
|
.eq("id", selectedOrder.id);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
|
// Revoke access for all products in this order
|
||||||
|
const { data: itemsData } = await supabase
|
||||||
|
.from("order_items")
|
||||||
|
.select("product_id")
|
||||||
|
.eq("order_id", selectedOrder.id);
|
||||||
|
|
||||||
|
if (itemsData) {
|
||||||
|
for (const item of itemsData) {
|
||||||
|
await supabase
|
||||||
|
.from("user_access")
|
||||||
|
.delete()
|
||||||
|
.eq("user_id", selectedOrder.user_id)
|
||||||
|
.eq("product_id", item.product_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ title: "Berhasil", description: "Refund berhasil diproses dan akses produk dicabut" });
|
||||||
|
setRefundDialogOpen(false);
|
||||||
|
setRefundAmount("");
|
||||||
|
setRefundReason("");
|
||||||
|
fetchOrders();
|
||||||
|
setDialogOpen(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || "Gagal memproses refund",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setProcessingRefund(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRefundDialog = () => {
|
||||||
|
setRefundAmount(selectedOrder?.total_amount.toString() || "");
|
||||||
|
setRefundDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMeetLinkDialog = (slotId: string, currentLink?: string) => {
|
||||||
|
setSelectedSlotId(slotId);
|
||||||
|
setNewMeetLink(currentLink || "");
|
||||||
|
setMeetLinkDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMeetLink = async () => {
|
||||||
|
if (!selectedSlotId || !newMeetLink) {
|
||||||
|
toast({ title: "Error", description: "Mohon masukkan Meet link", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingMeetLink(true);
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("consulting_slots")
|
||||||
|
.update({ meet_link: newMeetLink })
|
||||||
|
.eq("id", selectedSlotId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Refresh consulting slots
|
||||||
|
if (selectedOrder) {
|
||||||
|
const { data: slotsData } = await supabase
|
||||||
|
.from("consulting_slots")
|
||||||
|
.select("*")
|
||||||
|
.eq("order_id", selectedOrder.id)
|
||||||
|
.order("date", { ascending: true });
|
||||||
|
setConsultingSlots((slotsData as ConsultingSlot[]) || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ title: "Berhasil", description: "Meet link berhasil diperbarui" });
|
||||||
|
setMeetLinkDialogOpen(false);
|
||||||
|
setNewMeetLink("");
|
||||||
|
setSelectedSlotId(null);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || "Gagal memperbarui Meet link",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreatingMeetLink(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: string | null) => {
|
const getStatusBadge = (status: string | null) => {
|
||||||
switch (status) {
|
return (
|
||||||
case "paid":
|
<Badge className={`${getPaymentStatusColor(status)} rounded-full`}>
|
||||||
return <Badge className="bg-accent text-primary">Lunas</Badge>;
|
{getPaymentStatusLabel(status)}
|
||||||
case "pending":
|
</Badge>
|
||||||
return <Badge className="bg-secondary text-primary">Pending</Badge>;
|
);
|
||||||
case "cancelled":
|
};
|
||||||
return <Badge className="bg-destructive">Dibatalkan</Badge>;
|
|
||||||
default:
|
const handleExportOrders = async () => {
|
||||||
return <Badge className="bg-muted">{status}</Badge>;
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
// Fetch all orders with full details
|
||||||
|
const { data: ordersData, error } = await supabase
|
||||||
|
.from("orders")
|
||||||
|
.select("*, profile:profiles(email)")
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Transform data for CSV export
|
||||||
|
const csvData = (ordersData as Order[]).map((order) => ({
|
||||||
|
"Order ID": order.id,
|
||||||
|
"Email": order.profile?.email || "",
|
||||||
|
"Total": order.total_amount / 100, // Raw number in IDR (no formatting)
|
||||||
|
"Status": getPaymentStatusLabel(order.payment_status),
|
||||||
|
"Metode Pembayaran": order.payment_method || "",
|
||||||
|
"Referensi": order.payment_reference || "",
|
||||||
|
"Tanggal": formatExportDate(order.created_at),
|
||||||
|
"Refund Amount": order.refunded_amount ? order.refunded_amount / 100 : "",
|
||||||
|
"Refund Reason": order.refund_reason || "",
|
||||||
|
"Refunded At": order.refunded_at ? formatExportDate(order.refunded_at) : "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Convert to CSV
|
||||||
|
const headers = [
|
||||||
|
"Order ID",
|
||||||
|
"Email",
|
||||||
|
"Total",
|
||||||
|
"Status",
|
||||||
|
"Metode Pembayaran",
|
||||||
|
"Referensi",
|
||||||
|
"Tanggal",
|
||||||
|
"Refund Amount",
|
||||||
|
"Refund Reason",
|
||||||
|
"Refunded At",
|
||||||
|
];
|
||||||
|
const csv = convertToCSV(csvData, headers);
|
||||||
|
|
||||||
|
// Download CSV
|
||||||
|
const filename = `orders-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
downloadCSV(csv, filename);
|
||||||
|
|
||||||
|
toast({ title: "Berhasil", description: "Data order berhasil di-export" });
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || "Gagal men-export data order",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,51 +380,186 @@ export default function AdminOrders() {
|
|||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-4xl font-bold mb-2">Manajemen Order</h1>
|
<div className="flex items-center justify-between mb-8">
|
||||||
<p className="text-muted-foreground mb-8">Kelola semua pesanan</p>
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Manajemen Order</h1>
|
||||||
|
<p className="text-muted-foreground">Kelola semua pesanan</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleExportOrders}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-2"
|
||||||
|
disabled={exporting || orders.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{exporting ? "Men-export..." : "Export Orders"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="border-2 border-border">
|
{/* Search & Filter */}
|
||||||
<CardContent className="p-0">
|
<Card className="border-2 border-border mb-6">
|
||||||
<Table>
|
<CardContent className="pt-6">
|
||||||
<TableHeader>
|
<div className="space-y-4">
|
||||||
<TableRow>
|
<div className="relative">
|
||||||
<TableHead>ID Order</TableHead>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<TableHead>Email</TableHead>
|
<Input
|
||||||
<TableHead>Total</TableHead>
|
placeholder="Cari ID order atau email..."
|
||||||
<TableHead>Metode</TableHead>
|
value={searchQuery}
|
||||||
<TableHead>Status</TableHead>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<TableHead>Tanggal</TableHead>
|
className="pl-10 border-2"
|
||||||
<TableHead className="text-right">Aksi</TableHead>
|
/>
|
||||||
</TableRow>
|
{searchQuery && (
|
||||||
</TableHeader>
|
<button
|
||||||
<TableBody>
|
onClick={() => setSearchQuery('')}
|
||||||
{orders.map((order) => (
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
<TableRow key={order.id}>
|
>
|
||||||
<TableCell className="font-mono text-sm">{order.id.slice(0, 8)}</TableCell>
|
<X className="w-4 h-4" />
|
||||||
<TableCell>{order.profile?.email || "-"}</TableCell>
|
</button>
|
||||||
<TableCell className="font-bold">{formatIDR(order.total_amount)}</TableCell>
|
|
||||||
<TableCell className="uppercase text-sm">{order.payment_method || "-"}</TableCell>
|
|
||||||
<TableCell>{getStatusBadge(order.payment_status)}</TableCell>
|
|
||||||
<TableCell>{formatDateTime(order.created_at)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => viewOrderDetails(order)}>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{orders.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
|
||||||
Belum ada order
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Status:</span>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('all')}
|
||||||
|
className={filterStatus === 'all' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Semua
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'paid' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('paid')}
|
||||||
|
className={filterStatus === 'paid' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Lunas
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'pending' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('pending')}
|
||||||
|
className={filterStatus === 'pending' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'refunded' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('refunded')}
|
||||||
|
className={filterStatus === 'refunded' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Refunded
|
||||||
|
</Button>
|
||||||
|
{(searchQuery || filterStatus !== 'all') && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Menampilkan {filteredOrders.length} dari {orders.length} order
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{filteredOrders.length === 0 ? (
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{searchQuery || filterStatus !== 'all'
|
||||||
|
? 'Tidak ada order yang cocok dengan filter'
|
||||||
|
: 'Belum ada order'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className="border-2 border-border hidden md:block">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="whitespace-nowrap">ID Order</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">Email</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">Total</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">Metode</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">Status</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">Tanggal</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{order.id.slice(0, 8)}</TableCell>
|
||||||
|
<TableCell>{order.profile?.email || "-"}</TableCell>
|
||||||
|
<TableCell className="font-bold">{formatIDR(order.total_amount)}</TableCell>
|
||||||
|
<TableCell className="uppercase text-sm">{order.payment_method || "-"}</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(order.payment_status)}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(order.created_at)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => viewOrderDetails(order)}>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile Card Layout */}
|
||||||
|
<div className="md:hidden space-y-3">
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<div key={order.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-semibold text-sm font-mono">{order.id.slice(0, 8)}</h3>
|
||||||
|
{getStatusBadge(order.payment_status)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{order.profile?.email || "-"}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => viewOrderDetails(order)} className="shrink-0">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Total:</span>
|
||||||
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Metode:</span>
|
||||||
|
<span className="uppercase text-sm">{order.payment_method || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Tanggal:</span>
|
||||||
|
<span className="text-sm">{formatDateTime(order.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="max-w-lg border-2 border-border">
|
<DialogContent className="max-w-lg border-2 border-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -172,7 +567,7 @@ export default function AdminOrders() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedOrder && (
|
{selectedOrder && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">ID:</span> {selectedOrder.id.slice(0, 8)}
|
<span className="text-muted-foreground">ID:</span> {selectedOrder.id.slice(0, 8)}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,27 +581,156 @@ export default function AdminOrders() {
|
|||||||
<span className="text-muted-foreground">Metode:</span> {selectedOrder.payment_method || "-"}
|
<span className="text-muted-foreground">Metode:</span> {selectedOrder.payment_method || "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border pt-4">
|
{/* Order Items - only show if there are items */}
|
||||||
<p className="font-medium mb-2">Item:</p>
|
{orderItems.length > 0 && (
|
||||||
{orderItems.map((item) => (
|
<div className="border-t border-border pt-4">
|
||||||
<div key={item.id} className="flex justify-between py-1">
|
<p className="font-medium mb-2">Item Pesanan:</p>
|
||||||
<span>{item.product?.title}</span>
|
{orderItems.map((item) => (
|
||||||
<span className="font-bold">{formatIDR(item.unit_price)}</span>
|
<div key={item.id} className="flex justify-between py-1">
|
||||||
|
<span>{item.product?.title}</span>
|
||||||
|
<span className="font-bold">{formatIDR(item.unit_price)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-between pt-2 border-t border-border mt-2">
|
||||||
|
<span className="font-bold">Total</span>
|
||||||
|
<span className="font-bold">{formatIDR(selectedOrder.total_amount)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
<div className="flex justify-between pt-2 border-t border-border mt-2">
|
|
||||||
<span className="font-bold">Total</span>
|
|
||||||
<span className="font-bold">{formatIDR(selectedOrder.total_amount)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
{selectedOrder.payment_status !== "paid" && (
|
{/* Order Total for consulting-only orders */}
|
||||||
|
{orderItems.length === 0 && consultingSlots.length > 0 && (
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="font-bold">Total</span>
|
||||||
|
<span className="font-bold">{formatIDR(selectedOrder.total_amount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consulting Slots - Grouped by Date */}
|
||||||
|
{consultingSlots.length > 0 && (() => {
|
||||||
|
// Group slots by date
|
||||||
|
const slotsByDate = consultingSlots.reduce((acc, slot) => {
|
||||||
|
if (!acc[slot.date]) {
|
||||||
|
acc[slot.date] = [];
|
||||||
|
}
|
||||||
|
acc[slot.date].push(slot);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, typeof consultingSlots>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<p className="font-medium mb-3 flex items-center gap-2">
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
Jadwal Konsultasi
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(slotsByDate).map(([date, slots]) => {
|
||||||
|
const firstSlot = slots[0];
|
||||||
|
const lastSlot = slots[slots.length - 1];
|
||||||
|
const allSlotsHaveMeetLink = slots.every(s => s.meet_link);
|
||||||
|
const meetLink = firstSlot.meet_link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={date} className="border-2 border-border rounded-lg p-3 bg-background">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<Badge variant={firstSlot.status === "confirmed" ? "default" : "secondary"} className="text-xs">
|
||||||
|
{firstSlot.status === "confirmed" ? "Terkonfirmasi" : firstSlot.status}
|
||||||
|
</Badge>
|
||||||
|
{firstSlot.topic_category && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{firstSlot.topic_category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{slots.length > 1 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{slots.length} sesi
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{/* Meet Link Status */}
|
||||||
|
{allSlotsHaveMeetLink ? (
|
||||||
|
<Badge variant="outline" className="text-xs gap-1 border-green-500 text-green-700">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Meet Link Ready
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs gap-1 border-amber-500 text-amber-700">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Belum ada Meet Link
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{new Date(date).toLocaleDateString("id-ID", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric"
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)} WIB
|
||||||
|
</p>
|
||||||
|
{firstSlot.notes && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 italic">
|
||||||
|
Catatan: {firstSlot.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{meetLink && (
|
||||||
|
<Button asChild variant="outline" size="sm" className="gap-1">
|
||||||
|
<a
|
||||||
|
href={meetLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Video className="w-3 h-3" />
|
||||||
|
Meet
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openMeetLinkDialog(firstSlot.id, meetLink)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3 h-3" />
|
||||||
|
{meetLink ? "Update" : "Buat"} Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
||||||
|
{canRefundOrder(selectedOrder.payment_status, selectedOrder.refunded_at) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={openRefundDialog}
|
||||||
|
className="flex-1 gap-2 border-2 border-purple-500 text-purple-700 hover:bg-purple-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Refund
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canMarkAsPaid(selectedOrder.payment_status, selectedOrder.refunded_at) && (
|
||||||
<Button onClick={() => updateOrderStatus(selectedOrder.id, "paid")} className="flex-1">
|
<Button onClick={() => updateOrderStatus(selectedOrder.id, "paid")} className="flex-1">
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
Tandai Lunas
|
Tandai Lunas
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{selectedOrder.payment_status !== "cancelled" && (
|
{canCancelOrder(selectedOrder.payment_status, selectedOrder.refunded_at) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => updateOrderStatus(selectedOrder.id, "cancelled")}
|
onClick={() => updateOrderStatus(selectedOrder.id, "cancelled")}
|
||||||
@@ -216,11 +740,118 @@ export default function AdminOrders() {
|
|||||||
Batalkan
|
Batalkan
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteOrder(selectedOrder.id)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Hapus Order
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Refund Dialog */}
|
||||||
|
<Dialog open={refundDialogOpen} onOpenChange={setRefundDialogOpen}>
|
||||||
|
<DialogContent className="border-2 border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Proses Refund</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>Order ID: {selectedOrder?.id.slice(0, 8)}</p>
|
||||||
|
<p>Total: {selectedOrder && formatIDR(selectedOrder.total_amount)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="refundAmount">Jumlah Refund (Rp)</Label>
|
||||||
|
<Input
|
||||||
|
id="refundAmount"
|
||||||
|
type="number"
|
||||||
|
value={refundAmount}
|
||||||
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
|
placeholder="Masukkan jumlah refund"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Maksimal: {selectedOrder && formatIDR(selectedOrder.total_amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="refundReason">Alasan Refund</Label>
|
||||||
|
<Textarea
|
||||||
|
id="refundReason"
|
||||||
|
value={refundReason}
|
||||||
|
onChange={(e) => setRefundReason(e.target.value)}
|
||||||
|
placeholder="Jelaskan alasan refund..."
|
||||||
|
className="mt-1 min-h-[100px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 border-2 border-amber-200 rounded-lg p-3 text-sm">
|
||||||
|
<p className="font-medium text-amber-900 mb-1">⚠️ Perhatian</p>
|
||||||
|
<ul className="text-amber-800 space-y-1 text-xs">
|
||||||
|
<li>• Akses produk akan dicabut otomatis setelah refund</li>
|
||||||
|
<li>• Slot konsultasi akan dibatalkan</li>
|
||||||
|
<li>• Tindakan ini tidak dapat dibatalkan</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRefundDialogOpen(false)}
|
||||||
|
disabled={processingRefund}
|
||||||
|
className="border-2"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button onClick={processRefund} disabled={processingRefund}>
|
||||||
|
{processingRefund ? "Memproses..." : "Proses Refund"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Meet Link Dialog */}
|
||||||
|
<Dialog open={meetLinkDialogOpen} onOpenChange={setMeetLinkDialogOpen}>
|
||||||
|
<DialogContent className="border-2 border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{newMeetLink ? "Update" : "Buat"} Meet Link</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="meetLink">Google Meet Link</Label>
|
||||||
|
<Input
|
||||||
|
id="meetLink"
|
||||||
|
type="url"
|
||||||
|
value={newMeetLink}
|
||||||
|
onChange={(e) => setNewMeetLink(e.target.value)}
|
||||||
|
placeholder="https://meet.google.com/xxx-xxxx-xxx"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Masukkan link Google Meet yang valid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setMeetLinkDialogOpen(false)}
|
||||||
|
disabled={creatingMeetLink}
|
||||||
|
className="border-2"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button onClick={updateMeetLink} disabled={creatingMeetLink}>
|
||||||
|
{creatingMeetLink ? "Menyimpan..." : "Simpan"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user