Compare commits
120 Commits
17440cdf89
...
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 |
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/
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.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
|
||||
@@ -7,14 +7,14 @@ WORKDIR /app
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
# Install dependencies with clean install
|
||||
RUN npm ci --prefer-offline --no-audit --force
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
# Clean any previous build artifacts and node_modules cache, then build
|
||||
RUN rm -rf dist node_modules/.cache && npm run build
|
||||
|
||||
# Production stage - Use a simple server that works with Coolify
|
||||
FROM node:18-alpine AS production
|
||||
|
||||
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
|
||||
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!**
|
||||
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."
|
||||
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
|
||||
492
package-lock.json
generated
492
package-lock.json
generated
@@ -38,6 +38,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@supabase/supabase-js": "^2.88.0",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
||||
"@tiptap/extension-image": "^3.13.0",
|
||||
"@tiptap/extension-link": "^3.13.0",
|
||||
"@tiptap/extension-placeholder": "^3.13.0",
|
||||
@@ -48,14 +49,21 @@
|
||||
"@tiptap/extension-text-align": "^3.14.0",
|
||||
"@tiptap/react": "^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",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"input-otp": "^1.4.2",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"plyr": "^3.8.3",
|
||||
"plyr-react": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -69,6 +77,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tiptap-extension-resize-image": "^1.3.2",
|
||||
"vaul": "^0.9.9",
|
||||
"video.js": "^8.23.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -86,6 +95,7 @@
|
||||
"lovable-tagger": "^1.1.13",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19"
|
||||
@@ -853,6 +863,17 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
||||
@@ -2959,17 +2980,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.13.0.tgz",
|
||||
"integrity": "sha512-kIwfQ4iqootsWg9e74iYJK54/YMIj6ahUxEltjZRML5z/h4gTDcQt2eTpnEC8yjDjHeUVOR94zH9auCySyk9CQ==",
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.14.0.tgz",
|
||||
"integrity": "sha512-hRSdIhhm3Q9JBMQdKaifRVFnAa4sG+M7l1QcTKR3VSYVy2/oR0U+aiOifi5OvMRBUwhaR71Ro+cMT9FH9s26Kg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.13.0",
|
||||
"@tiptap/pm": "^3.13.0"
|
||||
"@tiptap/core": "^3.14.0",
|
||||
"@tiptap/pm": "^3.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block-lowlight": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.14.0.tgz",
|
||||
"integrity": "sha512-vkiDvPZUadrjAGNzvJYYXl5R+U1XmGALSbm+VlrGCR7iXHgYaMHdkqxHwGZMSqtsF2szPEqcAzLZShlAKl+AkA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.14.0",
|
||||
"@tiptap/extension-code-block": "^3.14.0",
|
||||
"@tiptap/pm": "^3.14.0",
|
||||
"highlight.js": "^11",
|
||||
"lowlight": "^2 || ^3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
@@ -3471,6 +3509,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/hls.js": {
|
||||
"version": "0.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/hls.js/-/hls.js-0.13.3.tgz",
|
||||
"integrity": "sha512-Po8ZPCsAcPPuf5OODPEkb6cdWJ/w4BdX1veP7IIOc2WG0x1SW4GEQ1+FHKN1AMG2AePJfNUceJbh5PKtP92yRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -3540,12 +3593,31 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/video.js": {
|
||||
"version": "7.3.58",
|
||||
"resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz",
|
||||
"integrity": "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -3813,6 +3885,54 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@videojs/http-streaming": {
|
||||
"version": "3.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz",
|
||||
"integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/vhs-utils": "^4.1.1",
|
||||
"aes-decrypter": "^4.0.2",
|
||||
"global": "^4.4.0",
|
||||
"m3u8-parser": "^7.2.0",
|
||||
"mpd-parser": "^1.3.1",
|
||||
"mux.js": "7.1.0",
|
||||
"video.js": "^7 || ^8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"video.js": "^8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@videojs/vhs-utils": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
|
||||
"integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"global": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/@videojs/xhr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
|
||||
"integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"global": "~4.4.0",
|
||||
"is-function": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react-swc": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
|
||||
@@ -3827,6 +3947,15 @@
|
||||
"vite": "^4 || ^5 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -3850,6 +3979,18 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-decrypter": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
|
||||
"integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/vhs-utils": "^4.1.1",
|
||||
"global": "^4.4.0",
|
||||
"pkcs7": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -4049,6 +4190,13 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -4212,6 +4360,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
|
||||
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
@@ -4249,6 +4408,12 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/custom-event-polyfill": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
|
||||
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
@@ -4411,12 +4576,34 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -4439,6 +4626,20 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-walk": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -4991,6 +5192,16 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/global": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-document": "^2.19.0",
|
||||
"process": "^0.11.10"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||
@@ -5033,6 +5244,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
@@ -5143,6 +5369,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-function": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
|
||||
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -5291,6 +5523,12 @@
|
||||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loadjs": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz",
|
||||
"integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -5790,6 +6028,21 @@
|
||||
"@esbuild/win32-x64": "0.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
||||
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"highlight.js": "~11.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
@@ -5804,6 +6057,17 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/m3u8-parser": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
|
||||
"integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/vhs-utils": "^4.1.1",
|
||||
"global": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
@@ -5849,6 +6113,15 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-document": {
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
|
||||
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dom-walk": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -5871,6 +6144,21 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mpd-parser": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz",
|
||||
"integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/vhs-utils": "^4.0.0",
|
||||
"@xmldom/xmldom": "^0.8.3",
|
||||
"global": "^4.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"mpd-to-m3u8-json": "bin/parse.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5878,6 +6166,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mux.js": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
|
||||
"integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.11.2",
|
||||
"global": "^4.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"muxjs-transmux": "bin/transmux.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -6120,6 +6425,47 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pkcs7": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
|
||||
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5"
|
||||
},
|
||||
"bin": {
|
||||
"pkcs7": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/plyr": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz",
|
||||
"integrity": "sha512-0+iI5uw0WRvtKBpgPCkmQQv7ucHVQKTEo6UFJjgJ8cy/JZhy0dQqshHQVitHXV6l2O3MzhgnuvQ95VSkWcWeSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.45.1",
|
||||
"custom-event-polyfill": "^1.0.7",
|
||||
"loadjs": "^4.3.0",
|
||||
"rangetouch": "^2.0.1",
|
||||
"url-polyfill": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/plyr-react": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/plyr-react/-/plyr-react-6.0.0.tgz",
|
||||
"integrity": "sha512-P8M+BuQoGrCd7m6K4QwwQlcSS1E26OeXuJTAmgLx11B9UqJrdc3Ka4TFwPwF3jul4EsVxSK9Zn1ME3DV8m9gdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-aptor": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"plyr": "^3.7.7",
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -6273,6 +6619,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -6533,6 +6888,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rangetouch": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||
"integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -6545,6 +6906,23 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-aptor": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aptor/-/react-aptor-2.0.0.tgz",
|
||||
"integrity": "sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
@@ -6956,6 +7334,16 @@
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6965,6 +7353,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@@ -7177,6 +7576,32 @@
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.44.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"bin": {
|
||||
"terser": "bin/terser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -7356,6 +7781,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-polyfill": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
||||
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
@@ -7449,6 +7880,57 @@
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/video.js": {
|
||||
"version": "8.23.4",
|
||||
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz",
|
||||
"integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/http-streaming": "^3.17.2",
|
||||
"@videojs/vhs-utils": "^4.1.1",
|
||||
"@videojs/xhr": "2.7.0",
|
||||
"aes-decrypter": "^4.0.2",
|
||||
"global": "4.4.0",
|
||||
"m3u8-parser": "^7.2.0",
|
||||
"mpd-parser": "^1.3.1",
|
||||
"mux.js": "^7.0.1",
|
||||
"videojs-contrib-quality-levels": "4.1.0",
|
||||
"videojs-font": "4.2.0",
|
||||
"videojs-vtt.js": "0.15.5"
|
||||
}
|
||||
},
|
||||
"node_modules/videojs-contrib-quality-levels": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
|
||||
"integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"global": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16",
|
||||
"npm": ">=8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"video.js": "^8"
|
||||
}
|
||||
},
|
||||
"node_modules/videojs-font": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
|
||||
"integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/videojs-vtt.js": {
|
||||
"version": "0.15.5",
|
||||
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
|
||||
"integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"global": "^4.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.19",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -41,6 +41,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@supabase/supabase-js": "^2.88.0",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
||||
"@tiptap/extension-image": "^3.13.0",
|
||||
"@tiptap/extension-link": "^3.13.0",
|
||||
"@tiptap/extension-placeholder": "^3.13.0",
|
||||
@@ -51,14 +52,21 @@
|
||||
"@tiptap/extension-text-align": "^3.14.0",
|
||||
"@tiptap/react": "^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",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"input-otp": "^1.4.2",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"plyr": "^3.8.3",
|
||||
"plyr-react": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -72,6 +80,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tiptap-extension-resize-image": "^1.3.2",
|
||||
"vaul": "^0.9.9",
|
||||
"video.js": "^8.23.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -89,6 +98,7 @@
|
||||
"lovable-tagger": "^1.1.13",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.9 KiB |
167
src/App.tsx
167
src/App.tsx
@@ -6,8 +6,10 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "@/hooks/useAuth";
|
||||
import { CartProvider } from "@/contexts/CartContext";
|
||||
import { BrandingProvider } from "@/hooks/useBranding";
|
||||
import { ProtectedRoute } from "@/components/ProtectedRoute";
|
||||
import Index from "./pages/Index";
|
||||
import Auth from "./pages/Auth";
|
||||
import ConfirmOTP from "./pages/ConfirmOTP";
|
||||
import Products from "./pages/Products";
|
||||
import ProductDetail from "./pages/ProductDetail";
|
||||
import Checkout from "./pages/Checkout";
|
||||
@@ -15,7 +17,7 @@ import Bootcamp from "./pages/Bootcamp";
|
||||
import WebinarRecording from "./pages/WebinarRecording";
|
||||
import Events from "./pages/Events";
|
||||
import ConsultingBooking from "./pages/ConsultingBooking";
|
||||
import Calendar from "./pages/Calendar";
|
||||
import CalendarPage from "./pages/Calendar";
|
||||
import Privacy from "./pages/Privacy";
|
||||
import Terms from "./pages/Terms";
|
||||
import NotFound from "./pages/NotFound";
|
||||
@@ -26,6 +28,7 @@ import MemberAccess from "./pages/member/MemberAccess";
|
||||
import MemberOrders from "./pages/member/MemberOrders";
|
||||
import MemberProfile from "./pages/member/MemberProfile";
|
||||
import OrderDetail from "./pages/member/OrderDetail";
|
||||
import MemberProfit from "./pages/member/MemberProfit";
|
||||
|
||||
// Admin pages
|
||||
import AdminDashboard from "./pages/admin/AdminDashboard";
|
||||
@@ -37,6 +40,8 @@ import AdminEvents from "./pages/admin/AdminEvents";
|
||||
import AdminSettings from "./pages/admin/AdminSettings";
|
||||
import AdminConsulting from "./pages/admin/AdminConsulting";
|
||||
import AdminReviews from "./pages/admin/AdminReviews";
|
||||
import ProductCurriculum from "./pages/admin/ProductCurriculum";
|
||||
import AdminWithdrawals from "./pages/admin/AdminWithdrawals";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -52,34 +57,158 @@ const App = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/confirm-otp" element={<ConfirmOTP />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/products/:slug" element={<ProductDetail />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<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="/calendar" element={<Calendar />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
|
||||
|
||||
{/* Member routes */}
|
||||
<Route path="/dashboard" element={<MemberDashboard />} />
|
||||
<Route path="/access" element={<MemberAccess />} />
|
||||
<Route path="/orders" element={<MemberOrders />} />
|
||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||
<Route path="/profile" element={<MemberProfile />} />
|
||||
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<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 */}
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
<Route path="/admin/products" element={<AdminProducts />} />
|
||||
<Route path="/admin/bootcamp" element={<AdminBootcamp />} />
|
||||
<Route path="/admin/orders" element={<AdminOrders />} />
|
||||
<Route path="/admin/members" element={<AdminMembers />} />
|
||||
<Route path="/admin/events" element={<AdminEvents />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/admin/consulting" element={<AdminConsulting />} />
|
||||
<Route path="/admin/reviews" element={<AdminReviews />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<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 />} />
|
||||
</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 { useAuth } from '@/hooks/useAuth';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { useBranding } from '@/hooks/useBranding';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Footer } from '@/components/Footer';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
X,
|
||||
Video,
|
||||
Star,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
@@ -43,27 +45,27 @@ const userNavItems: NavItem[] = [
|
||||
const adminNavItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||
{ label: 'Bootcamp', href: '/admin/bootcamp', icon: BookOpen },
|
||||
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video },
|
||||
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||
{ label: 'Withdrawals', href: '/admin/withdrawals', icon: Wallet },
|
||||
{ label: 'Ulasan', href: '/admin/reviews', icon: Star },
|
||||
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
|
||||
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
const mobileUserNav: NavItem[] = [
|
||||
{ label: 'Home', href: '/dashboard', icon: Home },
|
||||
{ label: 'Kelas', href: '/access', icon: BookOpen },
|
||||
{ label: 'Pesanan', href: '/orders', icon: Receipt },
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Akses', href: '/access', icon: BookOpen },
|
||||
{ label: 'Order', href: '/orders', icon: Receipt },
|
||||
{ label: 'Profil', href: '/profile', icon: User },
|
||||
];
|
||||
|
||||
const mobileAdminNav: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||
{ label: 'Pesanan', href: '/admin/orders', icon: Receipt },
|
||||
{ label: 'Pengguna', href: '/admin/members', icon: Users },
|
||||
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||
];
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -77,9 +79,36 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [isCollaborator, setIsCollaborator] = useState(false);
|
||||
|
||||
const navItems = isAdmin ? adminNavItems : userNavItems;
|
||||
const mobileNav = isAdmin ? mobileAdminNav : mobileUserNav;
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
await signOut();
|
||||
|
||||
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}</>;
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
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 {
|
||||
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
|
||||
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
|
||||
Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus
|
||||
Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus, Code, Copy, Check
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
@@ -18,6 +19,38 @@ import { toast } from '@/hooks/use-toast';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 {
|
||||
content: string;
|
||||
@@ -249,6 +282,20 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
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'],
|
||||
@@ -516,6 +563,16 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||
import { ChaptersEditor } from './ChaptersEditor';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
@@ -22,8 +30,14 @@ interface Lesson {
|
||||
title: string;
|
||||
content: 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;
|
||||
release_at: string | null;
|
||||
chapters?: VideoChapter[];
|
||||
}
|
||||
|
||||
interface CurriculumEditorProps {
|
||||
@@ -46,7 +60,13 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
title: '',
|
||||
content: '',
|
||||
video_url: '',
|
||||
youtube_url: '',
|
||||
embed_code: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||
release_at: '',
|
||||
chapters: [] as VideoChapter[],
|
||||
});
|
||||
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
||||
@@ -64,7 +84,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
.order('position'),
|
||||
supabase
|
||||
.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'),
|
||||
]);
|
||||
|
||||
@@ -168,7 +188,13 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
title: '',
|
||||
content: '',
|
||||
video_url: '',
|
||||
youtube_url: '',
|
||||
embed_code: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube',
|
||||
release_at: '',
|
||||
chapters: [],
|
||||
});
|
||||
setLessonDialogOpen(true);
|
||||
};
|
||||
@@ -180,7 +206,13 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
title: lesson.title,
|
||||
content: lesson.content || '',
|
||||
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] : '',
|
||||
chapters: lesson.chapters ? [...lesson.chapters] : [], // Create a copy to avoid mutation
|
||||
});
|
||||
setLessonDialogOpen(true);
|
||||
};
|
||||
@@ -196,7 +228,13 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
title: lessonForm.title,
|
||||
content: lessonForm.content || 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,
|
||||
chapters: lessonForm.chapters || [],
|
||||
};
|
||||
|
||||
if (editingLesson) {
|
||||
@@ -432,24 +470,85 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Video URL</Label>
|
||||
<Input
|
||||
value={lessonForm.video_url}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
||||
placeholder="https://youtube.com/... or https://vimeo.com/..."
|
||||
className="border-2"
|
||||
/>
|
||||
<Label>Video Host</Label>
|
||||
<Select
|
||||
value={lessonForm.video_host}
|
||||
onValueChange={(value: 'youtube' | 'adilo') => setLessonForm({ ...lessonForm, video_host: value })}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<Label>Content (HTML)</Label>
|
||||
<Textarea
|
||||
value={lessonForm.content}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, content: e.target.value })}
|
||||
placeholder="Lesson content..."
|
||||
rows={6}
|
||||
className="border-2 font-mono text-sm"
|
||||
<Label>Content</Label>
|
||||
<RichTextEditor
|
||||
content={lessonForm.content}
|
||||
onChange={(html) => setLessonForm({ ...lessonForm, content: html })}
|
||||
placeholder="Write your lesson content here... Use code blocks for syntax highlighting."
|
||||
className="min-h-[400px]"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Supports rich text formatting, code blocks with syntax highlighting, images, and more.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Release Date (optional)</Label>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X } 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 {
|
||||
icon: string;
|
||||
@@ -22,6 +25,8 @@ interface PlatformSettings {
|
||||
brand_favicon_url: string;
|
||||
brand_primary_color: string;
|
||||
brand_accent_color: string;
|
||||
owner_name: string;
|
||||
owner_avatar_url: string;
|
||||
homepage_headline: string;
|
||||
homepage_description: string;
|
||||
homepage_features: HomepageFeature[];
|
||||
@@ -40,6 +45,8 @@ const emptySettings: PlatformSettings = {
|
||||
brand_favicon_url: '',
|
||||
brand_primary_color: '#111827',
|
||||
brand_accent_color: '#0F766E',
|
||||
owner_name: 'Dwindi',
|
||||
owner_avatar_url: '',
|
||||
homepage_headline: 'Learn. Grow. Succeed.',
|
||||
homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.',
|
||||
homepage_features: defaultFeatures,
|
||||
@@ -53,6 +60,7 @@ export function BrandingTab() {
|
||||
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);
|
||||
@@ -91,6 +99,8 @@ export function BrandingTab() {
|
||||
brand_favicon_url: data.brand_favicon_url || '',
|
||||
brand_primary_color: data.brand_primary_color || '#111827',
|
||||
brand_accent_color: data.brand_accent_color || '#0F766E',
|
||||
owner_name: data.owner_name || 'Dwindi',
|
||||
owner_avatar_url: data.owner_avatar_url || '',
|
||||
homepage_headline: data.homepage_headline || emptySettings.homepage_headline,
|
||||
homepage_description: data.homepage_description || emptySettings.homepage_description,
|
||||
homepage_features: features,
|
||||
@@ -109,6 +119,8 @@ export function BrandingTab() {
|
||||
brand_favicon_url: settings.brand_favicon_url,
|
||||
brand_primary_color: settings.brand_primary_color,
|
||||
brand_accent_color: settings.brand_accent_color,
|
||||
owner_name: settings.owner_name,
|
||||
owner_avatar_url: settings.owner_avatar_url,
|
||||
homepage_headline: settings.homepage_headline,
|
||||
homepage_description: settings.homepage_description,
|
||||
homepage_features: settings.homepage_features,
|
||||
@@ -311,6 +323,28 @@ export function BrandingTab() {
|
||||
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" />;
|
||||
|
||||
return (
|
||||
@@ -595,6 +629,54 @@ export function BrandingTab() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="font-semibold mb-4">Identitas Owner</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Nama Owner</Label>
|
||||
<Input
|
||||
value={settings.owner_name}
|
||||
onChange={(e) => setSettings({ ...settings, owner_name: e.target.value })}
|
||||
placeholder="Dwindi"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -20,14 +20,12 @@ interface IntegrationSettings {
|
||||
google_oauth_config?: string;
|
||||
integration_email_provider: 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_terms_url: string;
|
||||
integration_n8n_test_mode: boolean;
|
||||
// Mailketing specific settings
|
||||
provider: 'mailketing' | 'smtp';
|
||||
api_token: string;
|
||||
from_name: string;
|
||||
from_email: string;
|
||||
}
|
||||
|
||||
const emptySettings: IntegrationSettings = {
|
||||
@@ -37,13 +35,12 @@ const emptySettings: IntegrationSettings = {
|
||||
integration_google_calendar_id: '',
|
||||
integration_email_provider: 'mailketing',
|
||||
integration_email_api_base_url: '',
|
||||
integration_email_api_token: '',
|
||||
integration_email_from_name: '',
|
||||
integration_email_from_email: '',
|
||||
integration_privacy_url: '/privacy',
|
||||
integration_terms_url: '/terms',
|
||||
integration_n8n_test_mode: false,
|
||||
provider: 'mailketing',
|
||||
api_token: '',
|
||||
from_name: '',
|
||||
from_email: '',
|
||||
};
|
||||
|
||||
export function IntegrasiTab() {
|
||||
@@ -64,12 +61,6 @@ export function IntegrasiTab() {
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
// Fetch email provider settings from notification_settings
|
||||
const { data: emailData } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (platformData) {
|
||||
setSettings({
|
||||
id: platformData.id,
|
||||
@@ -80,14 +71,12 @@ export function IntegrasiTab() {
|
||||
google_oauth_config: platformData.google_oauth_config || '',
|
||||
integration_email_provider: platformData.integration_email_provider || 'mailketing',
|
||||
integration_email_api_base_url: platformData.integration_email_api_base_url || '',
|
||||
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,
|
||||
// Email settings from notification_settings
|
||||
provider: emailData?.provider || 'mailketing',
|
||||
api_token: emailData?.api_token || '',
|
||||
from_name: emailData?.from_name || platformData.brand_email_from_name || '',
|
||||
from_email: emailData?.from_email || '',
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -97,7 +86,7 @@ export function IntegrasiTab() {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Save platform settings
|
||||
// Save platform settings (includes email settings)
|
||||
const platformPayload = {
|
||||
integration_n8n_base_url: settings.integration_n8n_base_url,
|
||||
integration_whatsapp_number: settings.integration_whatsapp_number,
|
||||
@@ -106,6 +95,9 @@ export function IntegrasiTab() {
|
||||
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,
|
||||
@@ -136,6 +128,9 @@ export function IntegrasiTab() {
|
||||
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,
|
||||
@@ -153,34 +148,6 @@ export function IntegrasiTab() {
|
||||
}
|
||||
}
|
||||
|
||||
// Save email provider settings to notification_settings
|
||||
const emailPayload = {
|
||||
provider: settings.provider,
|
||||
api_token: settings.api_token,
|
||||
from_name: settings.from_name,
|
||||
from_email: settings.from_email,
|
||||
};
|
||||
|
||||
const { data: existingEmailSettings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('id')
|
||||
.maybeSingle();
|
||||
|
||||
if (existingEmailSettings?.id) {
|
||||
const { error: emailError } = await supabase
|
||||
.from('notification_settings')
|
||||
.update(emailPayload)
|
||||
.eq('id', existingEmailSettings.id);
|
||||
|
||||
if (emailError) throw emailError;
|
||||
} else {
|
||||
const { error: emailError } = await supabase
|
||||
.from('notification_settings')
|
||||
.insert(emailPayload);
|
||||
|
||||
if (emailError) throw emailError;
|
||||
}
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' });
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
@@ -195,21 +162,50 @@ export function IntegrasiTab() {
|
||||
|
||||
setSendingTest(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('send-email-v2', {
|
||||
// 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: {
|
||||
to: testEmail,
|
||||
api_token: settings.api_token,
|
||||
from_name: settings.from_name,
|
||||
from_email: settings.from_email,
|
||||
subject: 'Test Email dari Access Hub',
|
||||
html_body: `
|
||||
<h2>Test Email</h2>
|
||||
<p>Ini adalah email uji coba dari aplikasi Access Hub Anda.</p>
|
||||
<p>Jika Anda menerima email ini, konfigurasi Mailketing API sudah berfungsi dengan baik!</p>
|
||||
<p>Kirim ke: ${testEmail}</p>
|
||||
<br>
|
||||
<p>Best regards,<br>Access Hub Team</p>
|
||||
`,
|
||||
template_key: 'test_email',
|
||||
recipient_email: testEmail,
|
||||
recipient_name: 'Admin',
|
||||
variables: {
|
||||
brand_name: brandName,
|
||||
test_email: testEmail
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -228,7 +224,7 @@ export function IntegrasiTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const isEmailConfigured = settings.api_token && settings.from_email;
|
||||
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" />;
|
||||
|
||||
@@ -437,20 +433,19 @@ export function IntegrasiTab() {
|
||||
<div className="space-y-2">
|
||||
<Label>Provider Email</Label>
|
||||
<Select
|
||||
value={settings.provider}
|
||||
onValueChange={(value: 'mailketing' | 'smtp') => setSettings({ ...settings, provider: value })}
|
||||
value={settings.integration_email_provider}
|
||||
onValueChange={(value: 'mailketing') => setSettings({ ...settings, integration_email_provider: value })}
|
||||
>
|
||||
<SelectTrigger className="border-2">
|
||||
<SelectValue placeholder="Pilih provider email" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mailketing">Mailketing</SelectItem>
|
||||
<SelectItem value="smtp">SMTP (Legacy)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{settings.provider === 'mailketing' && (
|
||||
{settings.integration_email_provider === 'mailketing' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -459,8 +454,8 @@ export function IntegrasiTab() {
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.api_token}
|
||||
onChange={(e) => setSettings({ ...settings, api_token: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
@@ -473,8 +468,8 @@ export function IntegrasiTab() {
|
||||
<div className="space-y-2">
|
||||
<Label>Nama Pengirim</Label>
|
||||
<Input
|
||||
value={settings.from_name}
|
||||
onChange={(e) => setSettings({ ...settings, from_name: e.target.value })}
|
||||
value={settings.integration_email_from_name}
|
||||
onChange={(e) => setSettings({ ...settings, integration_email_from_name: e.target.value })}
|
||||
placeholder="Nama Bisnis"
|
||||
className="border-2"
|
||||
/>
|
||||
@@ -483,8 +478,8 @@ export function IntegrasiTab() {
|
||||
<Label>Email Pengirim</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={settings.from_email}
|
||||
onChange={(e) => setSettings({ ...settings, from_email: e.target.value })}
|
||||
value={settings.integration_email_from_email}
|
||||
onChange={(e) => setSettings({ ...settings, integration_email_from_email: e.target.value })}
|
||||
placeholder="info@domain.com"
|
||||
className="border-2"
|
||||
/>
|
||||
@@ -509,21 +504,6 @@ export function IntegrasiTab() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{settings.provider === 'smtp' && (
|
||||
<div className="space-y-2">
|
||||
<Label>API Base URL Provider Email</Label>
|
||||
<Input
|
||||
value={settings.integration_email_api_base_url}
|
||||
onChange={(e) => setSettings({ ...settings, integration_email_api_base_url: e.target.value })}
|
||||
placeholder="https://api.resend.com"
|
||||
className="border-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Konfigurasi SMTP masih di bagian Notifikasi
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface NotificationTemplate {
|
||||
const RELEVANT_SHORTCODES = {
|
||||
'payment_success': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{produk}', '{link_akses}', '{thank_you_page}'],
|
||||
'access_granted': ['{nama}', '{email}', '{produk}', '{link_akses}', '{username_akses}', '{password_akses}', '{kadaluarsa_akses}'],
|
||||
'order_created': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{produk}', '{payment_link}', '{thank_you_page}'],
|
||||
'order_created': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{produk}', '{payment_link}', '{thank_you_page}', '{qr_code_image}', '{qr_expiry_time}'],
|
||||
'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}'],
|
||||
@@ -143,6 +143,16 @@ const DEFAULT_TEMPLATES: { key: string; name: string; defaultSubject: string; de
|
||||
</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>
|
||||
@@ -484,37 +494,30 @@ export function NotifikasiTab() {
|
||||
|
||||
setTestingTemplate(template.id);
|
||||
try {
|
||||
// Fetch email settings from notification_settings
|
||||
const { data: emailData } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
// Fetch platform settings to get brand name
|
||||
const { data: platformData } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('brand_name')
|
||||
.single();
|
||||
|
||||
if (!emailData || !emailData.api_token || !emailData.from_email) {
|
||||
throw new Error('Konfigurasi email provider belum lengkap');
|
||||
}
|
||||
const brandName = platformData?.brand_name || 'ACCESS HUB';
|
||||
|
||||
// Import EmailTemplateRenderer and ShortcodeProcessor
|
||||
const { EmailTemplateRenderer, ShortcodeProcessor } = await import('@/lib/email-templates/master-template');
|
||||
// Import ShortcodeProcessor to get dummy data
|
||||
const { ShortcodeProcessor } = await import('@/lib/email-templates/master-template');
|
||||
|
||||
// Process shortcodes and render with master template
|
||||
const processedSubject = ShortcodeProcessor.process(template.email_subject || '');
|
||||
const processedContent = ShortcodeProcessor.process(template.email_body_html || '');
|
||||
const fullHtml = EmailTemplateRenderer.render({
|
||||
subject: processedSubject,
|
||||
content: processedContent,
|
||||
brandName: 'ACCESS HUB'
|
||||
});
|
||||
// Get default dummy data for all template variables
|
||||
const dummyData = ShortcodeProcessor.getDummyData();
|
||||
|
||||
// Send test email using send-email-v2
|
||||
const { data, error } = await supabase.functions.invoke('send-email-v2', {
|
||||
// Send test email using send-notification (same as IntegrasiTab)
|
||||
const { data, error } = await supabase.functions.invoke('send-notification', {
|
||||
body: {
|
||||
to: template.test_email,
|
||||
api_token: emailData.api_token,
|
||||
from_name: emailData.from_name,
|
||||
from_email: emailData.from_email,
|
||||
subject: processedSubject,
|
||||
html_body: fullHtml,
|
||||
template_key: template.key,
|
||||
recipient_email: template.test_email,
|
||||
recipient_name: dummyData.nama,
|
||||
variables: {
|
||||
...dummyData,
|
||||
platform_name: brandName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,27 +4,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { id } from 'date-fns/locale';
|
||||
import { ReviewModal } from './ReviewModal';
|
||||
|
||||
interface ConsultingSlot {
|
||||
interface ConsultingSession {
|
||||
id: string;
|
||||
date: string;
|
||||
session_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
topic_category: string | null;
|
||||
meet_link: string | null;
|
||||
order_id: string | null;
|
||||
}
|
||||
|
||||
interface GroupedOrder {
|
||||
orderId: string | null;
|
||||
slots: ConsultingSlot[];
|
||||
firstDate: string;
|
||||
meetLink: string | null;
|
||||
total_blocks: number;
|
||||
}
|
||||
|
||||
interface ConsultingHistoryProps {
|
||||
@@ -32,7 +26,7 @@ interface ConsultingHistoryProps {
|
||||
}
|
||||
|
||||
export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
const [slots, setSlots] = useState<ConsultingSlot[]>([]);
|
||||
const [sessions, setSessions] = useState<ConsultingSession[]>([]);
|
||||
const [reviewedOrderIds, setReviewedOrderIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [reviewModal, setReviewModal] = useState<{
|
||||
@@ -46,18 +40,18 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
}, [userId]);
|
||||
|
||||
const fetchData = async () => {
|
||||
// Fetch consulting slots
|
||||
const { data: slotsData } = await supabase
|
||||
.from('consulting_slots')
|
||||
.select('id, date, start_time, end_time, status, topic_category, meet_link, order_id')
|
||||
// Fetch consulting sessions
|
||||
const { data: sessionsData } = await supabase
|
||||
.from('consulting_sessions')
|
||||
.select('id, session_date, start_time, end_time, status, topic_category, meet_link, order_id, total_blocks')
|
||||
.eq('user_id', userId)
|
||||
.order('date', { ascending: false });
|
||||
.order('session_date', { ascending: false });
|
||||
|
||||
if (slotsData) {
|
||||
setSlots(slotsData);
|
||||
if (sessionsData) {
|
||||
setSessions(sessionsData);
|
||||
|
||||
// Check which orders have been reviewed
|
||||
const orderIds = slotsData
|
||||
const orderIds = sessionsData
|
||||
.filter(s => s.order_id)
|
||||
.map(s => s.order_id as string);
|
||||
|
||||
@@ -78,26 +72,6 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Group slots by order_id
|
||||
const groupedOrders: GroupedOrder[] = (() => {
|
||||
const groups = new Map<string | null, ConsultingSlot[]>();
|
||||
|
||||
slots.forEach(slot => {
|
||||
const orderId = slot.order_id || 'no-order';
|
||||
if (!groups.has(orderId)) {
|
||||
groups.set(orderId, []);
|
||||
}
|
||||
groups.get(orderId)!.push(slot);
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([orderId, slots]) => ({
|
||||
orderId: orderId === 'no-order' ? null : orderId,
|
||||
slots,
|
||||
firstDate: slots[0].date,
|
||||
meetLink: slots[0].meet_link, // Use meet_link from first slot
|
||||
})).sort((a, b) => new Date(b.firstDate).getTime() - new Date(a.firstDate).getTime());
|
||||
})();
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
@@ -113,14 +87,18 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const openReviewModal = (order: GroupedOrder) => {
|
||||
const firstSlot = order.slots[0];
|
||||
const lastSlot = order.slots[order.slots.length - 1];
|
||||
const dateLabel = format(new Date(firstSlot.date), 'd MMMM yyyy', { locale: id });
|
||||
const timeLabel = `${firstSlot.start_time.substring(0, 5)} - ${lastSlot.end_time.substring(0, 5)}`;
|
||||
// Check if session has passed
|
||||
const isSessionPassed = (session: ConsultingSession) => {
|
||||
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({
|
||||
open: true,
|
||||
orderId: order.orderId,
|
||||
orderId: session.order_id,
|
||||
label: `Sesi konsultasi ${dateLabel}, ${timeLabel}`,
|
||||
});
|
||||
};
|
||||
@@ -132,8 +110,32 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const doneOrders = groupedOrders.filter(o => o.slots.every(s => s.status === 'done'));
|
||||
const upcomingOrders = groupedOrders.filter(o => o.slots.some(s => s.status === 'confirmed'));
|
||||
// Generate Google Calendar link for adding to user's calendar
|
||||
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) {
|
||||
return (
|
||||
@@ -152,7 +154,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (slots.length === 0) {
|
||||
if (sessions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -167,68 +169,106 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Upcoming sessions */}
|
||||
{upcomingOrders.length > 0 && (
|
||||
{upcomingSessions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Sesi Mendatang</h4>
|
||||
{upcomingOrders.map((order) => {
|
||||
const firstSlot = order.slots[0];
|
||||
const lastSlot = order.slots[order.slots.length - 1];
|
||||
return (
|
||||
<div key={order.orderId || 'no-order'} className="flex items-center justify-between p-3 border-2 border-border bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{format(new Date(firstSlot.date), 'd MMM yyyy', { locale: id })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}
|
||||
{firstSlot.topic_category && ` • ${firstSlot.topic_category}`}
|
||||
</p>
|
||||
</div>
|
||||
{upcomingSessions.map((session) => (
|
||||
<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">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
<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">
|
||||
<Clock className="w-3 h-3" />
|
||||
{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}
|
||||
{session.topic_category && ` • ${session.topic_category}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(firstSlot.status)}
|
||||
{order.meetLink && (
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(session.status)}
|
||||
{session.meet_link && (
|
||||
<>
|
||||
<Button asChild size="sm" variant="outline" className="border-2">
|
||||
<a href={order.meetLink} target="_blank" rel="noopener noreferrer">
|
||||
<a href={session.meet_link} target="_blank" rel="noopener noreferrer">
|
||||
Join
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{doneOrders.length > 0 && (
|
||||
{doneSessions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Sesi Selesai</h4>
|
||||
{doneOrders.map((order) => {
|
||||
const firstSlot = order.slots[0];
|
||||
const lastSlot = order.slots[order.slots.length - 1];
|
||||
const hasReviewed = order.orderId ? reviewedOrderIds.has(order.orderId) : false;
|
||||
{doneSessions.map((session) => {
|
||||
const hasReviewed = session.order_id ? reviewedOrderIds.has(session.order_id) : false;
|
||||
return (
|
||||
<div key={order.orderId || 'no-order'} 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">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{format(new Date(firstSlot.date), 'd MMM yyyy', { locale: id })}
|
||||
{format(new Date(session.session_date), 'd MMM yyyy', { locale: id })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}
|
||||
{firstSlot.topic_category && ` • ${firstSlot.topic_category}`}
|
||||
{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(firstSlot.status)}
|
||||
{getStatusBadge(session.status)}
|
||||
{hasReviewed ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-accent" />
|
||||
@@ -238,7 +278,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openReviewModal(order)}
|
||||
onClick={() => openReviewModal(session)}
|
||||
className="border-2"
|
||||
>
|
||||
<Star className="w-4 h-4 mr-1" />
|
||||
|
||||
@@ -15,6 +15,12 @@ interface ReviewModalProps {
|
||||
orderId?: string | null;
|
||||
type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
|
||||
contextLabel?: string;
|
||||
existingReview?: {
|
||||
id: string;
|
||||
rating: number;
|
||||
title?: string;
|
||||
body?: string;
|
||||
};
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +32,7 @@ export function ReviewModal({
|
||||
orderId,
|
||||
type,
|
||||
contextLabel,
|
||||
existingReview,
|
||||
onSuccess,
|
||||
}: ReviewModalProps) {
|
||||
const [rating, setRating] = useState(0);
|
||||
@@ -34,6 +41,20 @@ export function ReviewModal({
|
||||
const [body, setBody] = useState('');
|
||||
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 () => {
|
||||
if (rating === 0) {
|
||||
toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' });
|
||||
@@ -45,22 +66,46 @@ export function ReviewModal({
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const { error } = 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,
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
if (existingReview) {
|
||||
// Update existing review
|
||||
const result = await supabase
|
||||
.from('reviews')
|
||||
.update({
|
||||
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) {
|
||||
console.error('Review submit error:', error);
|
||||
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
|
||||
} 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
|
||||
setRating(0);
|
||||
setTitle('');
|
||||
@@ -81,7 +126,7 @@ export function ReviewModal({
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Beri Ulasan</DialogTitle>
|
||||
<DialogTitle>{existingReview ? 'Edit Ulasan' : 'Beri Ulasan'}</DialogTitle>
|
||||
{contextLabel && (
|
||||
<DialogDescription>{contextLabel}</DialogDescription>
|
||||
)}
|
||||
@@ -140,7 +185,7 @@ export function ReviewModal({
|
||||
Batal
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? 'Mengirim...' : 'Kirim Ulasan'}
|
||||
{submitting ? 'Menyimpan...' : (existingReview ? 'Simpan Perubahan' : 'Kirim Ulasan')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
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;
|
||||
isAdmin: boolean;
|
||||
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>;
|
||||
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);
|
||||
@@ -21,31 +24,55 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
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(
|
||||
(event, session) => {
|
||||
if (!mounted) return;
|
||||
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
|
||||
|
||||
if (session?.user) {
|
||||
setTimeout(() => {
|
||||
checkAdminRole(session.user.id);
|
||||
}, 0);
|
||||
// Wait for admin role check
|
||||
checkAdminRole(session.user.id).then(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setIsAdmin(false);
|
||||
// No session, set loading to false immediately
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
if (session?.user) {
|
||||
checkAdminRole(session.user.id);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkAdminRole = async (userId: string) => {
|
||||
@@ -55,8 +82,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
.eq('user_id', userId)
|
||||
.eq('role', 'admin')
|
||||
.maybeSingle();
|
||||
|
||||
|
||||
setIsAdmin(!!data);
|
||||
return !!data; // Return the result
|
||||
};
|
||||
|
||||
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 redirectUrl = `${window.location.origin}/`;
|
||||
const { error } = await supabase.auth.signUp({
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
@@ -74,15 +102,112 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
data: { name }
|
||||
}
|
||||
});
|
||||
return { error };
|
||||
return { error, data };
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
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 (
|
||||
<AuthContext.Provider value={{ user, session, loading, isAdmin, signIn, signUp, signOut }}>
|
||||
<AuthContext.Provider value={{ user, session, loading, isAdmin, signIn, signUp, signOut, sendAuthOTP, verifyAuthOTP, getUserByEmail }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
166
src/index.css
166
src/index.css
@@ -226,4 +226,170 @@ All colors MUST be HSL.
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export const getPaymentStatusColor = (status: PaymentStatus | string | null): st
|
||||
export const getConsultingSlotStatusLabel = (status: ConsultingSlotStatus | string): string => {
|
||||
switch (status) {
|
||||
case 'pending_payment':
|
||||
return 'Menunggu Pembayaran';
|
||||
return 'Pending';
|
||||
case 'confirmed':
|
||||
return 'Terkonfirmasi';
|
||||
case 'completed':
|
||||
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -7,28 +7,52 @@ import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft, Mail } from 'lucide-react';
|
||||
|
||||
const emailSchema = z.string().email('Invalid email address');
|
||||
const passwordSchema = z.string().min(6, 'Password must be at least 6 characters');
|
||||
|
||||
export default function Auth() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [showOTP, setShowOTP] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
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 location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
try {
|
||||
emailSchema.parse(email);
|
||||
passwordSchema.parse(password);
|
||||
@@ -44,9 +68,51 @@ export default function Auth() {
|
||||
if (isLogin) {
|
||||
const { error } = await signIn(email, password);
|
||||
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' });
|
||||
setLoading(false);
|
||||
} 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 {
|
||||
if (!name.trim()) {
|
||||
@@ -54,16 +120,98 @@ export default function Auth() {
|
||||
setLoading(false);
|
||||
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.message.includes('already registered')) {
|
||||
toast({ title: 'Error', description: 'This email is already registered. Please login instead.', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
}
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Check your email to confirm your account' });
|
||||
setLoading(false);
|
||||
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);
|
||||
@@ -71,66 +219,151 @@ export default function Auth() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{isLogin ? 'Login' : 'Sign Up'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isLogin ? 'Enter your credentials to access your account' : 'Create a new account to get started'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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 className="w-full max-w-md space-y-4">
|
||||
{/* Back to Home Button */}
|
||||
<Link to="/">
|
||||
<Button variant="ghost" className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Kembali ke Beranda
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{!showOTP ? (
|
||||
<Card className="border-2 border-border shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{isLogin ? 'Login' : 'Daftar'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isLogin ? 'Masuk untuk mengakses akun Anda' : 'Buat akun baru untuk memulai'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!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>
|
||||
)}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Login'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -12,6 +12,14 @@ import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, Ch
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
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 {
|
||||
id: string;
|
||||
@@ -31,9 +39,15 @@ interface Lesson {
|
||||
title: string;
|
||||
content: 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;
|
||||
position: number;
|
||||
release_at: string | null;
|
||||
chapters?: VideoChapter[];
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
@@ -50,8 +64,178 @@ interface UserReview {
|
||||
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() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { slug, lessonId } = useParams<{ slug: string; lessonId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
|
||||
@@ -64,6 +248,9 @@ export default function Bootcamp() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [accentColor, setAccentColor] = useState<string>('');
|
||||
const playerRef = useRef<VideoPlayerRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
@@ -89,6 +276,16 @@ export default function Bootcamp() {
|
||||
|
||||
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
|
||||
.from('user_access')
|
||||
.select('id')
|
||||
@@ -113,9 +310,15 @@ export default function Bootcamp() {
|
||||
title,
|
||||
content,
|
||||
video_url,
|
||||
youtube_url,
|
||||
embed_code,
|
||||
m3u8_url,
|
||||
mp4_url,
|
||||
video_host,
|
||||
duration_seconds,
|
||||
position,
|
||||
release_at
|
||||
release_at,
|
||||
chapters
|
||||
)
|
||||
`)
|
||||
.eq('product_id', productData.id)
|
||||
@@ -128,7 +331,20 @@ export default function Bootcamp() {
|
||||
}));
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +380,12 @@ export default function Bootcamp() {
|
||||
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 () => {
|
||||
if (!selectedLesson || !user || !product) return;
|
||||
|
||||
@@ -182,11 +404,12 @@ export default function Bootcamp() {
|
||||
|
||||
const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }];
|
||||
setProgress(newProgress);
|
||||
|
||||
|
||||
// Calculate completion percentage for notification
|
||||
const completedCount = newProgress.length;
|
||||
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||
const completionPercent = Math.round((completedCount / totalLessons) * 100);
|
||||
|
||||
|
||||
// Trigger progress notification at milestones
|
||||
if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) {
|
||||
try {
|
||||
@@ -230,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 totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
||||
@@ -249,7 +464,7 @@ export default function Bootcamp() {
|
||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{module.title}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 ml-2">
|
||||
{module.lessons.map((lesson) => {
|
||||
const isCompleted = isLessonCompleted(lesson.id);
|
||||
const isSelected = selectedLesson?.id === lesson.id;
|
||||
@@ -260,7 +475,7 @@ export default function Bootcamp() {
|
||||
key={lesson.id}
|
||||
onClick={() => {
|
||||
if (isReleased) {
|
||||
setSelectedLesson(lesson);
|
||||
handleSelectLesson(lesson);
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
@@ -273,7 +488,7 @@ export default function Bootcamp() {
|
||||
>
|
||||
{isCompleted ? (
|
||||
<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" />
|
||||
) : (
|
||||
<BookOpen className="w-4 h-4 shrink-0" />
|
||||
@@ -382,23 +597,29 @@ export default function Bootcamp() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedLesson.video_url && (
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
||||
<iframe
|
||||
src={getVideoEmbed(selectedLesson.video_url)}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<VideoPlayer
|
||||
lesson={selectedLesson}
|
||||
playerRef={playerRef}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
setCurrentTime={setCurrentTime}
|
||||
/>
|
||||
|
||||
{selectedLesson.content && (
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: selectedLesson.content }}
|
||||
className="prose prose-slate max-w-none"
|
||||
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>
|
||||
</Card>
|
||||
@@ -542,6 +763,12 @@ export default function Bootcamp() {
|
||||
productId={product.id}
|
||||
type="bootcamp"
|
||||
contextLabel={product.title}
|
||||
existingReview={userReview ? {
|
||||
id: userReview.id,
|
||||
rating: userReview.rating,
|
||||
title: userReview.title,
|
||||
body: userReview.body,
|
||||
} : undefined}
|
||||
onSuccess={() => {
|
||||
// Refresh review data
|
||||
const refreshReview = async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AppLayout } from "@/components/AppLayout";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
@@ -6,9 +6,13 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { formatIDR } from "@/lib/format";
|
||||
import { Trash2, CreditCard, Loader2, QrCode } from "lucide-react";
|
||||
import { Trash2, CreditCard, Loader2, QrCode, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
// Edge function base URL - configurable via env with sensible default
|
||||
const getEdgeFunctionBaseUrl = (): string => {
|
||||
@@ -21,12 +25,23 @@ type CheckoutStep = "cart" | "payment";
|
||||
|
||||
export default function Checkout() {
|
||||
const { items, removeItem, clearCart, total } = useCart();
|
||||
const { user } = useAuth();
|
||||
const { user, signIn, signUp, sendAuthOTP, verifyAuthOTP } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [step, setStep] = useState<CheckoutStep>("cart");
|
||||
|
||||
// Auth modal state
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [authEmail, setAuthEmail] = useState("");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [authName, setAuthName] = useState("");
|
||||
const [showOTP, setShowOTP] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [pendingUserId, setPendingUserId] = useState<string | null>(null);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
const checkPaymentStatus = async (oid: string) => {
|
||||
const { data: order } = await supabase.from("orders").select("payment_status").eq("id", oid).single();
|
||||
|
||||
@@ -39,7 +54,8 @@ export default function Checkout() {
|
||||
const handleCheckout = async () => {
|
||||
if (!user) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -89,6 +105,42 @@ export default function Checkout() {
|
||||
const { error: itemsError } = await supabase.from("order_items").insert(orderItems);
|
||||
if (itemsError) throw new Error("Gagal menambahkan item order");
|
||||
|
||||
// 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);
|
||||
|
||||
try {
|
||||
const result = await supabase.functions.invoke('send-notification', {
|
||||
body: {
|
||||
template_key: 'order_created',
|
||||
recipient_email: user.email,
|
||||
recipient_name: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan',
|
||||
variables: {
|
||||
nama: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan',
|
||||
email: user.email,
|
||||
order_id: order.id,
|
||||
order_id_short: order.id.substring(0, 8),
|
||||
tanggal_pesanan: new Date().toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}),
|
||||
total: formatIDR(total),
|
||||
metode_pembayaran: 'QRIS',
|
||||
produk: items.map(item => item.title).join(', '),
|
||||
payment_link: `${window.location.origin}/orders/${order.id}`,
|
||||
thank_you_page: `${window.location.origin}/orders/${order.id}`
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('[CHECKOUT] send-notification called successfully:', result);
|
||||
} catch (emailErr) {
|
||||
console.error('[CHECKOUT] Failed to send order_created email:', emailErr);
|
||||
// Don't block checkout flow if email fails
|
||||
}
|
||||
|
||||
console.log('[CHECKOUT] Order creation email call completed');
|
||||
|
||||
// Build description from product titles
|
||||
const productTitles = items.map(item => item.title).join(", ");
|
||||
|
||||
@@ -127,6 +179,168 @@ export default function Checkout() {
|
||||
toast({ title: "Info", description: "Status pembayaran diupdate otomatis" });
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!authEmail || !authPassword) {
|
||||
toast({ title: "Error", description: "Email dan password wajib diisi", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
const { error } = await signIn(authEmail, authPassword);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Login gagal",
|
||||
description: error.message || "Email atau password salah",
|
||||
variant: "destructive",
|
||||
});
|
||||
setAuthLoading(false);
|
||||
} else {
|
||||
toast({ title: "Login berhasil", description: "Silakan lanjutkan pembayaran" });
|
||||
setAuthModalOpen(false);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!authEmail || !authPassword || !authName) {
|
||||
toast({ title: "Error", description: "Semua field wajib diisi", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (authPassword.length < 6) {
|
||||
toast({ title: "Error", description: "Password minimal 6 karakter", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
|
||||
try {
|
||||
const { data, error } = await signUp(authEmail, authPassword, authName);
|
||||
|
||||
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 (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -192,21 +406,208 @@ export default function Checkout() {
|
||||
<span className="font-bold">{formatIDR(total)}</span>
|
||||
</div>
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : user ? (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Bayar dengan QRIS
|
||||
</>
|
||||
) : (
|
||||
"Login untuk Checkout"
|
||||
)}
|
||||
</Button>
|
||||
{user ? (
|
||||
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Bayar dengan QRIS
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,8 @@ interface Workhour {
|
||||
end_time: string;
|
||||
}
|
||||
|
||||
interface ConfirmedSlot {
|
||||
date: string;
|
||||
interface ConfirmedSession {
|
||||
session_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
}
|
||||
@@ -67,12 +67,13 @@ export default function ConsultingBooking() {
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
|
||||
|
||||
// NEW: Range selection instead of array
|
||||
// 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 [notes, setNotes] = useState('');
|
||||
@@ -81,6 +82,31 @@ export default function ConsultingBooking() {
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
@@ -106,9 +132,9 @@ export default function ConsultingBooking() {
|
||||
const fetchConfirmedSlots = async (date: Date) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
const { data } = await supabase
|
||||
.from('consulting_slots')
|
||||
.select('date, start_time, end_time')
|
||||
.eq('date', dateStr)
|
||||
.from('consulting_sessions')
|
||||
.select('session_date, start_time, end_time')
|
||||
.eq('session_date', dateStr)
|
||||
.in('status', ['pending_payment', 'confirmed']);
|
||||
|
||||
if (data) setConfirmedSlots(data);
|
||||
@@ -192,7 +218,12 @@ export default function ConsultingBooking() {
|
||||
}, [selectedDate, workhours, confirmedSlots, webinars, settings]);
|
||||
|
||||
// Helper: Get all slots between start and end (inclusive)
|
||||
// Now supports single slot selection where start = end
|
||||
const getSlotsInRange = useMemo(() => {
|
||||
// If there's a pending slot but no confirmed range, don't show any slots as selected
|
||||
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);
|
||||
@@ -203,65 +234,74 @@ export default function ConsultingBooking() {
|
||||
return availableSlots
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.map(s => s.start);
|
||||
}, [selectedRange, availableSlots]);
|
||||
}, [selectedRange, availableSlots, pendingSlot]);
|
||||
|
||||
// NEW: Range selection handler
|
||||
// Range selection handler with pending slot UX
|
||||
const handleSlotClick = (slotStart: string) => {
|
||||
const slot = availableSlots.find(s => s.start === slotStart);
|
||||
if (!slot || !slot.available) return;
|
||||
|
||||
setSelectedRange(prev => {
|
||||
// CASE 1: No selection yet → Set start time
|
||||
if (!prev.start) {
|
||||
return { start: slotStart, end: null };
|
||||
}
|
||||
|
||||
// CASE 2: Only start selected → Set end time
|
||||
if (!prev.end) {
|
||||
if (slotStart === prev.start) {
|
||||
// Clicked same slot → Clear selection
|
||||
return { start: null, end: null };
|
||||
}
|
||||
// Ensure end is after start
|
||||
const startIndex = availableSlots.findIndex(s => s.start === prev.start);
|
||||
// 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 < startIndex) {
|
||||
// Clicked before start → Make new start, old start becomes end
|
||||
return { start: slotStart, end: prev.start };
|
||||
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 });
|
||||
}
|
||||
|
||||
return { start: prev.start, end: slotStart };
|
||||
setPendingSlot(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// CASE 3: Both selected (changing range)
|
||||
const startIndex = availableSlots.findIndex(s => s.start === prev.start);
|
||||
const endIndex = availableSlots.findIndex(s => s.start === prev.end);
|
||||
// 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 === prev.start) {
|
||||
return { start: null, end: null };
|
||||
if (slotStart === selectedRange.start) {
|
||||
setSelectedRange({ start: null, end: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked end time → Update end
|
||||
if (slotStart === prev.end) {
|
||||
return { start: prev.start, end: null };
|
||||
// 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) {
|
||||
return { start: slotStart, end: prev.start };
|
||||
setSelectedRange({ start: slotStart, end: selectedRange.start });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked after end → New end
|
||||
if (clickIndex > endIndex) {
|
||||
return { start: prev.start, end: slotStart };
|
||||
setSelectedRange({ start: selectedRange.start, end: slotStart });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked within range → Update end to clicked slot
|
||||
return { start: prev.start, end: slotStart };
|
||||
});
|
||||
setSelectedRange({ start: selectedRange.start, end: slotStart });
|
||||
return;
|
||||
}
|
||||
|
||||
// No selection at all → Set as pending
|
||||
setPendingSlot(slotStart);
|
||||
};
|
||||
|
||||
// Calculate total blocks from range
|
||||
@@ -316,26 +356,55 @@ export default function ConsultingBooking() {
|
||||
|
||||
if (orderError) throw orderError;
|
||||
|
||||
// Create consulting slots
|
||||
const slotsToInsert = getSlotsInRange.map(slotStart => {
|
||||
// Create consulting session and time slots
|
||||
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(
|
||||
addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes),
|
||||
'HH:mm'
|
||||
);
|
||||
return {
|
||||
user_id: user.id,
|
||||
order_id: order.id,
|
||||
date: format(selectedDate, 'yyyy-MM-dd'),
|
||||
session_id: session.id,
|
||||
slot_date: format(selectedDate, 'yyyy-MM-dd'),
|
||||
start_time: slotStart + ':00',
|
||||
end_time: slotEnd + ':00',
|
||||
status: 'pending_payment',
|
||||
topic_category: selectedCategory,
|
||||
notes: notes,
|
||||
is_available: false,
|
||||
booked_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const { error: slotsError } = await supabase.from('consulting_slots').insert(slotsToInsert);
|
||||
if (slotsError) throw slotsError;
|
||||
const { error: timeSlotsError } = await supabase.from('consulting_time_slots').insert(timeSlotsToInsert);
|
||||
if (timeSlotsError) throw timeSlotsError;
|
||||
|
||||
// Call edge function to create payment with QR code
|
||||
const { data: paymentData, error: paymentError } = await supabase.functions.invoke('create-payment', {
|
||||
@@ -447,7 +516,7 @@ export default function ConsultingBooking() {
|
||||
Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Klik slot awal dan akhir untuk memilih rentang waktu. {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
|
||||
@@ -464,6 +533,7 @@ export default function ConsultingBooking() {
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-0">
|
||||
{availableSlots.map((slot, index) => {
|
||||
const isSelected = getSlotsInRange.includes(slot.start);
|
||||
const isPending = slot.start === pendingSlot;
|
||||
const isStart = slot.start === selectedRange.start;
|
||||
const isEnd = slot.start === selectedRange.end;
|
||||
const isMiddle = isSelected && !isStart && !isEnd;
|
||||
@@ -475,6 +545,11 @@ export default function ConsultingBooking() {
|
||||
// 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]
|
||||
@@ -491,14 +566,15 @@ export default function ConsultingBooking() {
|
||||
return (
|
||||
<Button
|
||||
key={slot.start}
|
||||
variant={variant}
|
||||
variant={isPending ? "default" : variant}
|
||||
disabled={!slot.available}
|
||||
onClick={() => slot.available && handleSlotClick(slot.start)}
|
||||
className={className}
|
||||
>
|
||||
{isStart && <span className="text-xs opacity-70">Mulai</span>}
|
||||
{!isStart && !isEnd && slot.start}
|
||||
{isEnd && <span className="text-xs opacity-70">Selesai</span>}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
@@ -599,7 +675,13 @@ export default function ConsultingBooking() {
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Selesai</p>
|
||||
<p className="font-bold text-lg">{selectedRange.end}</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>
|
||||
|
||||
@@ -610,6 +692,21 @@ export default function ConsultingBooking() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingSlot && !selectedRange.start && (
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground mb-2">Slot dipilih:</p>
|
||||
|
||||
{/* Show pending slot */}
|
||||
<div className="bg-amber-500/10 p-3 rounded-lg border-2 border-amber-500/20">
|
||||
<div className="text-center">
|
||||
<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 className="pt-4 border-t">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function Dashboard() {
|
||||
<p className="text-sm text-muted-foreground">{formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge className={`${getStatusColor(order.payment_status || order.status)} rounded-full`}>
|
||||
<Badge className={`${getPaymentStatusColor(order.payment_status || order.status)} rounded-full`}>
|
||||
{getPaymentStatusLabel(order.payment_status || order.status)}
|
||||
</Badge>
|
||||
<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 { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
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 { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||
import { ProductReviews } from '@/components/reviews/ProductReviews';
|
||||
import { useOwnerIdentity } from '@/hooks/useOwnerIdentity';
|
||||
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
@@ -26,9 +29,14 @@ interface Product {
|
||||
sale_price: number | null;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
m3u8_url: string | null;
|
||||
mp4_url: string | null;
|
||||
video_host: 'youtube' | 'adilo' | 'unknown' | null;
|
||||
event_start: string | null;
|
||||
duration_minutes: number | null;
|
||||
chapters?: { time: number; title: string; }[];
|
||||
created_at: string;
|
||||
collaborator_user_id?: string | null;
|
||||
}
|
||||
|
||||
interface Module {
|
||||
@@ -43,6 +51,7 @@ interface Lesson {
|
||||
title: string;
|
||||
duration_seconds: number | null;
|
||||
position: number;
|
||||
chapters?: { time: number; title: string; }[];
|
||||
}
|
||||
|
||||
interface UserReview {
|
||||
@@ -63,10 +72,13 @@ export default function ProductDetail() {
|
||||
const [hasAccess, setHasAccess] = useState(false);
|
||||
const [checkingAccess, setCheckingAccess] = useState(true);
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
||||
const [expandedLessonChapters, setExpandedLessonChapters] = useState<Set<string>>(new Set());
|
||||
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||
const [collaborator, setCollaborator] = useState<{ name: string; avatar_url: string | null } | null>(null);
|
||||
const { addItem, items } = useCart();
|
||||
const { user } = useAuth();
|
||||
const { owner } = useOwnerIdentity();
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) fetchProduct();
|
||||
@@ -87,6 +99,28 @@ export default function ProductDetail() {
|
||||
}
|
||||
}, [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 { data, error } = await supabase
|
||||
.from('products')
|
||||
@@ -105,7 +139,7 @@ export default function ProductDetail() {
|
||||
|
||||
const fetchCurriculum = async () => {
|
||||
if (!product) return;
|
||||
|
||||
|
||||
const { data: modulesData } = await supabase
|
||||
.from('bootcamp_modules')
|
||||
.select(`
|
||||
@@ -116,7 +150,8 @@ export default function ProductDetail() {
|
||||
id,
|
||||
title,
|
||||
duration_seconds,
|
||||
position
|
||||
position,
|
||||
chapters
|
||||
)
|
||||
`)
|
||||
.eq('product_id', product.id)
|
||||
@@ -132,6 +167,9 @@ export default function ProductDetail() {
|
||||
if (sorted.length > 0) {
|
||||
setExpandedModules(new Set([sorted[0].id]));
|
||||
}
|
||||
|
||||
// Keep all lesson timelines collapsed by default for cleaner UX
|
||||
setExpandedLessonChapters(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -215,6 +253,53 @@ export default function ProductDetail() {
|
||||
|
||||
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 youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||
@@ -235,6 +320,22 @@ export default function ProductDetail() {
|
||||
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) {
|
||||
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>);
|
||||
}
|
||||
@@ -265,23 +366,28 @@ export default function ProductDetail() {
|
||||
</Button>
|
||||
);
|
||||
case 'webinar':
|
||||
if (product.recording_url) {
|
||||
if (hasRecording()) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||
<iframe
|
||||
src={getVideoEmbed(product.recording_url)}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="border-2">
|
||||
<a href={product.recording_url} target="_blank" rel="noopener noreferrer">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
</a>
|
||||
</Button>
|
||||
<Card className="border-2 border-primary/20 bg-primary/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<Play className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-1">Rekaman webinar tersedia</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Akses rekaman webinar kapan saja. Pelajari materi sesuai kecepatan Anda.
|
||||
</p>
|
||||
<Button onClick={() => navigate(`/webinar/${product.slug}`)} size="lg">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Sekarang
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -352,15 +458,55 @@ export default function ProductDetail() {
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<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) => (
|
||||
<div key={lesson.id} className="flex items-center justify-between py-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="w-3 h-3 text-muted-foreground" />
|
||||
<span>{lesson.title}</span>
|
||||
<div key={lesson.id} className="space-y-2">
|
||||
{/* Lesson header */}
|
||||
<div className="flex items-center justify-between py-1 text-sm">
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
@@ -378,21 +524,81 @@ export default function ProductDetail() {
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<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>
|
||||
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge>
|
||||
{product.type === 'webinar' && product.recording_url && (
|
||||
{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' && !product.recording_url && product.event_start && new Date(product.event_start) > new Date() && (
|
||||
{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' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && (
|
||||
{product.type === 'webinar' && !hasRecording() && product.event_start && new Date(product.event_start) <= new Date() && (
|
||||
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
||||
)}
|
||||
{hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</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 className="text-right">
|
||||
@@ -424,7 +630,7 @@ export default function ProductDetail() {
|
||||
{product.content && (
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: product.content }}
|
||||
/>
|
||||
@@ -432,6 +638,8 @@ export default function ProductDetail() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{renderWebinarChapters()}
|
||||
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { AppLayout } from '@/components/AppLayout';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { formatIDR } from '@/lib/format';
|
||||
import { Video, Package, Check, Search, X } 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 {
|
||||
id: string;
|
||||
@@ -21,6 +24,13 @@ interface Product {
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
is_active: boolean;
|
||||
collaborator_user_id?: string | null;
|
||||
}
|
||||
|
||||
interface CollaboratorProfile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface ConsultingSettings {
|
||||
@@ -35,7 +45,9 @@ export default function Products() {
|
||||
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 { owner } = useOwnerIdentity();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -57,7 +69,33 @@ export default function Products() {
|
||||
if (productsRes.error) {
|
||||
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
|
||||
} 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) {
|
||||
@@ -105,7 +143,7 @@ export default function Products() {
|
||||
});
|
||||
|
||||
// Get unique product types for filter
|
||||
const productTypes = ['all', ...Array.from(new Set(products.map(p => p.type)))];
|
||||
const productTypes: string[] = ['all', ...Array.from(new Set(products.map(p => p.type as string)))];
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
@@ -118,21 +156,6 @@ export default function Products() {
|
||||
<h1 className="text-4xl font-bold mb-2">Produk</h1>
|
||||
<p className="text-muted-foreground mb-4">Jelajahi konsultasi, webinar, dan bootcamp kami</p>
|
||||
|
||||
{/* Consulting Availability Banner */}
|
||||
{!loading && consultingSettings?.is_consulting_enabled && (
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-2 border-primary/30 flex items-center gap-3 hover:border-primary/50 transition-colors">
|
||||
<div className="bg-primary text-primary-foreground p-2 rounded-full shrink-0">
|
||||
<Video className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Konsultasi Tersedia!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Booking jadwal konsultasi 1-on-1 dengan mentor • {formatIDR(consultingSettings.consulting_block_price)} / {consultingSettings.consulting_block_duration_minutes} menit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filter */}
|
||||
{!loading && products.length > 0 && (
|
||||
<div className="mb-6 space-y-4">
|
||||
@@ -143,7 +166,7 @@ export default function Products() {
|
||||
type="text"
|
||||
placeholder="Cari produk..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-2"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -218,7 +241,7 @@ export default function Products() {
|
||||
<Video className="w-5 h-5 text-primary shrink-0" />
|
||||
Konsultasi 1-on-1
|
||||
</CardTitle>
|
||||
<Badge className="bg-primary text-white shadow-sm shrink-0">
|
||||
<Badge variant="default" className="shrink-0">
|
||||
Konsultasi
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -243,14 +266,42 @@ export default function Products() {
|
||||
)}
|
||||
|
||||
{/* Regular Products */}
|
||||
{filteredProducts.map((product) => (
|
||||
{filteredProducts.map((product: Product) => (
|
||||
<Card key={product.id} className="border-2 border-border shadow-sm hover:shadow-md transition-shadow h-full flex flex-col">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-start gap-2 mb-2">
|
||||
<CardTitle className="text-xl line-clamp-1">{product.title}</CardTitle>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{getTypeLabel(product.type)}
|
||||
</Badge>
|
||||
<CardTitle className="text-xl line-clamp-2 leading-tight min-h-[3rem]">{product.title}</CardTitle>
|
||||
<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 className="mb-2">
|
||||
{product.collaborator_user_id ? (
|
||||
<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)}
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 } from 'lucide-react';
|
||||
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() {
|
||||
@@ -24,6 +47,12 @@ export default function WebinarRecording() {
|
||||
|
||||
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) {
|
||||
@@ -36,7 +65,7 @@ export default function WebinarRecording() {
|
||||
const checkAccessAndFetch = async () => {
|
||||
const { data: productData, error: productError } = await supabase
|
||||
.from('products')
|
||||
.select('id, title, slug, recording_url, description')
|
||||
.select('id, title, slug, recording_url, m3u8_url, mp4_url, video_host, description, chapters')
|
||||
.eq('slug', slug)
|
||||
.eq('type', 'webinar')
|
||||
.maybeSingle();
|
||||
@@ -49,12 +78,24 @@ export default function WebinarRecording() {
|
||||
|
||||
setProduct(productData);
|
||||
|
||||
if (!productData.recording_url) {
|
||||
// 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
|
||||
@@ -75,37 +116,70 @@ export default function WebinarRecording() {
|
||||
order.order_items?.some((item: any) => item.product_id === productData.id)
|
||||
);
|
||||
|
||||
if (!hasDirectAccess && !hasPaidOrderAccess) {
|
||||
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 getVideoEmbed = (url: string) => {
|
||||
// YouTube
|
||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||
const checkUserReview = async () => {
|
||||
if (!product || !user) return;
|
||||
|
||||
// Vimeo
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
||||
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);
|
||||
|
||||
// Google Drive
|
||||
const driveMatch = url.match(/drive\.google\.com\/file\/d\/([^\/]+)/);
|
||||
if (driveMatch) return `https://drive.google.com/file/d/${driveMatch[1]}/preview`;
|
||||
|
||||
// Direct MP4 or other video files
|
||||
if (url.match(/\.(mp4|webm|ogg)$/i)) return url;
|
||||
|
||||
return url;
|
||||
if (data && data.length > 0) {
|
||||
setUserReview(data[0] as UserReview);
|
||||
} else {
|
||||
setUserReview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isDirectVideo = (url: string) => {
|
||||
return url.match(/\.(mp4|webm|ogg)$/i) || url.includes('drive.google.com');
|
||||
};
|
||||
// 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 (
|
||||
@@ -120,69 +194,181 @@ export default function WebinarRecording() {
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const embedUrl = product.recording_url ? getVideoEmbed(product.recording_url) : null;
|
||||
const hasChapters = product.chapters && product.chapters.length > 0;
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<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>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl md:text-3xl">{product.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Video Embed */}
|
||||
{embedUrl && (
|
||||
<div className="aspect-video bg-muted rounded-lg overflow-hidden border-2 border-border">
|
||||
{isDirectVideo(embedUrl) ? (
|
||||
<video
|
||||
src={embedUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
>
|
||||
<source src={embedUrl} type="video/mp4" />
|
||||
Browser Anda tidak mendukung pemutaran video.
|
||||
</video>
|
||||
) : (
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
title={product.title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-6">{product.title}</h1>
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
{/* 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">
|
||||
<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>
|
||||
{/* 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 { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
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 {
|
||||
id: string;
|
||||
@@ -21,14 +22,13 @@ export default function AdminBootcamp() {
|
||||
const navigate = useNavigate();
|
||||
const [bootcamps, setBootcamps] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
else fetchBootcamps();
|
||||
if (user && isAdmin) {
|
||||
fetchBootcamps();
|
||||
}
|
||||
}, [user, isAdmin, authLoading]);
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const fetchBootcamps = async () => {
|
||||
const { data, error } = await supabase
|
||||
@@ -40,6 +40,11 @@ export default function AdminBootcamp() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Filter bootcamps based on search
|
||||
const filteredBootcamps = bootcamps.filter((bootcamp) =>
|
||||
bootcamp.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
@@ -62,18 +67,40 @@ export default function AdminBootcamp() {
|
||||
</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">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">Belum ada bootcamp. Buat produk dengan tipe bootcamp terlebih dahulu.</p>
|
||||
<Button onClick={() => navigate('/admin/products')} variant="outline" className="border-2">
|
||||
Ke Manajemen Produk
|
||||
</Button>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchQuery ? 'Tidak ada bootcamp yang cocok dengan pencarian' : 'Belum ada bootcamp. Buat produk dengan tipe bootcamp terlebih dahulu.'}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button onClick={() => navigate('/admin/products')} variant="outline" className="border-2">
|
||||
Ke Manajemen Produk
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<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">
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<span className="font-bold">{bootcamp.title}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,12 +75,10 @@ export default function AdminEvents() {
|
||||
const [blockForm, setBlockForm] = useState(emptyBlock);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
else fetchData();
|
||||
if (user && isAdmin) {
|
||||
fetchData();
|
||||
}
|
||||
}, [user, isAdmin, authLoading]);
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [eventsRes, blocksRes, productsRes] = await Promise.all([
|
||||
|
||||
@@ -9,9 +9,20 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
@@ -36,6 +47,11 @@ export default function AdminMembers() {
|
||||
const [selectedMember, setSelectedMember] = useState<Member | null>(null);
|
||||
const [memberAccess, setMemberAccess] = useState<UserAccess[]>([]);
|
||||
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(() => {
|
||||
if (!authLoading) {
|
||||
@@ -60,6 +76,25 @@ export default function AdminMembers() {
|
||||
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) => {
|
||||
setSelectedMember(member);
|
||||
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) {
|
||||
return (
|
||||
<AppLayout>
|
||||
@@ -102,7 +220,87 @@ export default function AdminMembers() {
|
||||
<h1 className="text-4xl font-bold mb-2">Manajemen Member</h1>
|
||||
<p className="text-muted-foreground mb-8">Kelola semua pengguna</p>
|
||||
|
||||
<Card className="border-2 border-border hidden md:block">
|
||||
{/* 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">
|
||||
{/* Desktop Table */}
|
||||
<div className="overflow-x-auto">
|
||||
@@ -117,7 +315,7 @@ export default function AdminMembers() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((member) => (
|
||||
{filteredMembers.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>{member.email || "-"}</TableCell>
|
||||
<TableCell>{member.name || "-"}</TableCell>
|
||||
@@ -141,16 +339,18 @@ export default function AdminMembers() {
|
||||
>
|
||||
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
|
||||
</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>
|
||||
</TableRow>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
Belum ada member
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -159,7 +359,7 @@ export default function AdminMembers() {
|
||||
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{members.map((member) => (
|
||||
{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">
|
||||
@@ -194,16 +394,23 @@ export default function AdminMembers() {
|
||||
{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>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Belum ada member
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg border-2 border-border">
|
||||
@@ -242,6 +449,57 @@ export default function AdminMembers() {
|
||||
)}
|
||||
</DialogContent>
|
||||
</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>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 { Eye, CheckCircle, XCircle, Video, ExternalLink, Trash2, AlertTriangle, RefreshCw, Link as LinkIcon, Download } 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 { getPaymentStatusLabel, getPaymentStatusColor, canRefundOrder, canCancelOrder, canMarkAsPaid } from "@/lib/statusHelpers";
|
||||
import { convertToCSV, downloadCSV, formatExportDate, formatExportIDR } from "@/lib/exportCSV";
|
||||
@@ -46,6 +46,8 @@ interface ConsultingSlot {
|
||||
end_time: string;
|
||||
status: string;
|
||||
meet_link?: string;
|
||||
topic_category?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export default function AdminOrders() {
|
||||
@@ -66,6 +68,8 @@ export default function AdminOrders() {
|
||||
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(() => {
|
||||
if (!authLoading) {
|
||||
@@ -84,19 +88,43 @@ export default function AdminOrders() {
|
||||
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) => {
|
||||
setSelectedOrder(order);
|
||||
const { data: itemsData } = await supabase.from("order_items").select("*, product:products(title, type)").eq("order_id", order.id);
|
||||
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");
|
||||
if (hasConsulting) {
|
||||
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("date", { ascending: true })
|
||||
.order("start_time", { ascending: true });
|
||||
setConsultingSlots((slotsData as ConsultingSlot[]) || []);
|
||||
} else {
|
||||
setConsultingSlots([]);
|
||||
@@ -368,7 +396,95 @@ export default function AdminOrders() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-2 border-border hidden md:block">
|
||||
{/* 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 ID order 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">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>
|
||||
</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">
|
||||
@@ -385,7 +501,7 @@ export default function AdminOrders() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.map((order) => (
|
||||
{filteredOrders.map((order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell className="font-mono text-sm">{order.id.slice(0, 8)}</TableCell>
|
||||
<TableCell>{order.profile?.email || "-"}</TableCell>
|
||||
@@ -400,13 +516,6 @@ export default function AdminOrders() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
Belum ada order
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -415,7 +524,7 @@ export default function AdminOrders() {
|
||||
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{orders.map((order) => (
|
||||
{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">
|
||||
@@ -447,14 +556,11 @@ export default function AdminOrders() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Belum ada order
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg border-2 border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detail Order</DialogTitle>
|
||||
@@ -475,92 +581,138 @@ export default function AdminOrders() {
|
||||
<span className="text-muted-foreground">Metode:</span> {selectedOrder.payment_method || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="font-medium mb-2">Item:</p>
|
||||
{orderItems.map((item) => (
|
||||
<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>
|
||||
|
||||
{/* Consulting Slots */}
|
||||
{consultingSlots.length > 0 && (
|
||||
{/* Order Items - only show if there are items */}
|
||||
{orderItems.length > 0 && (
|
||||
<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-2">
|
||||
{consultingSlots.map((slot) => (
|
||||
<div key={slot.id} 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={slot.status === "confirmed" ? "default" : "secondary"} className="text-xs">
|
||||
{slot.status === "confirmed" ? "Terkonfirmasi" : slot.status}
|
||||
</Badge>
|
||||
{/* Meet Link Status */}
|
||||
{slot.meet_link ? (
|
||||
<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(slot.date).toLocaleDateString("id-ID", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} WIB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{slot.meet_link && (
|
||||
<Button asChild variant="outline" size="sm" className="gap-1">
|
||||
<a
|
||||
href={slot.meet_link}
|
||||
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(slot.id, slot.meet_link)}
|
||||
className="gap-1"
|
||||
>
|
||||
<LinkIcon className="w-3 h-3" />
|
||||
{slot.meet_link ? "Update" : "Buat"} Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="font-medium mb-2">Item Pesanan:</p>
|
||||
{orderItems.map((item) => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -12,13 +12,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
||||
import { Plus, Pencil, Trash2, Search, X, BookOpen, ChevronsUpDown } from 'lucide-react';
|
||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||
import { formatIDR } from '@/lib/format';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Info } from 'lucide-react';
|
||||
import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
@@ -29,11 +38,24 @@ interface Product {
|
||||
content: string;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
m3u8_url?: string | null;
|
||||
mp4_url?: string | null;
|
||||
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||
event_start: string | null;
|
||||
duration_minutes: number | null;
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
is_active: boolean;
|
||||
chapters?: VideoChapter[];
|
||||
collaborator_user_id?: string | null;
|
||||
profit_share_percentage?: number;
|
||||
auto_grant_access?: boolean;
|
||||
}
|
||||
|
||||
interface CollaboratorProfile {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
const emptyProduct = {
|
||||
@@ -44,11 +66,18 @@ const emptyProduct = {
|
||||
content: '',
|
||||
meeting_link: '',
|
||||
recording_url: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||
event_start: null as string | null,
|
||||
duration_minutes: null as number | null,
|
||||
price: 0,
|
||||
sale_price: null as number | null,
|
||||
is_active: true,
|
||||
chapters: [] as VideoChapter[],
|
||||
collaborator_user_id: '',
|
||||
profit_share_percentage: 50,
|
||||
auto_grant_access: true,
|
||||
};
|
||||
|
||||
export default function AdminProducts() {
|
||||
@@ -60,22 +89,58 @@ export default function AdminProducts() {
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [form, setForm] = useState(emptyProduct);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [collaborators, setCollaborators] = useState<CollaboratorProfile[]>([]);
|
||||
const [collaboratorPickerOpen, setCollaboratorPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
else fetchProducts();
|
||||
if (user && isAdmin) {
|
||||
fetchProducts();
|
||||
fetchCollaborators();
|
||||
}
|
||||
}, [user, isAdmin, authLoading]);
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false });
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('id, title, slug, type, description, content, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, duration_minutes, price, sale_price, is_active, chapters, collaborator_user_id, profit_share_percentage, auto_grant_access')
|
||||
.order('created_at', { ascending: false });
|
||||
if (!error && data) setProducts(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchCollaborators = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
setCollaborators(data);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter products based on search and filters
|
||||
const filteredProducts = products.filter((product) => {
|
||||
const matchesSearch = product.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
product.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = filterType === 'all' || product.type === filterType;
|
||||
const matchesStatus = filterStatus === 'all' ||
|
||||
(filterStatus === 'active' && product.is_active) ||
|
||||
(filterStatus === 'inactive' && !product.is_active);
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
// Get unique product types from actual products
|
||||
const productTypes = ['all', ...Array.from(new Set(products.map(p => p.type)))];
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setFilterType('all');
|
||||
setFilterStatus('all');
|
||||
};
|
||||
|
||||
const generateSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
|
||||
const handleEdit = (product: Product) => {
|
||||
@@ -88,20 +153,25 @@ export default function AdminProducts() {
|
||||
content: product.content || '',
|
||||
meeting_link: product.meeting_link || '',
|
||||
recording_url: product.recording_url || '',
|
||||
m3u8_url: product.m3u8_url || '',
|
||||
mp4_url: product.mp4_url || '',
|
||||
video_host: product.video_host || 'youtube',
|
||||
event_start: product.event_start ? product.event_start.slice(0, 16) : null,
|
||||
duration_minutes: product.duration_minutes,
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
is_active: product.is_active,
|
||||
chapters: product.chapters || [],
|
||||
collaborator_user_id: product.collaborator_user_id || '',
|
||||
profit_share_percentage: product.profit_share_percentage ?? 50,
|
||||
auto_grant_access: product.auto_grant_access ?? true,
|
||||
});
|
||||
setActiveTab('details');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingProduct(null);
|
||||
setForm(emptyProduct);
|
||||
setActiveTab('details');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -119,21 +189,88 @@ export default function AdminProducts() {
|
||||
content: form.content,
|
||||
meeting_link: form.meeting_link || null,
|
||||
recording_url: form.recording_url || null,
|
||||
m3u8_url: form.m3u8_url || null,
|
||||
mp4_url: form.mp4_url || null,
|
||||
video_host: form.video_host || 'youtube',
|
||||
event_start: form.event_start || null,
|
||||
duration_minutes: form.duration_minutes || null,
|
||||
price: form.price,
|
||||
sale_price: form.sale_price || null,
|
||||
is_active: form.is_active,
|
||||
chapters: form.chapters || [],
|
||||
collaborator_user_id: form.collaborator_user_id || null,
|
||||
profit_share_percentage: form.collaborator_user_id ? (form.profit_share_percentage || 0) : 0,
|
||||
auto_grant_access: form.collaborator_user_id ? !!form.auto_grant_access : true,
|
||||
};
|
||||
|
||||
if (editingProduct) {
|
||||
const { error } = await supabase.from('products').update(productData).eq('id', editingProduct.id);
|
||||
if (error) toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' });
|
||||
else { toast({ title: 'Berhasil', description: 'Produk diupdate' }); setDialogOpen(false); fetchProducts(); }
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' });
|
||||
} else {
|
||||
const prevCollaboratorId = editingProduct.collaborator_user_id || null;
|
||||
const nextCollaboratorId = productData.collaborator_user_id;
|
||||
|
||||
// Remove old collaborator access when collaborator changed or auto-grant disabled
|
||||
if (prevCollaboratorId && (prevCollaboratorId !== nextCollaboratorId || !productData.auto_grant_access)) {
|
||||
await supabase
|
||||
.from('user_access')
|
||||
.delete()
|
||||
.eq('user_id', prevCollaboratorId)
|
||||
.eq('product_id', editingProduct.id)
|
||||
.eq('access_type', 'collaborator');
|
||||
}
|
||||
|
||||
// Grant collaborator access immediately on assignment (no buyer order needed)
|
||||
if (nextCollaboratorId && productData.auto_grant_access) {
|
||||
const { error: accessError } = await supabase
|
||||
.from('user_access')
|
||||
.upsert({
|
||||
user_id: nextCollaboratorId,
|
||||
product_id: editingProduct.id,
|
||||
access_type: 'collaborator',
|
||||
granted_by: user?.id || null,
|
||||
}, { onConflict: 'user_id,product_id' });
|
||||
|
||||
if (accessError) {
|
||||
toast({ title: 'Warning', description: `Produk tersimpan, tapi grant akses kolaborator gagal: ${accessError.message}`, variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Produk diupdate' });
|
||||
setDialogOpen(false);
|
||||
fetchProducts();
|
||||
}
|
||||
} else {
|
||||
const { error } = await supabase.from('products').insert(productData);
|
||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
else { toast({ title: 'Berhasil', description: 'Produk dibuat' }); setDialogOpen(false); fetchProducts(); }
|
||||
const { data: created, error } = await supabase
|
||||
.from('products')
|
||||
.insert(productData)
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error || !created) {
|
||||
toast({ title: 'Error', description: error?.message || 'Gagal membuat produk', variant: 'destructive' });
|
||||
} else {
|
||||
// Grant collaborator access immediately on assignment (no buyer order needed)
|
||||
if (productData.collaborator_user_id && productData.auto_grant_access) {
|
||||
const { error: accessError } = await supabase
|
||||
.from('user_access')
|
||||
.upsert({
|
||||
user_id: productData.collaborator_user_id,
|
||||
product_id: created.id,
|
||||
access_type: 'collaborator',
|
||||
granted_by: user?.id || null,
|
||||
}, { onConflict: 'user_id,product_id' });
|
||||
|
||||
if (accessError) {
|
||||
toast({ title: 'Warning', description: `Produk dibuat, tapi grant akses kolaborator gagal: ${accessError.message}`, variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Produk dibuat' });
|
||||
setDialogOpen(false);
|
||||
fetchProducts();
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
@@ -170,6 +307,93 @@ export default function AdminProducts() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<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 judul atau deskripsi produk..."
|
||||
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>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm font-medium text-muted-foreground">Tipe:</span>
|
||||
{productTypes.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filterType === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterType(type)}
|
||||
className={filterType === type ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
{type === 'all' ? 'Semua' : type === 'webinar' ? 'Webinar' : type === 'bootcamp' ? 'Bootcamp' : type}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<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 === 'active' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterStatus('active')}
|
||||
className={filterStatus === 'active' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Aktif
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterStatus === 'inactive' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterStatus('inactive')}
|
||||
className={filterStatus === 'inactive' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Nonaktif
|
||||
</Button>
|
||||
{(searchQuery || filterType !== 'all' || 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>
|
||||
|
||||
{/* Result count */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Menampilkan {filteredProducts.length} dari {products.length} produk
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-border hidden md:block">
|
||||
<CardContent className="p-0">
|
||||
{/* Desktop Table */}
|
||||
@@ -185,7 +409,7 @@ export default function AdminProducts() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((product) => (
|
||||
{filteredProducts.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="font-medium">{product.title}</TableCell>
|
||||
<TableCell className="capitalize">{product.type}</TableCell>
|
||||
@@ -205,6 +429,17 @@ export default function AdminProducts() {
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{product.type === 'bootcamp' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/products/${product.id}/curriculum`)}
|
||||
className="mr-1"
|
||||
>
|
||||
<BookOpen className="w-4 h-4 mr-1" />
|
||||
Curriculum
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -214,10 +449,12 @@ export default function AdminProducts() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
{filteredProducts.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
Belum ada produk
|
||||
{searchQuery || filterType !== 'all' || filterStatus !== 'all'
|
||||
? 'Tidak ada produk yang cocok dengan filter'
|
||||
: 'Belum ada produk'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -229,7 +466,7 @@ export default function AdminProducts() {
|
||||
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{products.map((product) => (
|
||||
{filteredProducts.map((product) => (
|
||||
<div key={product.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -237,6 +474,15 @@ export default function AdminProducts() {
|
||||
<p className="text-sm text-muted-foreground capitalize">{product.type}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{product.type === 'bootcamp' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/products/${product.id}/curriculum`)}
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -268,9 +514,11 @@ export default function AdminProducts() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
{filteredProducts.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Belum ada produk
|
||||
{searchQuery || filterType !== 'all' || filterStatus !== 'all'
|
||||
? 'Tidak ada produk yang cocok dengan filter'
|
||||
: 'Belum ada produk'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -286,51 +534,191 @@ export default function AdminProducts() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProduct ? 'Edit Produk' : 'Produk Baru'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
|
||||
<TabsList className="border-2 border-border">
|
||||
<TabsTrigger value="details">Detail</TabsTrigger>
|
||||
{editingProduct && form.type === 'bootcamp' && <TabsTrigger value="curriculum">Kurikulum</TabsTrigger>}
|
||||
</TabsList>
|
||||
<TabsContent value="details" className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Judul *</Label>
|
||||
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug *</Label>
|
||||
<Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tipe</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="webinar">Webinar</SelectItem>
|
||||
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{form.type === 'webinar' && (
|
||||
<div className="space-y-4 border-2 border-border rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Judul *</Label>
|
||||
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug *</Label>
|
||||
<Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" />
|
||||
<Label>Kolaborator (opsional)</Label>
|
||||
<Popover open={collaboratorPickerOpen} onOpenChange={setCollaboratorPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="w-full justify-between border-2">
|
||||
{form.collaborator_user_id
|
||||
? (() => {
|
||||
const selected = collaborators.find((c) => c.id === form.collaborator_user_id);
|
||||
return selected ? `${selected.name || 'User'}${selected.email ? ` (${selected.email})` : ''}` : 'Pilih kolaborator';
|
||||
})()
|
||||
: 'Tanpa kolaborator (solo)'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Cari nama atau email..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Tidak ada kolaborator yang cocok.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="Tanpa kolaborator (solo)"
|
||||
onSelect={() => {
|
||||
setForm({ ...form, collaborator_user_id: '' });
|
||||
setCollaboratorPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
Tanpa kolaborator (solo)
|
||||
</CommandItem>
|
||||
{collaborators.map((c) => (
|
||||
<CommandItem
|
||||
key={c.id}
|
||||
value={`${c.name || 'User'} ${c.email || ''}`}
|
||||
onSelect={() => {
|
||||
setForm({ ...form, collaborator_user_id: c.id });
|
||||
setCollaboratorPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{(c.name || 'User') + (c.email ? ` (${c.email})` : '')}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{!!form.collaborator_user_id && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Profit Share Kolaborator (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.profit_share_percentage}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value || '0', 10);
|
||||
const clamped = Math.max(0, Math.min(100, Number.isNaN(value) ? 0 : value));
|
||||
setForm({ ...form, profit_share_percentage: clamped });
|
||||
}}
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Host Share (%)</Label>
|
||||
<Input
|
||||
value={100 - (form.profit_share_percentage || 0)}
|
||||
disabled
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={!!form.auto_grant_access}
|
||||
onCheckedChange={(checked) => setForm({ ...form, auto_grant_access: checked })}
|
||||
/>
|
||||
<Label>Auto grant access ke kolaborator</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tipe</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="webinar">Webinar</SelectItem>
|
||||
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Deskripsi</Label>
|
||||
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Konten</Label>
|
||||
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Deskripsi</Label>
|
||||
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Konten</Label>
|
||||
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
|
||||
</div>
|
||||
{form.type === 'webinar' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Meeting Link</Label>
|
||||
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Recording URL</Label>
|
||||
<Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" />
|
||||
<Label>Video Host</Label>
|
||||
<Select value={form.video_host} onValueChange={(value: 'youtube' | 'adilo') => setForm({ ...form, video_host: value })}>
|
||||
<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>
|
||||
{form.type === 'webinar' && (
|
||||
|
||||
{/* YouTube URL */}
|
||||
{form.video_host === 'youtube' && (
|
||||
<div className="space-y-2">
|
||||
<Label>YouTube Recording URL</Label>
|
||||
<Input
|
||||
value={form.recording_url}
|
||||
onChange={(e) => setForm({ ...form, recording_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 */}
|
||||
{form.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={form.m3u8_url}
|
||||
onChange={(e) => setForm({ ...form, 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={form.mp4_url}
|
||||
onChange={(e) => setForm({ ...form, 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>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Tanggal & Waktu Webinar</Label>
|
||||
@@ -352,31 +740,70 @@ export default function AdminProducts() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Harga *</Label>
|
||||
<Input type="number" value={form.price} onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} className="border-2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Harga Promo</Label>
|
||||
<Input type="number" value={form.sale_price || ''} onChange={(e) => setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })} placeholder="Kosongkan jika tidak promo" className="border-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm({ ...form, is_active: checked })} />
|
||||
<Label>Aktif</Label>
|
||||
</div>
|
||||
<Button onClick={handleSave} className="w-full shadow-sm" disabled={saving}>
|
||||
{saving ? 'Menyimpan...' : 'Simpan Produk'}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
{editingProduct && form.type === 'bootcamp' && (
|
||||
<TabsContent value="curriculum" className="py-4">
|
||||
<CurriculumEditor productId={editingProduct.id} />
|
||||
</TabsContent>
|
||||
<ChaptersEditor
|
||||
chapters={form.chapters || []}
|
||||
onChange={(chapters) => setForm({ ...form, chapters })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
{form.type === 'bootcamp' && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-semibold">Video Source Settings</Label>
|
||||
<RadioGroup
|
||||
value={form.video_source || 'youtube'}
|
||||
onValueChange={(value) => setForm({ ...form, video_source: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2 p-3 border-2 border-border rounded-lg">
|
||||
<RadioGroupItem value="youtube" id="youtube" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="youtube" className="font-medium cursor-pointer">
|
||||
YouTube (Primary)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use YouTube URLs for all lessons
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 p-3 border-2 border-border rounded-lg">
|
||||
<RadioGroupItem value="embed" id="embed" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="embed" className="font-medium cursor-pointer">
|
||||
Adilo (Backup)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use Adilo M3U8/MP4 URLs for all lessons
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This setting affects ALL lessons in this bootcamp. Configure video URLs for each lesson in the curriculum editor. Use this toggle to switch between YouTube and Adilo sources instantly.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Harga *</Label>
|
||||
<Input type="number" value={form.price} onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} className="border-2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Harga Promo</Label>
|
||||
<Input type="number" value={form.sale_price || ''} onChange={(e) => setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })} placeholder="Kosongkan jika tidak promo" className="border-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm({ ...form, is_active: checked })} />
|
||||
<Label>Aktif</Label>
|
||||
</div>
|
||||
<Button onClick={handleSave} className="w-full shadow-sm" disabled={saving}>
|
||||
{saving ? 'Menyimpan...' : 'Simpan Produk'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { Star, Check, X, Edit, Trash2 } from "lucide-react";
|
||||
import { Star, Check, X, Edit, Trash2, Search, X as XIcon } from "lucide-react";
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
@@ -30,7 +29,7 @@ interface Review {
|
||||
export default function AdminReviews() {
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState({ type: "all", status: "all" });
|
||||
const [filter, setFilter] = useState({ type: "all", status: "all", search: "" });
|
||||
const [editReview, setEditReview] = useState<Review | null>(null);
|
||||
const [editForm, setEditForm] = useState({ title: "", body: "" });
|
||||
|
||||
@@ -112,9 +111,23 @@ export default function AdminReviews() {
|
||||
if (filter.type !== "all" && r.type !== filter.type) return false;
|
||||
if (filter.status === "approved" && !r.is_approved) return false;
|
||||
if (filter.status === "pending" && r.is_approved) return false;
|
||||
if (filter.search) {
|
||||
const query = filter.search.toLowerCase();
|
||||
const matchesSearch =
|
||||
r.title.toLowerCase().includes(query) ||
|
||||
r.body.toLowerCase().includes(query) ||
|
||||
r.profiles?.name?.toLowerCase().includes(query) ||
|
||||
r.profiles?.email?.toLowerCase().includes(query) ||
|
||||
r.products?.title.toLowerCase().includes(query);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilter({ ...filter, type: 'all', status: 'all', search: '' });
|
||||
};
|
||||
|
||||
const pendingReviews = reviews.filter((r) => !r.is_approved);
|
||||
|
||||
const renderStars = (rating: number) => (
|
||||
@@ -162,31 +175,116 @@ export default function AdminReviews() {
|
||||
<p className="text-muted-foreground">Kelola ulasan dari member</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<Select value={filter.type} onValueChange={(v) => setFilter({ ...filter, type: v })}>
|
||||
<SelectTrigger className="w-40 border-2">
|
||||
<SelectValue placeholder="Tipe" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Tipe</SelectItem>
|
||||
<SelectItem value="consulting">Konsultasi</SelectItem>
|
||||
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
||||
<SelectItem value="webinar">Webinar</SelectItem>
|
||||
<SelectItem value="general">Umum</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Card className="border-2 border-border">
|
||||
<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 ulasan..."
|
||||
value={filter.search}
|
||||
onChange={(e) => setFilter({ ...filter, search: e.target.value })}
|
||||
className="pl-10 border-2"
|
||||
/>
|
||||
{filter.search && (
|
||||
<button
|
||||
onClick={() => setFilter({ ...filter, search: '' })}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select value={filter.status} onValueChange={(v) => setFilter({ ...filter, status: v })}>
|
||||
<SelectTrigger className="w-40 border-2">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="pending">Menunggu</SelectItem>
|
||||
<SelectItem value="approved">Disetujui</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm font-medium text-muted-foreground">Tipe:</span>
|
||||
<Button
|
||||
variant={filter.type === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter({ ...filter, type: 'all' })}
|
||||
className={filter.type === 'all' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Semua
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter.type === 'consulting' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter({ ...filter, type: 'consulting' })}
|
||||
className={filter.type === 'consulting' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Konsultasi
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter.type === 'bootcamp' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter({ ...filter, type: 'bootcamp' })}
|
||||
className={filter.type === 'bootcamp' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Bootcamp
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter.type === 'webinar' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter({ ...filter, type: 'webinar' })}
|
||||
className={filter.type === 'webinar' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Webinar
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter.type === 'general' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter({ ...filter, type: 'general' })}
|
||||
className={filter.type === 'general' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Umum
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm font-medium text-muted-foreground">Status:</span>
|
||||
<Button
|
||||
variant={filter.status === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter({ ...filter, status: 'all' })}
|
||||
className={filter.status === 'all' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Semua
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter.status === 'pending' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter({ ...filter, status: 'pending' })}
|
||||
className={filter.status === 'pending' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Menunggu
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter.status === 'approved' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter({ ...filter, status: 'approved' })}
|
||||
className={filter.status === 'approved' ? 'shadow-sm' : 'border-2'}
|
||||
>
|
||||
Disetujui
|
||||
</Button>
|
||||
{(filter.search || filter.type !== 'all' || filter.status !== 'all') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<XIcon className="w-4 h-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Menampilkan {filteredReviews.length} dari {reviews.length} ulasan
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="list">
|
||||
<TabsList>
|
||||
@@ -202,9 +300,15 @@ export default function AdminReviews() {
|
||||
<Card className="border-2 border-border">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Star className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold mb-2">Belum ada ulasan</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{filter.search || filter.type !== 'all' || filter.status !== 'all'
|
||||
? 'Tidak ada ulasan yang cocok'
|
||||
: 'Belum ada ulasan'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Ulasan dari member akan muncul di sini setelah mereka mengirimkan ulasan.
|
||||
{filter.search || filter.type !== 'all' || filter.status !== 'all'
|
||||
? 'Coba ubah filter atau kata kunci pencarian'
|
||||
: 'Ulasan dari member akan muncul di sini setelah mereka mengirimkan ulasan.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -9,18 +9,13 @@ import { NotifikasiTab } from '@/components/admin/settings/NotifikasiTab';
|
||||
import { KonsultasiTab } from '@/components/admin/settings/KonsultasiTab';
|
||||
import { BrandingTab } from '@/components/admin/settings/BrandingTab';
|
||||
import { IntegrasiTab } from '@/components/admin/settings/IntegrasiTab';
|
||||
import { Clock, Bell, Video, Palette, Puzzle } from 'lucide-react';
|
||||
import { CollaborationTab } from '@/components/admin/settings/CollaborationTab';
|
||||
import { Clock, Bell, Video, Palette, Puzzle, Wallet } from 'lucide-react';
|
||||
|
||||
export default function AdminSettings() {
|
||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
}
|
||||
}, [user, isAdmin, authLoading, navigate]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
@@ -40,7 +35,7 @@ export default function AdminSettings() {
|
||||
<p className="text-muted-foreground mb-8">Konfigurasi platform</p>
|
||||
|
||||
<Tabs defaultValue="workhours" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-auto lg:inline-flex">
|
||||
<TabsList className="grid w-full grid-cols-3 md:grid-cols-6 lg:w-auto lg:inline-flex">
|
||||
<TabsTrigger value="workhours" className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Jam Kerja</span>
|
||||
@@ -61,6 +56,10 @@ export default function AdminSettings() {
|
||||
<Puzzle className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Integrasi</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="collaboration" className="flex items-center gap-2">
|
||||
<Wallet className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Kolaborasi</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="workhours">
|
||||
@@ -82,6 +81,10 @@ export default function AdminSettings() {
|
||||
<TabsContent value="integrasi">
|
||||
<IntegrasiTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="collaboration">
|
||||
<CollaborationTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
217
src/pages/admin/AdminWithdrawals.tsx
Normal file
217
src/pages/admin/AdminWithdrawals.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppLayout } from "@/components/AppLayout";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { formatDateTime, formatIDR } from "@/lib/format";
|
||||
|
||||
interface Withdrawal {
|
||||
id: string;
|
||||
user_id: string;
|
||||
amount: number;
|
||||
status: "pending" | "processing" | "completed" | "rejected" | "failed";
|
||||
requested_at: string;
|
||||
processed_at: string | null;
|
||||
payment_reference: string | null;
|
||||
notes: string | null;
|
||||
admin_notes: string | null;
|
||||
profile?: {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
bank_name: string | null;
|
||||
bank_account_name: string | null;
|
||||
bank_account_number: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function AdminWithdrawals() {
|
||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rows, setRows] = useState<Withdrawal[]>([]);
|
||||
const [selected, setSelected] = useState<Withdrawal | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [action, setAction] = useState<"completed" | "rejected">("completed");
|
||||
const [paymentReference, setPaymentReference] = useState("");
|
||||
const [adminNotes, setAdminNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && isAdmin) fetchData();
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from("withdrawals")
|
||||
.select(`
|
||||
id, user_id, amount, status, requested_at, processed_at, payment_reference, notes, admin_notes,
|
||||
profile:profiles!withdrawals_user_id_fkey (name, email, bank_name, bank_account_name, bank_account_number)
|
||||
`)
|
||||
.order("requested_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
toast({ title: "Error", description: error.message, variant: "destructive" });
|
||||
} else {
|
||||
setRows((data || []) as Withdrawal[]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const pendingCount = useMemo(() => rows.filter((r) => r.status === "pending").length, [rows]);
|
||||
|
||||
const openProcessDialog = (row: Withdrawal, mode: "completed" | "rejected") => {
|
||||
setSelected(row);
|
||||
setAction(mode);
|
||||
setPaymentReference("");
|
||||
setAdminNotes("");
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const processWithdrawal = async () => {
|
||||
if (!selected) return;
|
||||
if (action === "completed" && !paymentReference.trim()) {
|
||||
toast({ title: "Payment reference wajib diisi", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const { data, error } = await supabase.functions.invoke("process-withdrawal", {
|
||||
body: {
|
||||
withdrawalId: selected.id,
|
||||
status: action,
|
||||
payment_reference: paymentReference || null,
|
||||
admin_notes: adminNotes || null,
|
||||
reason: adminNotes || null,
|
||||
},
|
||||
});
|
||||
const response = data as { error?: string } | null;
|
||||
|
||||
if (error || response?.error) {
|
||||
toast({
|
||||
title: "Gagal memproses withdrawal",
|
||||
description: response?.error || error?.message || "Unknown error",
|
||||
variant: "destructive",
|
||||
});
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({ title: "Berhasil", description: "Withdrawal berhasil diproses" });
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
setSelected(null);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Withdrawal Requests</h1>
|
||||
<p className="text-muted-foreground">Kelola permintaan pencairan kolaborator</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader><CardTitle>Pending: {pendingCount}</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Collaborator</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Bank</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{formatDateTime(row.requested_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{row.profile?.name || "-"}</p>
|
||||
<p className="text-muted-foreground">{row.profile?.email || "-"}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatIDR(row.amount || 0)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={row.status === "completed" ? "default" : row.status === "rejected" ? "destructive" : "secondary"}>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<p>{row.profile?.bank_name || "-"}</p>
|
||||
<p className="text-muted-foreground">{row.profile?.bank_account_number || "-"}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
{row.status === "pending" ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => openProcessDialog(row, "completed")}>Approve</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => openProcessDialog(row, "rejected")}>Reject</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">Processed</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
Belum ada withdrawal request
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="border-2 border-border">
|
||||
<DialogHeader><DialogTitle>{action === "completed" ? "Approve" : "Reject"} Withdrawal</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{action === "completed" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Payment Reference</Label>
|
||||
<Input value={paymentReference} onChange={(e) => setPaymentReference(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Admin Notes</Label>
|
||||
<Textarea value={adminNotes} onChange={(e) => setAdminNotes(e.target.value)} />
|
||||
</div>
|
||||
<Button onClick={processWithdrawal} disabled={submitting} className="w-full">
|
||||
{submitting ? "Processing..." : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
697
src/pages/admin/ProductCurriculum.tsx
Normal file
697
src/pages/admin/ProductCurriculum.tsx
Normal file
@@ -0,0 +1,697 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical, ArrowLeft, Save, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||
import { AppLayout } from '@/components/AppLayout';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
title: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface VideoChapter {
|
||||
time: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
module_id: string;
|
||||
title: string;
|
||||
content: 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;
|
||||
release_at: string | null;
|
||||
chapters?: VideoChapter[];
|
||||
}
|
||||
|
||||
export default function ProductCurriculum() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [product, setProduct] = useState<any>(null);
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
|
||||
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
||||
|
||||
// Lesson editing state
|
||||
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
|
||||
const [lessonForm, setLessonForm] = useState({
|
||||
module_id: '',
|
||||
title: '',
|
||||
content: '',
|
||||
video_url: '',
|
||||
youtube_url: '',
|
||||
embed_code: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||
release_at: '',
|
||||
chapters: [] as VideoChapter[],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchData();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
const [productRes, modulesRes, lessonsRes] = await Promise.all([
|
||||
supabase.from('products').select('id, title, slug').eq('id', id).single(),
|
||||
supabase.from('bootcamp_modules').select('*').eq('product_id', id).order('position'),
|
||||
supabase.from('bootcamp_lessons').select('id, module_id, title, content, video_url, youtube_url, embed_code, m3u8_url, mp4_url, video_host, position, release_at, chapters').order('position'),
|
||||
]);
|
||||
|
||||
if (productRes.data) {
|
||||
setProduct(productRes.data);
|
||||
}
|
||||
|
||||
if (modulesRes.data) {
|
||||
setModules(modulesRes.data);
|
||||
setExpandedModules(new Set(modulesRes.data.map(m => m.id)));
|
||||
}
|
||||
|
||||
if (lessonsRes.data) {
|
||||
setLessons(lessonsRes.data);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getLessonsForModule = (moduleId: string) => {
|
||||
return lessons.filter(l => l.module_id === moduleId).sort((a, b) => a.position - b.position);
|
||||
};
|
||||
|
||||
const getSelectedModule = () => {
|
||||
return modules.find(m => m.id === selectedModuleId) || null;
|
||||
};
|
||||
|
||||
const getSelectedLesson = () => {
|
||||
return lessons.find(l => l.id === selectedLessonId) || null;
|
||||
};
|
||||
|
||||
// Module CRUD
|
||||
const handleAddModule = async () => {
|
||||
if (!id) return;
|
||||
|
||||
const title = prompt('Module title:');
|
||||
if (!title?.trim()) return;
|
||||
|
||||
const maxPosition = modules.length > 0 ? Math.max(...modules.map(m => m.position)) : 0;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('bootcamp_modules')
|
||||
.insert({ product_id: id, title, position: maxPosition + 1 });
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to create module', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Module created' });
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditModule = async (module: Module) => {
|
||||
const title = prompt('Module title:', module.title);
|
||||
if (!title?.trim()) return;
|
||||
|
||||
const { error } = await supabase.from('bootcamp_modules').update({ title }).eq('id', module.id);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to update module', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Module updated' });
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModule = async (moduleId: string) => {
|
||||
if (!confirm('Delete this module and all its lessons?')) return;
|
||||
|
||||
const { error } = await supabase.from('bootcamp_modules').delete().eq('id', moduleId);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to delete module', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Module deleted' });
|
||||
if (selectedModuleId === moduleId) {
|
||||
setSelectedModuleId(null);
|
||||
setSelectedLessonId(null);
|
||||
}
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const moveModule = async (moduleId: string, direction: 'up' | 'down') => {
|
||||
const index = modules.findIndex(m => m.id === moduleId);
|
||||
if ((direction === 'up' && index === 0) || (direction === 'down' && index === modules.length - 1)) return;
|
||||
|
||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
const currentModule = modules[index];
|
||||
const swapModule = modules[swapIndex];
|
||||
|
||||
await Promise.all([
|
||||
supabase.from('bootcamp_modules').update({ position: swapModule.position }).eq('id', currentModule.id),
|
||||
supabase.from('bootcamp_modules').update({ position: currentModule.position }).eq('id', swapModule.id),
|
||||
]);
|
||||
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// Lesson CRUD
|
||||
const handleAddLesson = (moduleId: string) => {
|
||||
setEditingLesson(null);
|
||||
setLessonForm({
|
||||
module_id: moduleId,
|
||||
title: '',
|
||||
content: '',
|
||||
video_url: '',
|
||||
youtube_url: '',
|
||||
embed_code: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube',
|
||||
release_at: '',
|
||||
chapters: [],
|
||||
});
|
||||
setSelectedModuleId(moduleId);
|
||||
setSelectedLessonId('new');
|
||||
};
|
||||
|
||||
const handleEditLesson = (lesson: Lesson) => {
|
||||
setEditingLesson(lesson);
|
||||
setLessonForm({
|
||||
module_id: lesson.module_id,
|
||||
title: lesson.title,
|
||||
content: lesson.content || '',
|
||||
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] : '',
|
||||
chapters: lesson.chapters || [],
|
||||
});
|
||||
setSelectedModuleId(lesson.module_id);
|
||||
setSelectedLessonId(lesson.id);
|
||||
};
|
||||
|
||||
const handleSaveLesson = async () => {
|
||||
if (!lessonForm.title.trim()) {
|
||||
toast({ title: 'Error', description: 'Lesson title is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
const lessonData = {
|
||||
module_id: lessonForm.module_id,
|
||||
title: lessonForm.title,
|
||||
content: lessonForm.content || 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,
|
||||
chapters: lessonForm.chapters || [],
|
||||
};
|
||||
|
||||
if (editingLesson) {
|
||||
const { error } = await supabase.from('bootcamp_lessons').update(lessonData).eq('id', editingLesson.id);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to update lesson', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Lesson updated' });
|
||||
fetchData();
|
||||
}
|
||||
} else {
|
||||
const moduleLessons = getLessonsForModule(lessonForm.module_id);
|
||||
const maxPosition = moduleLessons.length > 0 ? Math.max(...moduleLessons.map(l => l.position)) : 0;
|
||||
|
||||
const { error } = await supabase.from('bootcamp_lessons').insert({ ...lessonData, position: maxPosition + 1 });
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to create lesson', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Lesson created' });
|
||||
fetchData();
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
setSelectedLessonId(null);
|
||||
};
|
||||
|
||||
const handleDeleteLesson = async (lessonId: string) => {
|
||||
if (!confirm('Delete this lesson?')) return;
|
||||
|
||||
const { error } = await supabase.from('bootcamp_lessons').delete().eq('id', lessonId);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to delete lesson', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Lesson deleted' });
|
||||
if (selectedLessonId === lessonId) {
|
||||
setSelectedLessonId(null);
|
||||
}
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const moveLesson = async (lessonId: string, direction: 'up' | 'down') => {
|
||||
const lesson = lessons.find(l => l.id === lessonId);
|
||||
if (!lesson) return;
|
||||
|
||||
const moduleLessons = getLessonsForModule(lesson.module_id);
|
||||
const index = moduleLessons.findIndex(l => l.id === lessonId);
|
||||
|
||||
if ((direction === 'up' && index === 0) || (direction === 'down' && index === moduleLessons.length - 1)) return;
|
||||
|
||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
const swapLesson = moduleLessons[swapIndex];
|
||||
|
||||
await Promise.all([
|
||||
supabase.from('bootcamp_lessons').update({ position: swapLesson.position }).eq('id', lesson.id),
|
||||
supabase.from('bootcamp_lessons').update({ position: lesson.position }).eq('id', swapLesson.id),
|
||||
]);
|
||||
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const toggleModule = (moduleId: string) => {
|
||||
const newExpanded = new Set(expandedModules);
|
||||
if (newExpanded.has(moduleId)) {
|
||||
newExpanded.delete(moduleId);
|
||||
} else {
|
||||
newExpanded.add(moduleId);
|
||||
}
|
||||
setExpandedModules(newExpanded);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header with breadcrumb */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/admin/products')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Products
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{product?.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">Curriculum Management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Left Sidebar: Modules & Lessons (4 columns) */}
|
||||
<div className="col-span-4 space-y-6">
|
||||
{/* Modules Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Modules</CardTitle>
|
||||
<Button size="sm" onClick={handleAddModule}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{modules.map((module, index) => {
|
||||
const moduleLessons = getLessonsForModule(module.id);
|
||||
const isSelected = selectedModuleId === module.id;
|
||||
const isExpanded = expandedModules.has(module.id);
|
||||
|
||||
return (
|
||||
<div key={module.id} className="border-2 border-border rounded-lg overflow-hidden">
|
||||
{/* Module Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 cursor-pointer transition-colors",
|
||||
isSelected ? "bg-primary/10" : "bg-muted hover:bg-muted/80"
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedModuleId(module.id);
|
||||
toggleModule(module.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium truncate">{module.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveModule(module.id, 'up');
|
||||
}}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveModule(module.id, 'down');
|
||||
}}
|
||||
disabled={index === modules.length - 1}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditModule(module);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteModule(module.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 pl-6">
|
||||
{moduleLessons.length} lesson{moduleLessons.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lessons List (expanded) */}
|
||||
{isExpanded && (
|
||||
<div className="border-t-2 border-border bg-card">
|
||||
<div className="p-2 space-y-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddLesson(module.id);
|
||||
}}
|
||||
className="w-full border-dashed text-xs"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Lesson
|
||||
</Button>
|
||||
{moduleLessons.map((lesson, lessonIndex) => {
|
||||
const isLessonSelected = selectedLessonId === lesson.id;
|
||||
return (
|
||||
<div
|
||||
key={lesson.id}
|
||||
className={cn(
|
||||
"p-2 rounded cursor-pointer transition-colors group",
|
||||
isLessonSelected ? "bg-primary/20 border border-primary" : "hover:bg-muted"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditLesson(lesson);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{lesson.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lessonIndex + 1}.
|
||||
</span>
|
||||
{lesson.youtube_url && (
|
||||
<span className="text-xs text-blue-600">YouTube</span>
|
||||
)}
|
||||
{lesson.embed_code && (
|
||||
<span className="text-xs text-purple-600">Embed</span>
|
||||
)}
|
||||
{lesson.content && (
|
||||
<span className="text-xs text-muted-foreground">✓ Content</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveLesson(lesson.id, 'up');
|
||||
}}
|
||||
disabled={lessonIndex === 0}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveLesson(lesson.id, 'down');
|
||||
}}
|
||||
disabled={lessonIndex === moduleLessons.length - 1}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteLesson(lesson.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{moduleLessons.length === 0 && (
|
||||
<div className="text-center text-xs text-muted-foreground py-2">
|
||||
No lessons yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{modules.length === 0 && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
No modules yet. Click + to create one.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Lesson Editor (8 columns - full width for better UX) */}
|
||||
<div className="col-span-8">
|
||||
<Card className="sticky top-4">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedLessonId === 'new' ? 'New Lesson' : editingLesson ? 'Edit Lesson' : 'Lesson Editor'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedLessonId ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Select or create a lesson to start editing
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click on a module to expand it, then click "Add Lesson" or select an existing lesson.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Title *</Label>
|
||||
<Input
|
||||
value={lessonForm.title}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, title: e.target.value })}
|
||||
placeholder="Lesson title"
|
||||
className="border-2 text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Video Host</Label>
|
||||
<Select
|
||||
value={lessonForm.video_host}
|
||||
onValueChange={(value: 'youtube' | 'adilo') => setLessonForm({ ...lessonForm, video_host: value })}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Release Date (optional)</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={lessonForm.release_at}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChaptersEditor
|
||||
chapters={lessonForm.chapters || []}
|
||||
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Content</Label>
|
||||
<RichTextEditor
|
||||
content={lessonForm.content}
|
||||
onChange={(html) => setLessonForm({ ...lessonForm, content: html })}
|
||||
placeholder="Write your lesson content here... Use code blocks for syntax highlighting."
|
||||
className="min-h-[400px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supports rich text formatting, code blocks with syntax highlighting, images, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button onClick={handleSaveLesson} disabled={saving} className="flex-1 shadow-sm" size="lg">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Lesson
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedLessonId(null)}
|
||||
disabled={saving}
|
||||
className="border-2"
|
||||
size="lg"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,10 @@ interface UserAccess {
|
||||
type: string;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
m3u8_url: string | null;
|
||||
mp4_url: string | null;
|
||||
video_host: 'youtube' | 'adilo' | 'unknown' | null;
|
||||
event_start: string | null;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
@@ -33,8 +37,8 @@ interface ConsultingSession {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
recording_url: string | null;
|
||||
topic_category: string | null;
|
||||
meet_link: string | null;
|
||||
}
|
||||
|
||||
export default function MemberAccess() {
|
||||
@@ -47,16 +51,15 @@ export default function MemberAccess() {
|
||||
const [selectedType, setSelectedType] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate('/auth');
|
||||
else if (user) fetchAccess();
|
||||
}, [user, authLoading]);
|
||||
if (user) fetchAccess();
|
||||
}, [user]);
|
||||
|
||||
const fetchAccess = async () => {
|
||||
const [accessRes, paidOrdersRes, consultingRes] = await Promise.all([
|
||||
// Get direct user_access
|
||||
supabase
|
||||
.from('user_access')
|
||||
.select(`id, granted_at, expires_at, product:products (id, title, slug, type, meeting_link, recording_url, description)`)
|
||||
.select(`id, granted_at, expires_at, product:products (id, title, slug, type, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, description)`)
|
||||
.eq('user_id', user!.id),
|
||||
// Get products from paid orders (via order_items)
|
||||
supabase
|
||||
@@ -64,7 +67,7 @@ export default function MemberAccess() {
|
||||
.select(
|
||||
`
|
||||
order_items (
|
||||
product:products (id, title, slug, type, meeting_link, recording_url, description)
|
||||
product:products (id, title, slug, type, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, description)
|
||||
)
|
||||
`,
|
||||
)
|
||||
@@ -73,7 +76,7 @@ export default function MemberAccess() {
|
||||
// Get completed consulting sessions with recordings
|
||||
supabase
|
||||
.from('consulting_slots')
|
||||
.select('id, date, start_time, end_time, status, recording_url, topic_category')
|
||||
.select('id, date, start_time, end_time, status, topic_category, meet_link')
|
||||
.eq('user_id', user!.id)
|
||||
.eq('status', 'done')
|
||||
.order('date', { ascending: false }),
|
||||
@@ -152,8 +155,11 @@ export default function MemberAccess() {
|
||||
// Check if webinar has ended
|
||||
const webinarEnded = item.product.event_start && new Date(item.product.event_start) <= new Date();
|
||||
|
||||
// Check if any recording exists (YouTube, M3U8, or MP4)
|
||||
const hasRecording = item.product.recording_url || item.product.m3u8_url || item.product.mp4_url;
|
||||
|
||||
// If recording exists, show it
|
||||
if (item.product.recording_url) {
|
||||
if (hasRecording) {
|
||||
return (
|
||||
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
@@ -298,16 +304,16 @@ export default function MemberAccess() {
|
||||
<Clock className="w-4 h-4 ml-2" />
|
||||
<span>{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}</span>
|
||||
</div>
|
||||
{session.recording_url ? (
|
||||
<Button asChild className="shadow-sm">
|
||||
<a href={session.recording_url} target="_blank" rel="noopener noreferrer">
|
||||
{session.meet_link ? (
|
||||
<Button asChild className="shadow-sm" size="sm">
|
||||
<a href={session.meet_link} target="_blank" rel="noopener noreferrer">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
Rekam Sesi
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Badge className="bg-muted text-primary">Rekaman segera tersedia</Badge>
|
||||
<Badge className="bg-muted text-primary">Selesai</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatIDR } from "@/lib/format";
|
||||
import { Video, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react";
|
||||
import { Video, ArrowRight, Package, Receipt, ShoppingBag, Wallet } from "lucide-react";
|
||||
import { WhatsAppBanner } from "@/components/WhatsAppBanner";
|
||||
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
|
||||
import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert";
|
||||
@@ -45,8 +45,8 @@ interface ConsultingSlot {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
product_id: string | null;
|
||||
meet_link: string | null;
|
||||
topic_category?: string | null;
|
||||
}
|
||||
|
||||
export default function MemberDashboard() {
|
||||
@@ -58,6 +58,7 @@ export default function MemberDashboard() {
|
||||
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasWhatsApp, setHasWhatsApp] = useState(true);
|
||||
const [isCollaborator, setIsCollaborator] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate("/auth");
|
||||
@@ -122,7 +123,7 @@ export default function MemberDashboard() {
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [accessRes, ordersRes, paidOrdersRes, profileRes, slotsRes] = await Promise.all([
|
||||
const [accessRes, ordersRes, paidOrdersRes, profileRes, slotsRes, walletRes, collaboratorProductRes] = await Promise.all([
|
||||
supabase
|
||||
.from("user_access")
|
||||
.select(`id, product:products (id, title, slug, type, meeting_link, recording_url, event_start, duration_minutes)`)
|
||||
@@ -144,10 +145,12 @@ export default function MemberDashboard() {
|
||||
// Fetch confirmed consulting slots for quick access
|
||||
supabase
|
||||
.from("consulting_slots")
|
||||
.select("id, date, start_time, end_time, status, product_id, meet_link")
|
||||
.select("id, date, start_time, end_time, status, meet_link, topic_category")
|
||||
.eq("user_id", user!.id)
|
||||
.eq("status", "confirmed")
|
||||
.order("date", { ascending: false }),
|
||||
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),
|
||||
]);
|
||||
|
||||
// Combine access from user_access and paid orders
|
||||
@@ -170,6 +173,7 @@ export default function MemberDashboard() {
|
||||
if (ordersRes.data) setRecentOrders(ordersRes.data);
|
||||
if (profileRes.data) setHasWhatsApp(!!profileRes.data.whatsapp_number);
|
||||
if (slotsRes.data) setConsultingSlots(slotsRes.data as unknown as ConsultingSlot[]);
|
||||
setIsCollaborator(!!walletRes?.data || !!(collaboratorProductRes?.data && collaboratorProductRes.data.length > 0));
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -178,10 +182,9 @@ export default function MemberDashboard() {
|
||||
|
||||
switch (item.product.type) {
|
||||
case "consulting": {
|
||||
// Only show if user has a confirmed upcoming consulting slot for this product
|
||||
// Only show if user has a confirmed upcoming consulting slot
|
||||
const upcomingSlot = consultingSlots.find(
|
||||
(slot) =>
|
||||
slot.product_id === item.product.id &&
|
||||
slot.status === "confirmed" &&
|
||||
new Date(slot.date) >= new Date(now.setHours(0, 0, 0, 0))
|
||||
);
|
||||
@@ -283,6 +286,22 @@ export default function MemberDashboard() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isCollaborator && (
|
||||
<Card className="border-2 border-border col-span-full">
|
||||
<CardContent className="pt-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Wallet className="w-10 h-10 text-primary" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Kolaborator Dashboard</p>
|
||||
<p className="font-medium">Lihat profit & withdrawal</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate("/profit")} className="border-2">
|
||||
Buka Profit
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{access.length > 0 && (
|
||||
@@ -350,7 +369,7 @@ export default function MemberDashboard() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white" : "bg-amber-500 text-white"} rounded-full>
|
||||
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white rounded-full" : "bg-amber-500 text-white rounded-full"}>
|
||||
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
||||
</Badge>
|
||||
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||
|
||||
@@ -10,7 +10,10 @@ import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { User, LogOut, Phone } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { User, LogOut, Phone, Upload } from 'lucide-react';
|
||||
import { uploadToContentStorage } from '@/lib/storageUpload';
|
||||
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
@@ -19,6 +22,11 @@ interface Profile {
|
||||
avatar_url: string | null;
|
||||
whatsapp_number: string | null;
|
||||
whatsapp_opt_in: boolean;
|
||||
bio?: string | null;
|
||||
portfolio_url?: string | null;
|
||||
bank_account_number?: string | null;
|
||||
bank_account_name?: string | null;
|
||||
bank_name?: string | null;
|
||||
}
|
||||
|
||||
export default function MemberProfile() {
|
||||
@@ -27,17 +35,22 @@ export default function MemberProfile() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
avatar_url: '',
|
||||
whatsapp_number: '',
|
||||
whatsapp_opt_in: false,
|
||||
bio: '',
|
||||
portfolio_url: '',
|
||||
bank_name: '',
|
||||
bank_account_name: '',
|
||||
bank_account_number: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate('/auth');
|
||||
else if (user) fetchProfile();
|
||||
}, [user, authLoading]);
|
||||
if (user) fetchProfile();
|
||||
}, [user]);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
const { data } = await supabase
|
||||
@@ -52,6 +65,11 @@ export default function MemberProfile() {
|
||||
avatar_url: data.avatar_url || '',
|
||||
whatsapp_number: data.whatsapp_number || '',
|
||||
whatsapp_opt_in: data.whatsapp_opt_in || false,
|
||||
bio: data.bio || '',
|
||||
portfolio_url: data.portfolio_url || '',
|
||||
bank_name: data.bank_name || '',
|
||||
bank_account_name: data.bank_account_name || '',
|
||||
bank_account_number: data.bank_account_number || '',
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -82,6 +100,11 @@ export default function MemberProfile() {
|
||||
avatar_url: form.avatar_url || null,
|
||||
whatsapp_number: normalizedWA || null,
|
||||
whatsapp_opt_in: form.whatsapp_opt_in,
|
||||
bio: form.bio || null,
|
||||
portfolio_url: form.portfolio_url || null,
|
||||
bank_name: form.bank_name || null,
|
||||
bank_account_name: form.bank_account_name || null,
|
||||
bank_account_number: form.bank_account_number || null,
|
||||
})
|
||||
.eq('id', user!.id);
|
||||
|
||||
@@ -94,6 +117,29 @@ export default function MemberProfile() {
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (file: File) => {
|
||||
if (!user) return;
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast({ title: 'Error', description: 'Ukuran file maksimal 2MB', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingAvatar(true);
|
||||
const ext = file.name.split('.').pop() || 'png';
|
||||
const path = `users/${user.id}/avatar-${Date.now()}.${ext}`;
|
||||
const publicUrl = await uploadToContentStorage(file, path);
|
||||
setForm((prev) => ({ ...prev, avatar_url: publicUrl }));
|
||||
toast({ title: 'Berhasil', description: 'Avatar berhasil diupload' });
|
||||
} catch (error) {
|
||||
console.error('Avatar upload error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Gagal upload avatar';
|
||||
toast({ title: 'Error', description: message, variant: 'destructive' });
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
navigate('/');
|
||||
@@ -139,10 +185,52 @@ export default function MemberProfile() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>URL Avatar</Label>
|
||||
<Label>Avatar</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16 border-2 border-border">
|
||||
<AvatarImage src={resolveAvatarUrl(form.avatar_url) || undefined} alt={form.name || 'User avatar'} />
|
||||
<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 handleAvatarUpload(file);
|
||||
e.currentTarget.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" asChild disabled={uploadingAvatar}>
|
||||
<span>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingAvatar ? 'Mengupload...' : 'Upload Avatar'}
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Bio</Label>
|
||||
<Input
|
||||
value={form.avatar_url}
|
||||
onChange={(e) => setForm({ ...form, avatar_url: e.target.value })}
|
||||
value={form.bio}
|
||||
onChange={(e) => setForm({ ...form, bio: e.target.value })}
|
||||
className="border-2"
|
||||
placeholder="Tentang Anda"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Portfolio URL</Label>
|
||||
<Input
|
||||
value={form.portfolio_url}
|
||||
onChange={(e) => setForm({ ...form, portfolio_url: e.target.value })}
|
||||
className="border-2"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
@@ -185,6 +273,41 @@ export default function MemberProfile() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Bank (Untuk Withdrawal)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nama Bank</Label>
|
||||
<Input
|
||||
value={form.bank_name}
|
||||
onChange={(e) => setForm({ ...form, bank_name: e.target.value })}
|
||||
className="border-2"
|
||||
placeholder="BCA / Mandiri / BNI / dll"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nama Pemilik Rekening</Label>
|
||||
<Input
|
||||
value={form.bank_account_name}
|
||||
onChange={(e) => setForm({ ...form, bank_account_name: e.target.value })}
|
||||
className="border-2"
|
||||
placeholder="Nama sesuai rekening"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nomor Rekening</Label>
|
||||
<Input
|
||||
value={form.bank_account_number}
|
||||
onChange={(e) => setForm({ ...form, bank_account_number: e.target.value })}
|
||||
className="border-2"
|
||||
placeholder="Nomor rekening"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving} className="w-full shadow-sm">
|
||||
{saving ? 'Menyimpan...' : 'Simpan Profil'}
|
||||
</Button>
|
||||
|
||||
239
src/pages/member/MemberProfit.tsx
Normal file
239
src/pages/member/MemberProfit.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AppLayout } from "@/components/AppLayout";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { formatIDR, formatDateTime } from "@/lib/format";
|
||||
|
||||
interface WalletData {
|
||||
current_balance: number;
|
||||
total_earned: number;
|
||||
total_withdrawn: number;
|
||||
pending_balance: number;
|
||||
}
|
||||
|
||||
interface ProfitRow {
|
||||
order_item_id: string;
|
||||
order_id: string;
|
||||
created_at: string;
|
||||
product_title: string;
|
||||
profit_share_percentage: number;
|
||||
profit_amount: number;
|
||||
profit_status: string | null;
|
||||
wallet_transaction_id: string | null;
|
||||
}
|
||||
|
||||
interface WithdrawalRow {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
requested_at: string;
|
||||
processed_at: string | null;
|
||||
payment_reference: string | null;
|
||||
admin_notes: string | null;
|
||||
}
|
||||
|
||||
export default function MemberProfit() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [wallet, setWallet] = useState<WalletData | null>(null);
|
||||
const [profits, setProfits] = useState<ProfitRow[]>([]);
|
||||
const [withdrawals, setWithdrawals] = useState<WithdrawalRow[]>([]);
|
||||
const [openWithdrawDialog, setOpenWithdrawDialog] = useState(false);
|
||||
const [withdrawAmount, setWithdrawAmount] = useState("");
|
||||
const [withdrawNotes, setWithdrawNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [settings, setSettings] = useState<{ min_withdrawal_amount: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate("/auth");
|
||||
if (user) fetchData();
|
||||
}, [user, authLoading]);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [walletRes, profitRes, withdrawalRes, settingsRes] = await Promise.all([
|
||||
supabase.rpc("get_collaborator_wallet", { p_user_id: user!.id }),
|
||||
supabase
|
||||
.from("collaborator_profits")
|
||||
.select("*")
|
||||
.eq("collaborator_user_id", user!.id)
|
||||
.order("created_at", { ascending: false }),
|
||||
supabase
|
||||
.from("withdrawals")
|
||||
.select("id, amount, status, requested_at, processed_at, payment_reference, admin_notes")
|
||||
.eq("user_id", user!.id)
|
||||
.order("requested_at", { ascending: false }),
|
||||
supabase.rpc("get_collaboration_settings"),
|
||||
]);
|
||||
|
||||
setWallet((walletRes.data?.[0] as WalletData) || {
|
||||
current_balance: 0,
|
||||
total_earned: 0,
|
||||
total_withdrawn: 0,
|
||||
pending_balance: 0,
|
||||
});
|
||||
setProfits((profitRes.data as ProfitRow[]) || []);
|
||||
setWithdrawals((withdrawalRes.data as WithdrawalRow[]) || []);
|
||||
setSettings({ min_withdrawal_amount: settingsRes.data?.[0]?.min_withdrawal_amount || 100000 });
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
const amount = Number(withdrawAmount || 0);
|
||||
const min = settings?.min_withdrawal_amount || 100000;
|
||||
const available = Number(wallet?.current_balance || 0);
|
||||
return amount >= min && amount <= available;
|
||||
}, [withdrawAmount, settings, wallet]);
|
||||
|
||||
const submitWithdrawal = async () => {
|
||||
if (!canSubmit) {
|
||||
toast({
|
||||
title: "Nominal tidak valid",
|
||||
description: "Periksa minimum penarikan dan saldo tersedia",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const { data, error } = await supabase.functions.invoke("create-withdrawal", {
|
||||
body: {
|
||||
amount: Number(withdrawAmount),
|
||||
notes: withdrawNotes || null,
|
||||
},
|
||||
});
|
||||
const response = data as { error?: string } | null;
|
||||
|
||||
if (error || response?.error) {
|
||||
toast({
|
||||
title: "Gagal membuat withdrawal",
|
||||
description: response?.error || error?.message || "Unknown error",
|
||||
variant: "destructive",
|
||||
});
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({ title: "Berhasil", description: "Withdrawal request berhasil dibuat" });
|
||||
setSubmitting(false);
|
||||
setOpenWithdrawDialog(false);
|
||||
setWithdrawAmount("");
|
||||
setWithdrawNotes("");
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Profit</h1>
|
||||
<p className="text-muted-foreground">Ringkasan pendapatan kolaborasi Anda</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="border-2 border-border"><CardContent className="pt-6"><p className="text-sm text-muted-foreground">Total Earnings</p><p className="text-2xl font-bold">{formatIDR(wallet?.total_earned || 0)}</p></CardContent></Card>
|
||||
<Card className="border-2 border-border"><CardContent className="pt-6"><p className="text-sm text-muted-foreground">Available Balance</p><p className="text-2xl font-bold">{formatIDR(wallet?.current_balance || 0)}</p></CardContent></Card>
|
||||
<Card className="border-2 border-border"><CardContent className="pt-6"><p className="text-sm text-muted-foreground">Total Withdrawn</p><p className="text-2xl font-bold">{formatIDR(wallet?.total_withdrawn || 0)}</p></CardContent></Card>
|
||||
<Card className="border-2 border-border"><CardContent className="pt-6"><p className="text-sm text-muted-foreground">Pending Balance</p><p className="text-2xl font-bold">{formatIDR(wallet?.pending_balance || 0)}</p></CardContent></Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Withdrawal</CardTitle>
|
||||
<Button onClick={() => setOpenWithdrawDialog(true)}>Request Withdrawal</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>Minimum withdrawal: {formatIDR(settings?.min_withdrawal_amount || 100000)}</p>
|
||||
<p>Available balance: {formatIDR(wallet?.current_balance || 0)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader><CardTitle>Profit History</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Date</TableHead><TableHead>Product</TableHead><TableHead>Share</TableHead><TableHead>Amount</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{profits.map((row) => (
|
||||
<TableRow key={row.order_item_id}>
|
||||
<TableCell>{formatDateTime(row.created_at)}</TableCell>
|
||||
<TableCell>{row.product_title}</TableCell>
|
||||
<TableCell>{row.profit_share_percentage}%</TableCell>
|
||||
<TableCell>{formatIDR(row.profit_amount || 0)}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{row.profit_status || "-"}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{profits.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">Belum ada data profit</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader><CardTitle>Withdrawal History</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Date</TableHead><TableHead>Amount</TableHead><TableHead>Status</TableHead><TableHead>Reference</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{withdrawals.map((w) => (
|
||||
<TableRow key={w.id}>
|
||||
<TableCell>{formatDateTime(w.requested_at)}</TableCell>
|
||||
<TableCell>{formatIDR(w.amount || 0)}</TableCell>
|
||||
<TableCell><Badge variant={w.status === "completed" ? "default" : w.status === "rejected" ? "destructive" : "secondary"}>{w.status}</Badge></TableCell>
|
||||
<TableCell>{w.payment_reference || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{withdrawals.length === 0 && (
|
||||
<TableRow><TableCell colSpan={4} className="text-center text-muted-foreground py-8">Belum ada withdrawal</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={openWithdrawDialog} onOpenChange={setOpenWithdrawDialog}>
|
||||
<DialogContent className="border-2 border-border">
|
||||
<DialogHeader><DialogTitle>Request Withdrawal</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nominal (IDR)</Label>
|
||||
<Input type="number" value={withdrawAmount} onChange={(e) => setWithdrawAmount(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea value={withdrawNotes} onChange={(e) => setWithdrawNotes(e.target.value)} />
|
||||
</div>
|
||||
<Button onClick={submitWithdrawal} disabled={submitting} className="w-full">
|
||||
{submitting ? "Submitting..." : "Submit Withdrawal"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { formatIDR, formatDate } from "@/lib/format";
|
||||
import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle, Video, Clock, RefreshCw } from "lucide-react";
|
||||
import { ArrowLeft, Package, CreditCard, Calendar as CalendarIcon, AlertCircle, Video, Clock, RefreshCw, Download } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { getPaymentStatusLabel, getPaymentStatusColor, getProductTypeLabel } from "@/lib/statusHelpers";
|
||||
|
||||
@@ -44,11 +44,13 @@ interface Order {
|
||||
|
||||
interface ConsultingSlot {
|
||||
id: string;
|
||||
date: string;
|
||||
session_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
meet_link?: string;
|
||||
topic_category?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export default function OrderDetail() {
|
||||
@@ -73,6 +75,13 @@ export default function OrderDetail() {
|
||||
(item: OrderItem) => item.products.type === "consulting"
|
||||
) || false;
|
||||
|
||||
// Check if consulting session has passed
|
||||
const isConsultingSessionPassed = consultingSlots.length > 0 ? (() => {
|
||||
const slot = consultingSlots[0];
|
||||
const sessionEndDateTime = new Date(`${slot.session_date}T${slot.end_time}`);
|
||||
return new Date() > sessionEndDateTime;
|
||||
})() : false;
|
||||
|
||||
// Memoized fetchOrder to avoid recreating on every render
|
||||
const fetchOrder = useCallback(async () => {
|
||||
if (!user || !id) return;
|
||||
@@ -123,15 +132,15 @@ export default function OrderDetail() {
|
||||
} else {
|
||||
setOrder(data);
|
||||
|
||||
// Always fetch consulting slots for this order (consulting orders don't have order_items)
|
||||
const { data: slots } = await supabase
|
||||
.from("consulting_slots")
|
||||
// Always fetch consulting sessions for this order (consulting orders don't have order_items)
|
||||
const { data: sessions } = await supabase
|
||||
.from("consulting_sessions")
|
||||
.select("*")
|
||||
.eq("order_id", id)
|
||||
.order("date", { ascending: true });
|
||||
.order("session_date", { ascending: true });
|
||||
|
||||
if (slots && slots.length > 0) {
|
||||
setConsultingSlots(slots as ConsultingSlot[]);
|
||||
if (sessions && sessions.length > 0) {
|
||||
setConsultingSlots(sessions as ConsultingSlot[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +255,29 @@ export default function OrderDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
// Generate Google Calendar link for adding to user's calendar
|
||||
const generateCalendarLink = (session: ConsultingSlot) => {
|
||||
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}${session.notes ? `\n\nCatatan: ${session.notes}` : ''}`,
|
||||
location: session.meet_link,
|
||||
});
|
||||
|
||||
return `https://www.google.com/calendar/render?${params.toString()}`;
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
@@ -308,37 +340,38 @@ export default function OrderDetail() {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Order Info */}
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Informasi Order
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tanggal Order</p>
|
||||
<p className="font-medium">{formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Terakhir Update</p>
|
||||
<p className="font-medium">{formatDate(order.updated_at)}</p>
|
||||
</div>
|
||||
{order.payment_method && (
|
||||
{/* Order Info - Only show for product orders */}
|
||||
{!isConsultingOrder && (
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
Informasi Order
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Metode Pembayaran</p>
|
||||
<p className="font-medium uppercase">{order.payment_method}</p>
|
||||
<p className="text-muted-foreground">Tanggal Order</p>
|
||||
<p className="font-medium">{formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
{order.payment_provider && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Provider</p>
|
||||
<p className="font-medium capitalize">{order.payment_provider}</p>
|
||||
<p className="text-muted-foreground">Terakhir Update</p>
|
||||
<p className="font-medium">{formatDate(order.updated_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{order.payment_method && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Metode Pembayaran</p>
|
||||
<p className="font-medium uppercase">{order.payment_method}</p>
|
||||
</div>
|
||||
)}
|
||||
{order.payment_provider && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Provider</p>
|
||||
<p className="font-medium capitalize">{order.payment_provider}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR Code Display for pending QRIS payments */}
|
||||
{order.payment_status === "pending" && order.payment_method === "qris" && !isQrExpired && (
|
||||
@@ -417,61 +450,68 @@ export default function OrderDetail() {
|
||||
<Alert className="mb-4 border-orange-200 bg-orange-50">
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
<AlertDescription className="text-orange-900">
|
||||
{isConsultingOrder
|
||||
? "Waktu pembayaran telah habis. Slot konsultasi telah dilepaskan. Silakan buat booking baru."
|
||||
: "QR Code telah kadaluarsa. Anda dapat me-regenerate QR code untuk melanjutkan pembayaran."}
|
||||
QR Code telah kadaluarsa. Anda dapat me-regenerate QR code untuk melanjutkan pembayaran.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isConsultingOrder ? (
|
||||
// Consulting order - show booking button
|
||||
<div className="text-center space-y-4">
|
||||
{/* Product order - show regenerate button */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{formatIDR(order.total_amount)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Order ini telah dibatalkan secara otomatis karena waktu pembayaran habis.
|
||||
Order ID: {order.id.slice(0, 8)}
|
||||
</p>
|
||||
<Button onClick={() => navigate("/consulting-booking")} className="shadow-sm">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Buat Booking Baru
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Product order - show regenerate button
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{formatIDR(order.total_amount)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Order ID: {order.id.slice(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRegenerateQR}
|
||||
disabled={regeneratingQR}
|
||||
className="w-full shadow-sm"
|
||||
>
|
||||
{regeneratingQR ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Regenerate QR Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRegenerateQR}
|
||||
disabled={regeneratingQR}
|
||||
className="w-full shadow-sm"
|
||||
>
|
||||
{regeneratingQR ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Regenerate QR Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate("/products")}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Kembali ke Produk
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => navigate("/products")}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Kembali ke Produk
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancelled Order Handling */}
|
||||
{order.status === "cancelled" && (
|
||||
<div className="pt-4">
|
||||
<Alert className="mb-4 border-red-200 bg-red-50">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
<AlertDescription className="text-red-900">
|
||||
Order ini telah dibatalkan.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Product order - show back to products button */}
|
||||
<Button
|
||||
onClick={() => navigate("/products")}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Kembali ke Produk
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -488,45 +528,57 @@ export default function OrderDetail() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Smart Item/Service Display */}
|
||||
{consultingSlots.length > 0 ? (
|
||||
// === Consulting Orders (NO order_items, has consulting_slots) ===
|
||||
<>
|
||||
<Card className="border-2 border-primary bg-primary/5 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Video className="w-5 h-5" />
|
||||
Detail Sesi Konsultasi
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Summary Card */}
|
||||
<div className="bg-background p-4 rounded-lg border-2 border-border">
|
||||
<div className="grid grid-cols-1 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Waktu Konsultasi</p>
|
||||
<p className="font-bold text-lg">
|
||||
{consultingSlots[0].start_time.substring(0,5)} - {consultingSlots[consultingSlots.length-1].end_time.substring(0,5)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{consultingSlots.length} blok ({consultingSlots.length * 45} menit)
|
||||
</p>
|
||||
</div>
|
||||
// === Consulting Orders - Single Merged Card ===
|
||||
<Card className="border-2 border-primary bg-primary/5 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Video className="w-5 h-5" />
|
||||
Detail Sesi Konsultasi
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Session Information */}
|
||||
<div className="bg-background p-4 rounded-lg border-2 border-border">
|
||||
<div className="grid grid-cols-1 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Waktu Konsultasi</p>
|
||||
<p className="font-bold text-lg">
|
||||
{consultingSlots[0].start_time.substring(0,5)} - {consultingSlots[0].end_time.substring(0,5)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tanggal</p>
|
||||
<p className="font-medium">
|
||||
{new Date(consultingSlots[0].date).toLocaleDateString("id-ID", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tanggal</p>
|
||||
<p className="font-medium">
|
||||
{new Date(consultingSlots[0].session_date).toLocaleDateString("id-ID", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{consultingSlots[0]?.meet_link && (
|
||||
{consultingSlots[0]?.topic_category && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Kategori</p>
|
||||
<p className="font-medium">{consultingSlots[0].topic_category}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{consultingSlots[0]?.notes && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Catatan</p>
|
||||
<p className="font-medium">{consultingSlots[0].notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{consultingSlots[0]?.meet_link && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Google Meet Link</p>
|
||||
<a
|
||||
@@ -538,35 +590,212 @@ export default function OrderDetail() {
|
||||
{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"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Tambah ke Kalender
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code Display for pending QRIS payments */}
|
||||
{order.payment_status === "pending" && order.payment_method === "qris" && !isQrExpired && (
|
||||
<div className="pt-4">
|
||||
{order.qr_string ? (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Scan QR code ini dengan aplikasi e-wallet atau mobile banking Anda
|
||||
{timeRemaining && (
|
||||
<span className="ml-2 font-medium">
|
||||
(Kadaluarsa dalam {timeRemaining})
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border-2 border-border flex flex-col items-center justify-center space-y-4">
|
||||
<div className="bg-white p-2 rounded">
|
||||
<QRCodeSVG value={order.qr_string} size={200} />
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-2xl font-bold">{formatIDR(order.total_amount)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Order ID: {order.id.slice(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isPolling && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Menunggu pembayaran...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||
<span>🔒 Pembayaran Aman</span>
|
||||
<span>⚡ QRIS Standar Bank Indonesia</span>
|
||||
</div>
|
||||
|
||||
{order.payment_url && (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Bayar di Halaman Pembayaran
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Alert className="border-orange-200 bg-orange-50">
|
||||
<Clock className="h-4 w-4 text-orange-600" />
|
||||
<AlertDescription className="text-orange-900">
|
||||
Sedang memproses QR code...
|
||||
{order.payment_url && (
|
||||
<Button asChild className="mt-2" variant="outline" size="sm">
|
||||
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Bayar di Halaman Pembayaran
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expired QR Handling for Consulting */}
|
||||
{order.payment_status === "pending" && order.payment_method === "qris" && isQrExpired && (
|
||||
<div className="pt-4">
|
||||
<Alert className="mb-4 border-orange-200 bg-orange-50">
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
<AlertDescription className="text-orange-900">
|
||||
Waktu pembayaran telah habis. Slot konsultasi telah dilepaskan. Silakan buat booking baru.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Order ini telah dibatalkan secara otomatis karena waktu pembayaran habis.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const expiredData = {
|
||||
fromExpiredOrder: true,
|
||||
orderId: order.id,
|
||||
topicCategory: consultingSlots[0]?.topic_category || '',
|
||||
notes: consultingSlots[0]?.notes || ''
|
||||
};
|
||||
sessionStorage.setItem('expiredConsultingOrder', JSON.stringify(expiredData));
|
||||
navigate("/consulting");
|
||||
}}
|
||||
className="shadow-sm"
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4 mr-2" />
|
||||
Buat Booking Baru
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kategori dan catatan akan terisi otomatis dari order sebelumnya
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Alert */}
|
||||
{order.payment_status === "paid" ? (
|
||||
{/* Cancelled Order Handling for Consulting */}
|
||||
{order.status === "cancelled" && (
|
||||
<div className="pt-4">
|
||||
<Alert className="mb-4 border-red-200 bg-red-50">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
<AlertDescription className="text-red-900">
|
||||
Order ini telah dibatalkan. Slot konsultasi telah dilepaskan.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const expiredData = {
|
||||
fromExpiredOrder: true,
|
||||
orderId: order.id,
|
||||
topicCategory: consultingSlots[0]?.topic_category || '',
|
||||
notes: consultingSlots[0]?.notes || ''
|
||||
};
|
||||
sessionStorage.setItem('expiredConsultingOrder', JSON.stringify(expiredData));
|
||||
navigate("/consulting");
|
||||
}}
|
||||
className="shadow-sm"
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4 mr-2" />
|
||||
Buat Booking Baru
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kategori dan catatan akan terisi otomatis dari order sebelumnya
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback button for pending payments without QR */}
|
||||
{order.payment_status === "pending" && !order.qr_string && order.payment_url && (
|
||||
<div className="pt-4">
|
||||
<Button asChild className="w-full shadow-sm">
|
||||
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Lanjutkan Pembayaran
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Alert */}
|
||||
{order.payment_status === "paid" ? (
|
||||
isConsultingSessionPassed ? (
|
||||
<Alert className="bg-orange-50 border-orange-200">
|
||||
<Clock className="h-4 w-4 text-orange-600" />
|
||||
<AlertDescription className="text-orange-900">
|
||||
Sesi konsultasi telah berakhir. Menunggu konfirmasi admin.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert className="bg-green-50 border-green-200">
|
||||
<Video className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Pembayaran berhasil! Silakan bergabung sesuai jadwal.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert className="bg-yellow-50 border-yellow-200">
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Selesaikan pembayaran untuk mengkonfirmasi jadwal sesi konsultasi.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
)
|
||||
) : order.status !== "cancelled" && order.payment_status === "pending" && !isQrExpired ? (
|
||||
<Alert className="bg-yellow-50 border-yellow-200">
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Selesaikan pembayaran untuk mengkonfirmasi jadwal sesi konsultasi.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex items-center justify-between text-lg font-bold pt-4 border-t">
|
||||
<span>Total Pembayaran</span>
|
||||
<span>{formatIDR(order.total_amount)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
{/* Total */}
|
||||
<div className="flex items-center justify-between text-lg font-bold pt-4 border-t">
|
||||
<span>Total Pembayaran</span>
|
||||
<span>{formatIDR(order.total_amount)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : order.order_items.length > 0 ? (
|
||||
// === Product Orders (has order_items) ===
|
||||
<Card className="border-2 border-border mb-6">
|
||||
|
||||
86
supabase/functions/cancel-expired-consulting-orders/index.ts
Normal file
86
supabase/functions/cancel-expired-consulting-orders/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
console.log("[CLEANUP-CALENDAR] Starting calendar cleanup for cancelled sessions");
|
||||
|
||||
// Find cancelled consulting sessions with calendar events that haven't been cleaned up
|
||||
const { data: cancelledSessions, error: queryError } = await supabase
|
||||
.from("consulting_sessions")
|
||||
.select("id, calendar_event_id")
|
||||
.eq("status", "cancelled")
|
||||
.not("calendar_event_id", "is", null);
|
||||
|
||||
if (queryError) {
|
||||
console.error("[CLEANUP-CALENDAR] Query error:", queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
if (!cancelledSessions || cancelledSessions.length === 0) {
|
||||
console.log("[CLEANUP-CALENDAR] No cancelled sessions with calendar events found");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "No calendar events to clean up",
|
||||
processed: 0
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[CLEANUP-CALENDAR] Found ${cancelledSessions.length} cancelled sessions with calendar events`);
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
// Delete calendar events for cancelled sessions
|
||||
for (const session of cancelledSessions) {
|
||||
if (session.calendar_event_id) {
|
||||
try {
|
||||
await supabase.functions.invoke('delete-calendar-event', {
|
||||
body: { session_id: session.id }
|
||||
});
|
||||
console.log(`[CLEANUP-CALENDAR] Deleted calendar event for session: ${session.id}`);
|
||||
processedCount++;
|
||||
} catch (err) {
|
||||
console.log(`[CLEANUP-CALENDAR] Failed to delete calendar event: ${err}`);
|
||||
// Continue with other events even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CLEANUP-CALENDAR] Successfully cleaned up ${processedCount} calendar events`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: `Successfully cleaned up ${processedCount} calendar events`,
|
||||
processed: processedCount
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[CLEANUP-CALENDAR] Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Internal server error"
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -50,15 +50,16 @@ async function getGoogleAccessToken(oauthConfig: GoogleOAuthConfig): Promise<{ a
|
||||
body: new URLSearchParams(tokenRequest),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log("Token response status:", response.status);
|
||||
console.log("Token response body:", responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token exchange failed: ${responseText}`);
|
||||
const errorText = await response.text();
|
||||
console.error("Token response error:", errorText);
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Token response data:", JSON.stringify(data, null, 2));
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error("No access token in response");
|
||||
@@ -80,6 +81,12 @@ serve(async (req: Request): Promise<Response> => {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const logs: string[] = [];
|
||||
const log = (msg: string) => {
|
||||
console.log(msg);
|
||||
logs.push(msg);
|
||||
};
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
@@ -96,46 +103,60 @@ serve(async (req: Request): Promise<Response> => {
|
||||
};
|
||||
|
||||
try {
|
||||
log("Starting to read request body...");
|
||||
debugInfo.bodyReadAttempt = "Starting req.text()";
|
||||
const bodyText = await req.text();
|
||||
debugInfo.bodyLength = bodyText.length;
|
||||
debugInfo.bodyPreview = bodyText.substring(0, 200);
|
||||
console.log("Raw body text:", bodyText.substring(0, 100) + "...");
|
||||
log(`Raw body text: ${bodyText.substring(0, 100)}...`);
|
||||
body = JSON.parse(bodyText);
|
||||
debugInfo.parsedBody = body;
|
||||
log(`Parsed body: ${JSON.stringify(body)}`);
|
||||
} catch (bodyError) {
|
||||
debugInfo.readError = (bodyError as Error).message;
|
||||
console.error("Error reading body:", bodyError);
|
||||
console.error("Debug info:", JSON.stringify(debugInfo, null, 2));
|
||||
log(`Error reading body: ${(bodyError as Error).message}`);
|
||||
log(`Debug info: ${JSON.stringify(debugInfo, null, 2)}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Invalid request body: " + (bodyError as Error).message,
|
||||
debug: debugInfo
|
||||
debug: debugInfo,
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
console.log("Creating Google Meet event for slot:", body.slot_id);
|
||||
log(`Creating Google Meet event for slot: ${body.slot_id}`);
|
||||
|
||||
// Get platform settings
|
||||
log("Fetching platform settings...");
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("integration_google_calendar_id, google_oauth_config")
|
||||
.single();
|
||||
|
||||
if (settingsError) {
|
||||
console.error("Error fetching settings:", settingsError);
|
||||
throw settingsError;
|
||||
}
|
||||
|
||||
const calendarId = settings?.integration_google_calendar_id;
|
||||
|
||||
if (!calendarId) {
|
||||
log(`Error fetching settings: ${JSON.stringify(settingsError)}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Google Calendar ID belum dikonfigurasi di Pengaturan > Integrasi"
|
||||
message: "Error fetching settings: " + settingsError.message,
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const calendarId = settings?.integration_google_calendar_id;
|
||||
log(`Calendar ID: ${calendarId}`);
|
||||
|
||||
if (!calendarId) {
|
||||
log("Calendar ID not configured");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Google Calendar ID belum dikonfigurasi di Pengaturan > Integrasi",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
@@ -145,10 +166,12 @@ serve(async (req: Request): Promise<Response> => {
|
||||
const oauthConfigJson = settings?.google_oauth_config;
|
||||
|
||||
if (!oauthConfigJson) {
|
||||
log("OAuth config not found");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Google OAuth Config belum dikonfigurasi. Tambahkan di Pengaturan > Integrasi. Format: {\"client_id\":\"...\",\"client_secret\":\"...\",\"refresh_token\":\"...\"}"
|
||||
message: "Google OAuth Config belum dikonfigurasi. Tambahkan di Pengaturan > Integrasi. Format: {\"client_id\":\"...\",\"client_secret\":\"...\",\"refresh_token\":\"...\"}",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
@@ -158,12 +181,14 @@ serve(async (req: Request): Promise<Response> => {
|
||||
let oauthConfig: GoogleOAuthConfig;
|
||||
try {
|
||||
oauthConfig = JSON.parse(oauthConfigJson);
|
||||
log("OAuth config parsed successfully");
|
||||
} catch (error: any) {
|
||||
console.error("Failed to parse OAuth config JSON:", error);
|
||||
log(`Failed to parse OAuth config: ${error.message}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Format Google OAuth Config tidak valid: " + error.message
|
||||
message: "Format Google OAuth Config tidak valid: " + error.message,
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
@@ -175,11 +200,11 @@ serve(async (req: Request): Promise<Response> => {
|
||||
|
||||
if (oauthConfig.access_token && oauthConfig.expires_at && oauthConfig.expires_at > now + 60) {
|
||||
// Token is still valid (with 60 second buffer)
|
||||
console.log("Using cached access_token (expires at:", new Date(oauthConfig.expires_at * 1000).toISOString(), ")");
|
||||
log(`Using cached access_token (expires at: ${new Date(oauthConfig.expires_at * 1000).toISOString()})`);
|
||||
accessToken = oauthConfig.access_token;
|
||||
} else {
|
||||
// Need to refresh the token
|
||||
console.log("Access token expired or missing, refreshing...");
|
||||
log("Access token expired or missing, refreshing...");
|
||||
const tokenData = await getGoogleAccessToken(oauthConfig);
|
||||
accessToken = tokenData.access_token;
|
||||
|
||||
@@ -197,17 +222,20 @@ serve(async (req: Request): Promise<Response> => {
|
||||
.update({ google_oauth_config: JSON.stringify(updatedConfig) })
|
||||
.eq("id", settings.id);
|
||||
|
||||
console.log("Updated cached access_token in database");
|
||||
log("Updated cached access_token in database");
|
||||
}
|
||||
console.log("Got access token");
|
||||
log("Got access token");
|
||||
|
||||
// Build event data
|
||||
const startDate = new Date(`${body.date}T${body.start_time}`);
|
||||
const endDate = new Date(`${body.date}T${body.end_time}`);
|
||||
// Include +07:00 timezone offset to ensure times are treated as Asia/Jakarta time
|
||||
const startDate = new Date(`${body.date}T${body.start_time}+07:00`);
|
||||
const endDate = new Date(`${body.date}T${body.end_time}+07:00`);
|
||||
|
||||
log(`Event time: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||
|
||||
const eventData = {
|
||||
summary: `Konsultasi: ${body.topic} - ${body.client_name}`,
|
||||
description: `Client: ${body.client_email}\n\nNotes: ${body.notes || '-'}\n\nSlot ID: ${body.slot_id}`,
|
||||
description: `Kategori: ${body.topic}\n\nClient: ${body.client_email}\n\nCatatan: ${body.notes || '-'}\n\nSession ID: ${body.slot_id}`,
|
||||
start: {
|
||||
dateTime: startDate.toISOString(),
|
||||
timeZone: "Asia/Jakarta",
|
||||
@@ -226,12 +254,13 @@ serve(async (req: Request): Promise<Response> => {
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Creating event in calendar:", calendarId);
|
||||
console.log("Event data:", JSON.stringify(eventData, null, 2));
|
||||
log(`Creating event in calendar: ${calendarId}`);
|
||||
log(`Event data: ${JSON.stringify(eventData, null, 2)}`);
|
||||
|
||||
// Create event via Google Calendar API with better error handling
|
||||
let calendarResponse: Response;
|
||||
try {
|
||||
log("Calling Google Calendar API...");
|
||||
calendarResponse = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?conferenceDataVersion=1`,
|
||||
{
|
||||
@@ -245,50 +274,61 @@ serve(async (req: Request): Promise<Response> => {
|
||||
}
|
||||
);
|
||||
} catch (fetchError: any) {
|
||||
console.error("Network error calling Google Calendar API:", fetchError);
|
||||
log(`Network error calling Google Calendar API: ${fetchError.message}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Network error calling Google Calendar API: " + fetchError.message
|
||||
message: "Network error calling Google Calendar API: " + fetchError.message,
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Calendar API response status:", calendarResponse.status);
|
||||
log(`Calendar API response status: ${calendarResponse.status}`);
|
||||
|
||||
if (!calendarResponse.ok) {
|
||||
const errorText = await calendarResponse.text();
|
||||
console.error("Google Calendar API error:", errorText);
|
||||
log(`Google Calendar API error: ${errorText}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Gagal membuat event di Google Calendar: " + errorText
|
||||
message: "Gagal membuat event di Google Calendar: " + errorText,
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const eventDataResult = await calendarResponse.json();
|
||||
console.log("Event created:", eventDataResult.id);
|
||||
console.log("Full event response:", JSON.stringify(eventDataResult, null, 2));
|
||||
log(`Event created with ID: ${eventDataResult.id}`);
|
||||
log(`Full event response: ${JSON.stringify(eventDataResult, null, 2)}`);
|
||||
|
||||
// Check if conference data was created
|
||||
if (eventDataResult.conferenceData && eventDataResult.conferenceData.entryPoints) {
|
||||
const meetLink = eventDataResult.conferenceData.entryPoints.find((ep: any) => ep.entryPointType === "video")?.uri;
|
||||
|
||||
if (meetLink) {
|
||||
log(`Meet link found: ${meetLink}`);
|
||||
|
||||
// Update consulting_sessions with meet_link and event_id
|
||||
log(`Updating session ${body.slot_id} with meet_link and calendar_event_id`);
|
||||
await supabase
|
||||
.from("consulting_slots")
|
||||
.update({ meet_link: meetLink })
|
||||
.from("consulting_sessions")
|
||||
.update({
|
||||
meet_link: meetLink,
|
||||
calendar_event_id: eventDataResult.id
|
||||
})
|
||||
.eq("id", body.slot_id);
|
||||
|
||||
log("Successfully completed");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
meet_link: meetLink,
|
||||
event_id: eventDataResult.id,
|
||||
html_link: eventDataResult.htmlLink,
|
||||
logs: logs
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
@@ -297,36 +337,49 @@ serve(async (req: Request): Promise<Response> => {
|
||||
|
||||
// Fallback to hangoutLink for backwards compatibility
|
||||
if (eventDataResult.hangoutLink) {
|
||||
log(`Using hangoutLink: ${eventDataResult.hangoutLink}`);
|
||||
|
||||
// Update consulting_sessions with meet_link and event_id
|
||||
log(`Updating session ${body.slot_id} with meet_link and calendar_event_id`);
|
||||
await supabase
|
||||
.from("consulting_slots")
|
||||
.update({ meet_link: eventDataResult.hangoutLink })
|
||||
.from("consulting_sessions")
|
||||
.update({
|
||||
meet_link: eventDataResult.hangoutLink,
|
||||
calendar_event_id: eventDataResult.id
|
||||
})
|
||||
.eq("id", body.slot_id);
|
||||
|
||||
log("Successfully completed");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
meet_link: eventDataResult.hangoutLink,
|
||||
event_id: eventDataResult.id,
|
||||
html_link: eventDataResult.htmlLink,
|
||||
logs: logs
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
log("Event created but no meet link found");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Event berhasil dibuat tapi tidak ada meet link"
|
||||
message: "Event berhasil dibuat tapi tidak ada meet link",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error creating Google Meet event:", error);
|
||||
log(`Error creating Google Meet event: ${error.message}`);
|
||||
log(`Stack: ${error.stack}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: error.message || "Unknown error occurred"
|
||||
message: error.message || "Unknown error occurred",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface CreateMeetRequest {
|
||||
slot_id: string;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
client_name: string;
|
||||
client_email: string;
|
||||
topic: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
const body: CreateMeetRequest = await req.json();
|
||||
console.log("Creating meet link for slot:", body.slot_id);
|
||||
|
||||
// Get platform settings for Google Calendar ID
|
||||
const { data: settings } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("integration_google_calendar_id, brand_name")
|
||||
.single();
|
||||
|
||||
const calendarId = settings?.integration_google_calendar_id;
|
||||
const brandName = settings?.brand_name || "LearnHub";
|
||||
|
||||
if (!calendarId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Google Calendar ID belum dikonfigurasi di Pengaturan > Integrasi"
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// For now, this is a placeholder that returns a message
|
||||
// In production, you would integrate with Google Calendar API via OAuth or service account
|
||||
// Or call an n8n webhook to handle the calendar creation
|
||||
|
||||
const { data: integrationSettings } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("integration_n8n_base_url, integration_n8n_test_mode")
|
||||
.single();
|
||||
|
||||
if (integrationSettings?.integration_n8n_base_url) {
|
||||
// Check if we're in test mode (controlled by the integration_n8n_test_mode setting)
|
||||
const isTestMode = integrationSettings.integration_n8n_test_mode || false;
|
||||
|
||||
const webhookPath = isTestMode ? "/webhook-test/" : "/webhook/";
|
||||
const n8nUrl = `${integrationSettings.integration_n8n_base_url}${webhookPath}create-meet`;
|
||||
|
||||
console.log(`Calling n8n webhook: ${n8nUrl} (Test mode: ${isTestMode})`);
|
||||
|
||||
try {
|
||||
const n8nResponse = await fetch(n8nUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
slot_id: body.slot_id,
|
||||
date: body.date,
|
||||
start_time: body.start_time,
|
||||
end_time: body.end_time,
|
||||
client_name: body.client_name,
|
||||
client_email: body.client_email,
|
||||
topic: body.topic,
|
||||
notes: body.notes,
|
||||
calendar_id: calendarId,
|
||||
brand_name: brandName,
|
||||
test_mode: isTestMode, // Add test_mode flag for n8n to use
|
||||
}),
|
||||
});
|
||||
|
||||
if (n8nResponse.ok) {
|
||||
const result = await n8nResponse.json();
|
||||
|
||||
if (result.meet_link) {
|
||||
// Update the slot with the meet link
|
||||
await supabase
|
||||
.from("consulting_slots")
|
||||
.update({ meet_link: result.meet_link })
|
||||
.eq("id", body.slot_id);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, meet_link: result.meet_link }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (n8nError) {
|
||||
console.error("n8n webhook error:", n8nError);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Return instructions for manual setup
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Integrasi otomatis belum tersedia. Silakan buat link Meet secara manual atau konfigurasi n8n webhook di Pengaturan > Integrasi.",
|
||||
manual_instructions: {
|
||||
calendar_id: calendarId,
|
||||
event_title: `Konsultasi: ${body.topic} - ${body.client_name}`,
|
||||
event_date: body.date,
|
||||
event_time: `${body.start_time} - ${body.end_time}`,
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error creating meet link:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: error.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
164
supabase/functions/create-withdrawal/index.ts
Normal file
164
supabase/functions/create-withdrawal/index.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: authData } = await supabase.auth.getUser(token);
|
||||
const user = authData.user;
|
||||
if (!user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { amount, notes } = await req.json();
|
||||
const parsedAmount = Number(amount || 0);
|
||||
if (parsedAmount <= 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid withdrawal amount" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: wallet } = await supabase
|
||||
.rpc("get_collaborator_wallet", { p_user_id: user.id });
|
||||
const currentBalance = Number(wallet?.[0]?.current_balance || 0);
|
||||
|
||||
const { data: settings } = await supabase.rpc("get_collaboration_settings");
|
||||
const minWithdrawal = Number(settings?.[0]?.min_withdrawal_amount || 100000);
|
||||
const maxPendingWithdrawals = Number(settings?.[0]?.max_pending_withdrawals || 1);
|
||||
|
||||
if (currentBalance < minWithdrawal) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Minimum withdrawal is Rp ${minWithdrawal.toLocaleString("id-ID")}` }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedAmount > currentBalance) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Insufficient available balance", available: currentBalance }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: existingPending } = await supabase
|
||||
.from("withdrawals")
|
||||
.select("id")
|
||||
.eq("user_id", user.id)
|
||||
.eq("status", "pending");
|
||||
|
||||
if ((existingPending?.length || 0) >= maxPendingWithdrawals) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Maximum ${maxPendingWithdrawals} pending withdrawal(s) allowed` }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("bank_account_name, bank_account_number, bank_name")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!profile?.bank_account_number || !profile?.bank_account_name || !profile?.bank_name) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Please complete your bank account information in profile settings" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: withdrawal, error: createError } = await supabase
|
||||
.from("withdrawals")
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
amount: parsedAmount,
|
||||
status: "pending",
|
||||
payment_method: "bank_transfer",
|
||||
payment_reference: `${profile.bank_name} - ${profile.bank_account_number} (${profile.bank_account_name})`,
|
||||
notes: notes || null,
|
||||
created_by: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError || !withdrawal) {
|
||||
throw createError || new Error("Failed to create withdrawal");
|
||||
}
|
||||
|
||||
const { data: txId, error: holdError } = await supabase
|
||||
.rpc("hold_withdrawal_amount", {
|
||||
p_user_id: user.id,
|
||||
p_withdrawal_id: withdrawal.id,
|
||||
p_amount: parsedAmount,
|
||||
});
|
||||
|
||||
if (holdError) {
|
||||
await supabase.from("withdrawals").delete().eq("id", withdrawal.id);
|
||||
throw holdError;
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from("withdrawals")
|
||||
.update({ wallet_transaction_id: txId })
|
||||
.eq("id", withdrawal.id);
|
||||
|
||||
await supabase.functions.invoke("send-collaboration-notification", {
|
||||
body: {
|
||||
type: "withdrawal_requested",
|
||||
withdrawalId: withdrawal.id,
|
||||
userId: user.id,
|
||||
amount: parsedAmount,
|
||||
bankInfo: {
|
||||
bankName: profile.bank_name,
|
||||
accountNumber: profile.bank_account_number,
|
||||
accountName: profile.bank_account_name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
withdrawal: { ...withdrawal, wallet_transaction_id: txId },
|
||||
}),
|
||||
{ status: 201, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create withdrawal";
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
@@ -82,6 +83,14 @@ serve(async (req: Request): Promise<Response> => {
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
// Get platform settings for brand_name
|
||||
const { data: platformSettings } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("brand_name")
|
||||
.single();
|
||||
|
||||
const brandName = platformSettings?.brand_name || "ACCESS HUB";
|
||||
|
||||
let notifyError = null;
|
||||
|
||||
if (template && emailSettings?.api_token) {
|
||||
@@ -98,6 +107,7 @@ serve(async (req: Request): Promise<Response> => {
|
||||
jam_konsultasi: `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)} WIB`,
|
||||
link_meet: slot.meet_link || "Akan diinformasikan",
|
||||
jenis_konsultasi: slot.topic_category,
|
||||
platform_name: brandName,
|
||||
};
|
||||
|
||||
// Process shortcodes in template
|
||||
@@ -110,15 +120,22 @@ serve(async (req: Request): Promise<Response> => {
|
||||
emailSubject = emailSubject.replace(regex, String(value));
|
||||
});
|
||||
|
||||
// Wrap with master template
|
||||
const fullHtml = EmailTemplateRenderer.render({
|
||||
subject: emailSubject,
|
||||
content: emailBody,
|
||||
brandName: brandName,
|
||||
});
|
||||
|
||||
// Send via send-email-v2 (Mailketing API)
|
||||
const { error: emailError } = await supabase.functions.invoke("send-email-v2", {
|
||||
body: {
|
||||
to: profile.email,
|
||||
recipient: profile.email,
|
||||
api_token: emailSettings.api_token,
|
||||
from_name: emailSettings.from_name || "Access Hub",
|
||||
from_name: emailSettings.from_name || brandName,
|
||||
from_email: emailSettings.from_email || "noreply@with.dwindi.com",
|
||||
subject: emailSubject,
|
||||
html_body: emailBody,
|
||||
content: fullHtml,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
193
supabase/functions/delete-calendar-event/index.ts
Normal file
193
supabase/functions/delete-calendar-event/index.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface GoogleOAuthConfig {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
refresh_token: string;
|
||||
access_token?: string;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
interface DeleteEventRequest {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
// Function to get access token from refresh token (OAuth2)
|
||||
async function getGoogleAccessToken(oauthConfig: GoogleOAuthConfig): Promise<{ access_token: string; expires_in: number }> {
|
||||
try {
|
||||
console.log("Refreshing access token for calendar event deletion...");
|
||||
|
||||
const tokenRequest = {
|
||||
client_id: oauthConfig.client_id,
|
||||
client_secret: oauthConfig.client_secret,
|
||||
refresh_token: oauthConfig.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
};
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(tokenRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error("No access token in response");
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
expires_in: data.expires_in || 3600
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Error getting Google access token:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const body: DeleteEventRequest = await req.json();
|
||||
console.log("[DELETE-CALENDAR-EVENT] Deleting event for session:", body.session_id);
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get session data with calendar_event_id
|
||||
const { data: session, error: sessionError } = await supabase
|
||||
.from("consulting_sessions")
|
||||
.select("id, calendar_event_id, user_id")
|
||||
.eq("id", body.session_id)
|
||||
.single();
|
||||
|
||||
if (sessionError || !session) {
|
||||
console.error("[DELETE-CALENDAR-EVENT] Session not found:", sessionError);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Session not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!session.calendar_event_id) {
|
||||
console.log("[DELETE-CALENDAR-EVENT] No calendar_event_id found, skipping deletion");
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "No calendar event to delete" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get OAuth config
|
||||
const { data: settings } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("integration_google_calendar_id, google_oauth_config")
|
||||
.single();
|
||||
|
||||
const calendarId = settings?.integration_google_calendar_id;
|
||||
const oauthConfigJson = settings?.google_oauth_config;
|
||||
|
||||
if (!calendarId || !oauthConfigJson) {
|
||||
console.log("[DELETE-CALENDAR-EVENT] Calendar not configured, skipping deletion");
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Calendar not configured" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse OAuth config
|
||||
let oauthConfig: GoogleOAuthConfig;
|
||||
try {
|
||||
oauthConfig = JSON.parse(oauthConfigJson);
|
||||
} catch (error) {
|
||||
console.error("[DELETE-CALENDAR-EVENT] Failed to parse OAuth config");
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Invalid OAuth config" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get access token
|
||||
let accessToken: string;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (oauthConfig.access_token && oauthConfig.expires_at && oauthConfig.expires_at > now + 60) {
|
||||
accessToken = oauthConfig.access_token;
|
||||
} else {
|
||||
const tokenData = await getGoogleAccessToken(oauthConfig);
|
||||
accessToken = tokenData.access_token;
|
||||
|
||||
// Update cached token
|
||||
const newExpiresAt = now + tokenData.expires_in;
|
||||
const updatedConfig = {
|
||||
...oauthConfig,
|
||||
access_token: accessToken,
|
||||
expires_at: newExpiresAt
|
||||
};
|
||||
|
||||
await supabase
|
||||
.from("platform_settings")
|
||||
.update({ google_oauth_config: JSON.stringify(updatedConfig) })
|
||||
.eq("id", settings.id);
|
||||
}
|
||||
|
||||
// Delete event from Google Calendar
|
||||
console.log(`[DELETE-CALENDAR-EVENT] Deleting event ${session.calendar_event_id} from calendar ${calendarId}`);
|
||||
|
||||
const deleteResponse = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(session.calendar_event_id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
if (deleteResponse.status === 410) {
|
||||
// Event already deleted (Gone)
|
||||
console.log("[DELETE-CALENDAR-EVENT] Event already deleted (410)");
|
||||
} else {
|
||||
const errorText = await deleteResponse.text();
|
||||
console.error("[DELETE-CALENDAR-EVENT] Failed to delete event:", errorText);
|
||||
// Don't fail the operation, just log it
|
||||
}
|
||||
} else {
|
||||
console.log("[DELETE-CALENDAR-EVENT] Event deleted successfully");
|
||||
}
|
||||
|
||||
// Clear calendar_event_id from session
|
||||
await supabase
|
||||
.from("consulting_sessions")
|
||||
.update({ calendar_event_id: null })
|
||||
.eq("id", body.session_id);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Calendar event deleted" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[DELETE-CALENDAR-EVENT] Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: error.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -32,6 +32,31 @@ serve(async (req: Request): Promise<Response> => {
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get consulting sessions for this order to delete calendar events
|
||||
const { data: sessions } = await supabase
|
||||
.from("consulting_sessions")
|
||||
.select("id, calendar_event_id")
|
||||
.eq("order_id", order_id);
|
||||
|
||||
if (sessions && sessions.length > 0) {
|
||||
console.log("[DELETE-ORDER] Found consulting sessions:", sessions.length);
|
||||
|
||||
// Delete calendar events for each session
|
||||
for (const session of sessions) {
|
||||
if (session.calendar_event_id) {
|
||||
try {
|
||||
await supabase.functions.invoke('delete-calendar-event', {
|
||||
body: { session_id: session.id }
|
||||
});
|
||||
console.log("[DELETE-ORDER] Deleted calendar event for session:", session.id);
|
||||
} catch (err) {
|
||||
console.log("[DELETE-ORDER] Failed to delete calendar event:", err);
|
||||
// Continue with order deletion even if calendar deletion fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call the database function to delete the order
|
||||
const { data, error } = await supabase
|
||||
.rpc("delete_order", { order_uuid: order_id });
|
||||
|
||||
61
supabase/functions/delete-user/index.ts
Normal file
61
supabase/functions/delete-user/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface DeleteUserRequest {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const body: DeleteUserRequest = await req.json();
|
||||
const { user_id } = body;
|
||||
|
||||
if (!user_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "user_id is required" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Deleting user from auth.users: ${user_id}`);
|
||||
|
||||
// Delete user from auth.users using admin API
|
||||
const { error: deleteError } = await supabase.auth.admin.deleteUser(user_id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Error deleting user from auth.users:', deleteError);
|
||||
throw new Error(`Failed to delete user from auth: ${deleteError.message}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully deleted user: ${user_id}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "User deleted successfully" }),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting user:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: error.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
50
supabase/functions/get-owner-identity/index.ts
Normal file
50
supabase/functions/get-owner-identity/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { data: settings, error } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("owner_name, owner_avatar_url")
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
owner_name: settings?.owner_name || "Dwindi",
|
||||
owner_avatar_url: settings?.owner_avatar_url || "",
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to get owner identity";
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
97
supabase/functions/get-user-by-email/index.ts
Normal file
97
supabase/functions/get-user-by-email/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface GetUserRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { email }: GetUserRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!email) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Missing required field: email" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Invalid email format" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Supabase client with service role
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Looking up user with email: ${email}`);
|
||||
|
||||
// Get user by email from auth.users
|
||||
const { data: { users }, error } = await supabase.auth.admin.listUsers();
|
||||
|
||||
if (error) {
|
||||
console.error('Error listing users:', error);
|
||||
throw new Error(`Failed to lookup user: ${error.message}`);
|
||||
}
|
||||
|
||||
// Find user with matching email
|
||||
const user = users?.find(u => u.email === email);
|
||||
|
||||
if (!user) {
|
||||
console.log('User not found:', email);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "User not found",
|
||||
user_id: null
|
||||
}),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('User found:', { id: user.id, email: user.email, emailConfirmed: user.email_confirmed_at });
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
user_id: user.id,
|
||||
email_confirmed: !!user.email_confirmed_at,
|
||||
created_at: user.created_at
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error getting user by email:", error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: error.message || "Failed to lookup user",
|
||||
user_id: null
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -30,7 +30,7 @@ serve(async (req: Request): Promise<Response> => {
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get full order details with items AND consulting slots
|
||||
// Get full order details with items AND consulting sessions
|
||||
// Use maybeSingle() in case there are no related records
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from("orders")
|
||||
@@ -38,15 +38,18 @@ serve(async (req: Request): Promise<Response> => {
|
||||
*,
|
||||
profiles(email, name),
|
||||
order_items (
|
||||
product_id,
|
||||
product:products (title, type)
|
||||
),
|
||||
consulting_slots (
|
||||
id,
|
||||
date,
|
||||
product_id,
|
||||
unit_price,
|
||||
product:products (title, type, collaborator_user_id, profit_share_percentage, auto_grant_access)
|
||||
),
|
||||
consulting_sessions (
|
||||
id,
|
||||
session_date,
|
||||
start_time,
|
||||
end_time,
|
||||
status
|
||||
status,
|
||||
topic_category
|
||||
)
|
||||
`)
|
||||
.eq("id", order_id)
|
||||
@@ -72,87 +75,121 @@ serve(async (req: Request): Promise<Response> => {
|
||||
id: order.id,
|
||||
payment_status: order.payment_status,
|
||||
order_items_count: order.order_items?.length || 0,
|
||||
consulting_slots_count: order.consulting_slots?.length || 0,
|
||||
consulting_slots: order.consulting_slots
|
||||
consulting_sessions_count: order.consulting_sessions?.length || 0,
|
||||
consulting_sessions: order.consulting_sessions
|
||||
}));
|
||||
|
||||
const userEmail = order.profiles?.email || "";
|
||||
const userName = order.profiles?.name || userEmail.split('@')[0] || "Pelanggan";
|
||||
const orderItems = order.order_items as Array<{
|
||||
id: string;
|
||||
product_id: string;
|
||||
product: { title: string; type: string };
|
||||
unit_price?: number;
|
||||
product: {
|
||||
title: string;
|
||||
type: string;
|
||||
collaborator_user_id?: string | null;
|
||||
profit_share_percentage?: number | null;
|
||||
auto_grant_access?: boolean | null;
|
||||
};
|
||||
}>;
|
||||
|
||||
// Check if this is a consulting order by checking consulting_slots
|
||||
const consultingSlots = order.consulting_slots as Array<{
|
||||
// Check if this is a consulting order by checking consulting_sessions
|
||||
const consultingSessions = order.consulting_sessions as Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
session_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
topic_category?: string;
|
||||
meet_link?: string;
|
||||
}>;
|
||||
const isConsultingOrder = consultingSlots && consultingSlots.length > 0;
|
||||
const isConsultingOrder = consultingSessions && consultingSessions.length > 0;
|
||||
|
||||
console.log("[HANDLE-PAID] isConsultingOrder:", isConsultingOrder, "consultingSlots:", consultingSlots);
|
||||
console.log("[HANDLE-PAID] isConsultingOrder:", isConsultingOrder, "consultingSessions:", consultingSessions);
|
||||
|
||||
if (isConsultingOrder) {
|
||||
console.log("[HANDLE-PAID] Consulting order detected, processing slots");
|
||||
console.log("[HANDLE-PAID] Consulting order detected, processing sessions");
|
||||
|
||||
// Update consulting slots status from pending_payment to confirmed
|
||||
// Update consulting sessions status from pending_payment to confirmed
|
||||
const { error: updateError } = await supabase
|
||||
.from("consulting_slots")
|
||||
.from("consulting_sessions")
|
||||
.update({ status: "confirmed" })
|
||||
.eq("order_id", order_id)
|
||||
.in("status", ["pending_payment"]);
|
||||
|
||||
console.log("[HANDLE-PAID] Slot update result:", { updateError, order_id });
|
||||
console.log("[HANDLE-PAID] Session update result:", { updateError, order_id });
|
||||
|
||||
if (updateError) {
|
||||
console.error("[HANDLE-PAID] Failed to update slots:", updateError);
|
||||
console.error("[HANDLE-PAID] Failed to update sessions:", updateError);
|
||||
}
|
||||
|
||||
if (consultingSlots && consultingSlots.length > 0) {
|
||||
for (const slot of consultingSlots) {
|
||||
try {
|
||||
console.log("[HANDLE-PAID] Creating Google Meet for slot:", slot.id);
|
||||
if (consultingSessions && consultingSessions.length > 0) {
|
||||
try {
|
||||
console.log("[HANDLE-PAID] Creating Google Meet for order:", order_id);
|
||||
|
||||
const topic = "Konsultasi 1-on-1";
|
||||
// Use the first session for Meet creation
|
||||
const session = consultingSessions[0];
|
||||
const topic = session.topic_category || "Konsultasi 1-on-1";
|
||||
|
||||
const meetResponse = await fetch(
|
||||
`${supabaseUrl}/functions/v1/create-google-meet-event`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slot_id: slot.id,
|
||||
date: slot.date,
|
||||
start_time: slot.start_time,
|
||||
end_time: slot.end_time,
|
||||
client_name: userName,
|
||||
client_email: userEmail,
|
||||
topic: topic,
|
||||
}),
|
||||
}
|
||||
);
|
||||
console.log("[HANDLE-PAID] Session time:", `${session.start_time} - ${session.end_time}`);
|
||||
|
||||
if (meetResponse.ok) {
|
||||
const meetData = await meetResponse.json();
|
||||
if (meetData.success) {
|
||||
console.log("[HANDLE-PAID] Meet created:", meetData.meet_link);
|
||||
}
|
||||
const meetResponse = await fetch(
|
||||
`${supabaseUrl}/functions/v1/create-google-meet-event`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slot_id: session.id,
|
||||
date: session.session_date,
|
||||
start_time: session.start_time,
|
||||
end_time: session.end_time,
|
||||
client_name: userName,
|
||||
client_email: userEmail,
|
||||
topic: topic,
|
||||
notes: `Session ID: ${session.id}`,
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HANDLE-PAID] Meet creation failed:", error);
|
||||
// Don't fail the entire process
|
||||
);
|
||||
|
||||
console.log("[HANDLE-PAID] Meet response status:", meetResponse.status);
|
||||
|
||||
if (meetResponse.ok) {
|
||||
const meetData = await meetResponse.json();
|
||||
console.log("[HANDLE-PAID] Meet response data:", meetData);
|
||||
|
||||
if (meetData.success) {
|
||||
console.log("[HANDLE-PAID] Meet created:", meetData.meet_link);
|
||||
|
||||
// Update session with meet link
|
||||
const { error: updateError } = await supabase
|
||||
.from("consulting_sessions")
|
||||
.update({ meet_link: meetData.meet_link })
|
||||
.eq("order_id", order_id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("[HANDLE-PAID] Failed to update meet_link:", updateError);
|
||||
} else {
|
||||
console.log("[HANDLE-PAID] Meet link updated for session:", order_id);
|
||||
}
|
||||
} else {
|
||||
console.error("[HANDLE-PAID] Meet creation returned success=false:", meetData);
|
||||
}
|
||||
} else {
|
||||
const errorText = await meetResponse.text();
|
||||
console.error("[HANDLE-PAID] Meet creation failed with status:", meetResponse.status);
|
||||
console.error("[HANDLE-PAID] Error response:", errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HANDLE-PAID] Meet creation exception:", error);
|
||||
// Don't fail the entire process
|
||||
}
|
||||
}
|
||||
|
||||
// Send consulting notification with the consultingSlots data
|
||||
// Send consulting notification with the consultingSessions data
|
||||
await sendNotification(supabase, "consulting_scheduled", {
|
||||
nama: userName,
|
||||
email: userEmail,
|
||||
@@ -160,14 +197,14 @@ serve(async (req: Request): Promise<Response> => {
|
||||
tanggal_pesanan: new Date().toLocaleDateString("id-ID"),
|
||||
total: `Rp ${order.total_amount.toLocaleString("id-ID")}`,
|
||||
metode_pembayaran: order.payment_method || "Unknown",
|
||||
tanggal_konsultasi: consultingSlots[0]?.date || "",
|
||||
jam_konsultasi: consultingSlots.map(s => s.start_time.substring(0, 5)).join(", "),
|
||||
link_meet: consultingSlots[0]?.meet_link || "Akan dikirim terpisah",
|
||||
tanggal_konsultasi: consultingSessions[0]?.session_date || "",
|
||||
jam_konsultasi: consultingSessions.map(s => `${s.start_time.substring(0, 5)} - ${s.end_time.substring(0, 5)}`).join(", "),
|
||||
link_meet: consultingSessions[0]?.meet_link || "Akan dikirim terpisah",
|
||||
event: "consulting_scheduled",
|
||||
order_id,
|
||||
user_id: order.user_id,
|
||||
user_name: userName,
|
||||
slots: consultingSlots,
|
||||
slots: consultingSessions,
|
||||
});
|
||||
} else {
|
||||
// Regular product order - grant access
|
||||
@@ -191,6 +228,84 @@ serve(async (req: Request): Promise<Response> => {
|
||||
});
|
||||
console.log("[HANDLE-PAID] Access granted for product:", item.product_id);
|
||||
}
|
||||
|
||||
// Collaboration: credit collaborator wallet if this product has a collaborator
|
||||
const collaboratorUserId = item.product?.collaborator_user_id;
|
||||
const profitSharePct = Number(item.product?.profit_share_percentage || 0);
|
||||
const autoGrantAccess = item.product?.auto_grant_access !== false;
|
||||
const itemPrice = Number(item.unit_price || 0);
|
||||
|
||||
if (collaboratorUserId && profitSharePct > 0 && itemPrice > 0) {
|
||||
const hostShare = itemPrice * ((100 - profitSharePct) / 100);
|
||||
const collaboratorShare = itemPrice * (profitSharePct / 100);
|
||||
|
||||
// Save profit split to order_items
|
||||
const { error: splitError } = await supabase
|
||||
.from("order_items")
|
||||
.update({
|
||||
host_share: hostShare,
|
||||
collaborator_share: collaboratorShare,
|
||||
})
|
||||
.eq("id", item.id);
|
||||
|
||||
if (splitError) {
|
||||
console.error("[HANDLE-PAID] Failed to update order item split:", splitError);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Credit collaborator wallet (also stores wallet_transaction_id on order_items)
|
||||
const { data: transactionId, error: creditError } = await supabase
|
||||
.rpc("credit_collaborator_wallet", {
|
||||
p_user_id: collaboratorUserId,
|
||||
p_order_item_id: item.id,
|
||||
p_amount: collaboratorShare,
|
||||
p_description: `Profit from sale: ${item.product?.title || "Product"}`,
|
||||
});
|
||||
|
||||
if (creditError) {
|
||||
console.error("[HANDLE-PAID] Failed to credit collaborator wallet:", creditError);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[HANDLE-PAID] Credited collaborator wallet: ${collaboratorUserId} + Rp ${collaboratorShare}, tx=${transactionId}`
|
||||
);
|
||||
|
||||
// Grant collaborator access to the same product if enabled
|
||||
if (autoGrantAccess) {
|
||||
const { error: collaboratorAccessError } = await supabase
|
||||
.from("user_access")
|
||||
.upsert(
|
||||
{
|
||||
user_id: collaboratorUserId,
|
||||
product_id: item.product_id,
|
||||
access_type: "collaborator",
|
||||
granted_by: order.user_id,
|
||||
},
|
||||
{ onConflict: "user_id,product_id" }
|
||||
);
|
||||
|
||||
if (collaboratorAccessError) {
|
||||
console.error("[HANDLE-PAID] Failed to grant collaborator access:", collaboratorAccessError);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify collaborator about new sale
|
||||
const { error: collabNotifyError } = await supabase.functions.invoke("send-collaboration-notification", {
|
||||
body: {
|
||||
type: "new_sale",
|
||||
collaboratorUserId,
|
||||
productTitle: item.product?.title || "Product",
|
||||
profitAmount: collaboratorShare,
|
||||
profitSharePercentage: profitSharePct,
|
||||
saleDate: order.created_at,
|
||||
},
|
||||
});
|
||||
|
||||
if (collabNotifyError) {
|
||||
console.error("[HANDLE-PAID] Failed to send collaborator notification:", collabNotifyError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const productTitles = orderItems.map(i => i.product.title);
|
||||
@@ -230,12 +345,13 @@ serve(async (req: Request): Promise<Response> => {
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("[HANDLE-PAID] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Internal server error";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Internal server error"
|
||||
error: message
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
@@ -244,9 +360,9 @@ serve(async (req: Request): Promise<Response> => {
|
||||
|
||||
// Helper function to send notification
|
||||
async function sendNotification(
|
||||
supabase: any,
|
||||
supabase: ReturnType<typeof createClient>,
|
||||
templateKey: string,
|
||||
data: Record<string, any>
|
||||
data: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
console.log("[HANDLE-PAID] Sending notification:", templateKey);
|
||||
|
||||
@@ -282,18 +398,30 @@ async function sendNotification(
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email via Mailketing
|
||||
await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email-v2`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: data.email,
|
||||
subject: template.email_subject,
|
||||
html: template.email_body_html,
|
||||
shortcodeData: data,
|
||||
}),
|
||||
});
|
||||
// Send email via send-notification (which will process shortcodes and call send-email-v2)
|
||||
try {
|
||||
const notificationResponse = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-notification`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
template_key: templateKey,
|
||||
recipient_email: String(data.email || ""),
|
||||
recipient_name: String((data.user_name as string) || (data.nama as string) || ""),
|
||||
variables: data,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!notificationResponse.ok) {
|
||||
const errorText = await notificationResponse.text();
|
||||
console.error("[HANDLE-PAID] Notification send failed:", notificationResponse.status, errorText);
|
||||
} else {
|
||||
const result = await notificationResponse.json();
|
||||
console.log("[HANDLE-PAID] Notification sent successfully for template:", templateKey, result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HANDLE-PAID] Exception sending notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
154
supabase/functions/process-withdrawal/index.ts
Normal file
154
supabase/functions/process-withdrawal/index.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: authData } = await supabase.auth.getUser(token);
|
||||
const user = authData.user;
|
||||
if (!user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: isAdmin } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
|
||||
if (!isAdmin) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Forbidden - Admin only" }),
|
||||
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { withdrawalId, status, payment_reference, admin_notes, reason } = await req.json();
|
||||
if (!withdrawalId || !["completed", "rejected"].includes(status)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid payload" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: withdrawal } = await supabase
|
||||
.from("withdrawals")
|
||||
.select("*, user:profiles(name, email)")
|
||||
.eq("id", withdrawalId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!withdrawal) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Withdrawal not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (withdrawal.status !== "pending") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Withdrawal already processed" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
await supabase.rpc("complete_withdrawal", {
|
||||
p_user_id: withdrawal.user_id,
|
||||
p_withdrawal_id: withdrawalId,
|
||||
p_amount: withdrawal.amount,
|
||||
p_payment_reference: payment_reference || "-",
|
||||
});
|
||||
|
||||
await supabase
|
||||
.from("withdrawals")
|
||||
.update({
|
||||
status: "completed",
|
||||
processed_at: new Date().toISOString(),
|
||||
payment_reference: payment_reference || null,
|
||||
admin_notes: admin_notes || null,
|
||||
updated_by: user.id,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", withdrawalId);
|
||||
|
||||
await supabase.functions.invoke("send-collaboration-notification", {
|
||||
body: {
|
||||
type: "withdrawal_completed",
|
||||
userId: withdrawal.user_id,
|
||||
amount: withdrawal.amount,
|
||||
paymentReference: payment_reference || "-",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await supabase.rpc("reject_withdrawal", {
|
||||
p_user_id: withdrawal.user_id,
|
||||
p_withdrawal_id: withdrawalId,
|
||||
p_amount: withdrawal.amount,
|
||||
p_reason: reason || "Withdrawal rejected by admin",
|
||||
});
|
||||
|
||||
await supabase
|
||||
.from("withdrawals")
|
||||
.update({
|
||||
status: "rejected",
|
||||
processed_at: new Date().toISOString(),
|
||||
admin_notes: admin_notes || reason || null,
|
||||
updated_by: user.id,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", withdrawalId);
|
||||
|
||||
await supabase.functions.invoke("send-collaboration-notification", {
|
||||
body: {
|
||||
type: "withdrawal_rejected",
|
||||
userId: withdrawal.user_id,
|
||||
amount: withdrawal.amount,
|
||||
reason: admin_notes || reason || "Withdrawal rejected",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to process withdrawal";
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
120
supabase/functions/send-auth-otp/index.ts
Normal file
120
supabase/functions/send-auth-otp/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface SendOTPRequest {
|
||||
user_id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user_id, email }: SendOTPRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!user_id || !email) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Missing required fields: user_id, email" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Supabase client with service role
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Fetch platform settings for brand name and URL
|
||||
const { data: platformSettings } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('brand_name, platform_url')
|
||||
.single();
|
||||
|
||||
const platformName = platformSettings?.brand_name || 'ACCESS HUB';
|
||||
const platformUrl = platformSettings?.platform_url || 'https://access-hub.com';
|
||||
|
||||
console.log(`Generating OTP for user ${user_id}`);
|
||||
|
||||
// Generate 6-digit OTP code
|
||||
const otpCode = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
|
||||
// Calculate expiration time (15 minutes from now)
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
||||
|
||||
// Store OTP in database
|
||||
const { error: insertError } = await supabase
|
||||
.from('auth_otps')
|
||||
.insert({
|
||||
user_id: user_id,
|
||||
email: email,
|
||||
otp_code: otpCode,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error storing OTP:', insertError);
|
||||
throw new Error(`Failed to store OTP: ${insertError.message}`);
|
||||
}
|
||||
|
||||
console.log(`OTP generated and stored: ${otpCode}, expires at: ${expiresAt}`);
|
||||
|
||||
// Send OTP email using send-notification
|
||||
const notificationUrl = `${supabaseUrl}/functions/v1/send-notification`;
|
||||
const notificationResponse = await fetch(notificationUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${supabaseServiceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
template_key: 'auth_email_verification',
|
||||
recipient_email: email,
|
||||
recipient_name: email.split('@')[0],
|
||||
variables: {
|
||||
nama: email.split('@')[0],
|
||||
otp_code: otpCode,
|
||||
email: email,
|
||||
user_id: user_id,
|
||||
expiry_minutes: '15',
|
||||
platform_name: platformName,
|
||||
platform_url: platformUrl
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!notificationResponse.ok) {
|
||||
const errorText = await notificationResponse.text();
|
||||
console.error('Error sending notification email:', notificationResponse.status, errorText);
|
||||
throw new Error(`Failed to send OTP email: ${notificationResponse.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const notificationResult = await notificationResponse.json();
|
||||
console.log('Notification sent successfully:', notificationResult);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "OTP sent successfully"
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error sending OTP:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: error.message || "Failed to send OTP"
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
172
supabase/functions/send-collaboration-notification/index.ts
Normal file
172
supabase/functions/send-collaboration-notification/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface NotificationPayload {
|
||||
type: "new_sale" | "withdrawal_requested" | "withdrawal_completed" | "withdrawal_rejected";
|
||||
collaboratorUserId?: string;
|
||||
userId?: string;
|
||||
amount?: number;
|
||||
productTitle?: string;
|
||||
profitAmount?: number;
|
||||
profitSharePercentage?: number;
|
||||
saleDate?: string;
|
||||
paymentReference?: string;
|
||||
reason?: string;
|
||||
bankInfo?: {
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function sendEmail(recipient: string, subject: string, content: string): Promise<void> {
|
||||
const response = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email-v2`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient,
|
||||
subject,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`send-email-v2 failed: ${response.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = await req.json() as NotificationPayload;
|
||||
const { type } = data;
|
||||
|
||||
let recipientEmail = "";
|
||||
let subject = "";
|
||||
let htmlContent = "";
|
||||
|
||||
if (type === "new_sale") {
|
||||
const { data: collaborator } = await supabase
|
||||
.from("profiles")
|
||||
.select("email, name")
|
||||
.eq("id", data.collaboratorUserId || "")
|
||||
.maybeSingle();
|
||||
|
||||
recipientEmail = collaborator?.email || "";
|
||||
subject = `🎉 You earned Rp ${(data.profitAmount || 0).toLocaleString("id-ID")} from ${data.productTitle || "your product"}!`;
|
||||
htmlContent = `
|
||||
<h2>Great news, ${collaborator?.name || "Partner"}!</h2>
|
||||
<p>Your collaborative webinar <strong>${data.productTitle || "-"}</strong> just made a sale.</p>
|
||||
<ul>
|
||||
<li>Your Share: ${data.profitSharePercentage || 0}%</li>
|
||||
<li>Profit Earned: <strong>Rp ${(data.profitAmount || 0).toLocaleString("id-ID")}</strong></li>
|
||||
<li>Sale Date: ${data.saleDate ? new Date(data.saleDate).toLocaleDateString("id-ID") : "-"}</li>
|
||||
</ul>
|
||||
`;
|
||||
} else if (type === "withdrawal_requested") {
|
||||
const { data: adminRole } = await supabase
|
||||
.from("user_roles")
|
||||
.select("user_id")
|
||||
.eq("role", "admin")
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const { data: admin } = await supabase
|
||||
.from("profiles")
|
||||
.select("email")
|
||||
.eq("id", adminRole?.user_id || "")
|
||||
.maybeSingle();
|
||||
|
||||
recipientEmail = admin?.email || "";
|
||||
subject = "💸 New Withdrawal Request";
|
||||
htmlContent = `
|
||||
<h2>New Withdrawal Request</h2>
|
||||
<p>A collaborator has requested withdrawal:</p>
|
||||
<ul>
|
||||
<li>Amount: <strong>Rp ${(data.amount || 0).toLocaleString("id-ID")}</strong></li>
|
||||
<li>Bank: ${data.bankInfo?.bankName || "-"}</li>
|
||||
<li>Account: ${data.bankInfo?.accountNumber || "-"} (${data.bankInfo?.accountName || "-"})</li>
|
||||
</ul>
|
||||
`;
|
||||
} else if (type === "withdrawal_completed") {
|
||||
const { data: user } = await supabase
|
||||
.from("profiles")
|
||||
.select("email, name")
|
||||
.eq("id", data.userId || "")
|
||||
.maybeSingle();
|
||||
|
||||
recipientEmail = user?.email || "";
|
||||
subject = `✅ Withdrawal Completed: Rp ${(data.amount || 0).toLocaleString("id-ID")}`;
|
||||
htmlContent = `
|
||||
<h2>Withdrawal Completed, ${user?.name || "Partner"}!</h2>
|
||||
<ul>
|
||||
<li>Amount: <strong>Rp ${(data.amount || 0).toLocaleString("id-ID")}</strong></li>
|
||||
<li>Payment Reference: ${data.paymentReference || "-"}</li>
|
||||
</ul>
|
||||
`;
|
||||
} else if (type === "withdrawal_rejected") {
|
||||
const { data: user } = await supabase
|
||||
.from("profiles")
|
||||
.select("email, name")
|
||||
.eq("id", data.userId || "")
|
||||
.maybeSingle();
|
||||
|
||||
recipientEmail = user?.email || "";
|
||||
subject = "❌ Withdrawal Request Returned";
|
||||
htmlContent = `
|
||||
<h2>Withdrawal Request Returned</h2>
|
||||
<p>Hi ${user?.name || "Partner"},</p>
|
||||
<p>Your withdrawal request of <strong>Rp ${(data.amount || 0).toLocaleString("id-ID")}</strong> has been returned to your wallet.</p>
|
||||
<p>Reason: ${data.reason || "Contact admin for details"}</p>
|
||||
`;
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unknown notification type" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!recipientEmail) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Recipient email not found" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
await sendEmail(recipientEmail, subject, htmlContent);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to send notification";
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get current date/time in Jakarta timezone
|
||||
const now = new Date();
|
||||
const jakartaOffset = 7 * 60; // UTC+7
|
||||
const jakartaTime = new Date(now.getTime() + jakartaOffset * 60 * 1000);
|
||||
const today = jakartaTime.toISOString().split('T')[0];
|
||||
|
||||
// Find consultations happening in the next 24 hours that haven't been reminded
|
||||
const tomorrow = new Date(jakartaTime);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
console.log("Checking consultations for dates:", today, "to", tomorrowStr);
|
||||
|
||||
// Get confirmed slots for today and tomorrow
|
||||
const { data: upcomingSlots, error: slotsError } = await supabase
|
||||
.from("consulting_slots")
|
||||
.select(`
|
||||
*,
|
||||
profiles:user_id (full_name, email)
|
||||
`)
|
||||
.eq("status", "confirmed")
|
||||
.gte("date", today)
|
||||
.lte("date", tomorrowStr)
|
||||
.order("date")
|
||||
.order("start_time");
|
||||
|
||||
if (slotsError) {
|
||||
console.error("Error fetching slots:", slotsError);
|
||||
throw slotsError;
|
||||
}
|
||||
|
||||
console.log("Found upcoming slots:", upcomingSlots?.length || 0);
|
||||
|
||||
if (!upcomingSlots || upcomingSlots.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "No upcoming consultations to remind" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get notification template for consultation reminder
|
||||
const { data: template } = await supabase
|
||||
.from("notification_templates")
|
||||
.select("*")
|
||||
.eq("key", "consulting_scheduled")
|
||||
.single();
|
||||
|
||||
// Get SMTP settings
|
||||
const { data: smtpSettings } = await supabase
|
||||
.from("notification_settings")
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
// Get platform settings
|
||||
const { data: platformSettings } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("brand_name, brand_email_from_name, integration_whatsapp_number")
|
||||
.single();
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const slot of upcomingSlots) {
|
||||
const profile = slot.profiles as any;
|
||||
|
||||
// Build payload for notification
|
||||
const payload = {
|
||||
nama: profile?.full_name || "Pelanggan",
|
||||
email: profile?.email || "",
|
||||
tanggal_konsultasi: new Date(slot.date).toLocaleDateString("id-ID", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
jam_konsultasi: `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)} WIB`,
|
||||
link_meet: slot.meet_link || "Akan diinformasikan",
|
||||
topik: slot.topic_category,
|
||||
catatan: slot.notes || "-",
|
||||
brand_name: platformSettings?.brand_name || "LearnHub",
|
||||
whatsapp: platformSettings?.integration_whatsapp_number || "",
|
||||
};
|
||||
|
||||
// Log the reminder payload
|
||||
console.log("Reminder payload for slot:", slot.id, payload);
|
||||
|
||||
// Update last_payload_example in template
|
||||
if (template) {
|
||||
await supabase
|
||||
.from("notification_templates")
|
||||
.update({ last_payload_example: payload })
|
||||
.eq("id", template.id);
|
||||
}
|
||||
|
||||
// Send webhook if configured
|
||||
if (template?.webhook_url) {
|
||||
try {
|
||||
await fetch(template.webhook_url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event: "consulting_reminder",
|
||||
slot_id: slot.id,
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
console.log("Webhook sent for slot:", slot.id);
|
||||
} catch (webhookError) {
|
||||
console.error("Webhook error:", webhookError);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email if template is active and Mailketing is configured
|
||||
if (template?.is_active && smtpSettings?.api_token && profile?.email) {
|
||||
try {
|
||||
// Replace shortcodes in email body using master template system
|
||||
let emailBody = template.email_body_html || "";
|
||||
let emailSubject = template.email_subject || "Reminder Konsultasi";
|
||||
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, "g");
|
||||
emailBody = emailBody.replace(regex, String(value));
|
||||
emailSubject = emailSubject.replace(regex, String(value));
|
||||
});
|
||||
|
||||
// Send via send-email-v2 (Mailketing API)
|
||||
const { error: emailError } = await supabase.functions.invoke("send-email-v2", {
|
||||
body: {
|
||||
to: profile.email,
|
||||
api_token: smtpSettings.api_token,
|
||||
from_name: smtpSettings.from_name || platformSettings?.brand_name || "Access Hub",
|
||||
from_email: smtpSettings.from_email || "noreply@with.dwindi.com",
|
||||
subject: emailSubject,
|
||||
html_body: emailBody,
|
||||
},
|
||||
});
|
||||
|
||||
if (emailError) {
|
||||
console.error("Failed to send reminder email:", emailError);
|
||||
} else {
|
||||
console.log("Reminder email sent to:", profile.email);
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error("Error sending reminder email:", emailError);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
slot_id: slot.id,
|
||||
client: profile?.full_name,
|
||||
date: slot.date,
|
||||
time: slot.start_time,
|
||||
reminded: true,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: `Processed ${results.length} consultation reminders`,
|
||||
results
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error sending reminders:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: error.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
@@ -6,33 +7,37 @@ const corsHeaders = {
|
||||
};
|
||||
|
||||
interface EmailRequest {
|
||||
to: string;
|
||||
api_token: string;
|
||||
from_name: string;
|
||||
from_email: string;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
html_body: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Send via Mailketing API
|
||||
async function sendViaMailketing(request: EmailRequest): Promise<{ success: boolean; message: string }> {
|
||||
const { to, api_token, from_name, from_email, subject, html_body } = request;
|
||||
async function sendViaMailketing(
|
||||
request: EmailRequest,
|
||||
apiToken: string,
|
||||
fromName: string,
|
||||
fromEmail: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const { recipient, subject, content } = request;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('to', to);
|
||||
formData.append('from_name', from_name);
|
||||
formData.append('from_email', from_email);
|
||||
formData.append('subject', subject);
|
||||
formData.append('html_body', html_body);
|
||||
// Build form-encoded body (http_build_query format)
|
||||
const params = new URLSearchParams();
|
||||
params.append('api_token', apiToken);
|
||||
params.append('from_name', fromName);
|
||||
params.append('from_email', fromEmail);
|
||||
params.append('recipient', recipient);
|
||||
params.append('subject', subject);
|
||||
params.append('content', content);
|
||||
|
||||
console.log(`Sending email via Mailketing to ${to}`);
|
||||
console.log(`Sending email via Mailketing to ${recipient}`);
|
||||
|
||||
const response = await fetch('https://api.mailketing.co/v1/send', {
|
||||
const response = await fetch('https://api.mailketing.co.id/api/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${api_token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData,
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -46,7 +51,7 @@ async function sendViaMailketing(request: EmailRequest): Promise<{ success: bool
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || 'Email sent successfully via Mailketing'
|
||||
message: result.response || 'Email sent successfully via Mailketing'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,30 +61,57 @@ serve(async (req: Request): Promise<Response> => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Supabase client
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Fetch email settings from platform_settings
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (settingsError || !settings) {
|
||||
console.error('Error fetching platform settings:', settingsError);
|
||||
throw new Error('Failed to fetch email configuration from platform_settings');
|
||||
}
|
||||
|
||||
const apiToken = settings.integration_email_api_token;
|
||||
const fromName = settings.integration_email_from_name || settings.brand_name;
|
||||
const fromEmail = settings.integration_email_from_email;
|
||||
|
||||
if (!apiToken || !fromEmail) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Email not configured. Please set API token and from email in platform settings." }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const body: EmailRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!body.to || !body.api_token || !body.from_name || !body.from_email || !body.subject || !body.html_body) {
|
||||
if (!body.recipient || !body.subject || !body.content) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Missing required fields: to, api_token, from_name, from_email, subject, html_body" }),
|
||||
JSON.stringify({ success: false, message: "Missing required fields: recipient, subject, content" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(body.to) || !emailRegex.test(body.from_email)) {
|
||||
if (!emailRegex.test(body.recipient) || !emailRegex.test(fromEmail)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Invalid email format" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Attempting to send email to: ${body.to}`);
|
||||
console.log(`From: ${body.from_name} <${body.from_email}>`);
|
||||
console.log(`Attempting to send email to: ${body.recipient}`);
|
||||
console.log(`From: ${fromName} <${fromEmail}>`);
|
||||
console.log(`Subject: ${body.subject}`);
|
||||
|
||||
const result = await sendViaMailketing(body);
|
||||
const result = await sendViaMailketing(body, apiToken, fromName, fromEmail);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(result),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
@@ -31,6 +32,36 @@ interface EmailPayload {
|
||||
from_email: string;
|
||||
}
|
||||
|
||||
// Send via Mailketing API
|
||||
async function sendViaMailketing(payload: EmailPayload, apiToken: string): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('api_token', apiToken);
|
||||
params.append('from_name', payload.from_name);
|
||||
params.append('from_email', payload.from_email);
|
||||
params.append('recipient', payload.to);
|
||||
params.append('subject', payload.subject);
|
||||
params.append('content', payload.html);
|
||||
|
||||
console.log(`Sending email via Mailketing to ${payload.to}`);
|
||||
|
||||
const response = await fetch('https://api.mailketing.co.id/api/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Mailketing API error:', response.status, errorText);
|
||||
throw new Error(`Mailketing API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Mailketing API response:', result);
|
||||
}
|
||||
|
||||
// Send via SMTP
|
||||
async function sendViaSMTP(payload: EmailPayload, config: SMTPConfig): Promise<void> {
|
||||
const boundary = "----=_Part_" + Math.random().toString(36).substr(2, 9);
|
||||
@@ -191,7 +222,9 @@ async function sendViaMailgun(payload: EmailPayload, apiKey: string, domain: str
|
||||
function replaceVariables(template: string, variables: Record<string, string>): string {
|
||||
let result = template;
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
// Support both {key} and {{key}} formats
|
||||
result = result.replace(new RegExp(`{{${key}}}`, 'g'), value);
|
||||
result = result.replace(new RegExp(`{${key}}`, 'g'), value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -213,7 +246,7 @@ serve(async (req: Request): Promise<Response> => {
|
||||
const { data: template, error: templateError } = await supabase
|
||||
.from("notification_templates")
|
||||
.select("*")
|
||||
.eq("template_key", template_key)
|
||||
.eq("key", template_key)
|
||||
.eq("is_active", true)
|
||||
.single();
|
||||
|
||||
@@ -225,81 +258,60 @@ serve(async (req: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
|
||||
// Get platform settings
|
||||
const { data: settings } = await supabase
|
||||
// Get platform settings (includes email configuration)
|
||||
const { data: platformSettings, error: platformError } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (!settings) {
|
||||
if (platformError || !platformSettings) {
|
||||
console.error('Error fetching platform settings:', platformError);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Platform settings not configured" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const brandName = platformSettings.brand_name || "ACCESS HUB";
|
||||
|
||||
// Build email payload
|
||||
const allVariables = {
|
||||
recipient_name: recipient_name || "Pelanggan",
|
||||
platform_name: settings.brand_name || "Platform",
|
||||
platform_name: brandName,
|
||||
...variables,
|
||||
};
|
||||
|
||||
const subject = replaceVariables(template.subject, allVariables);
|
||||
const htmlBody = replaceVariables(template.body_html || template.body_text || "", allVariables);
|
||||
const subject = replaceVariables(template.email_subject || template.subject || "", allVariables);
|
||||
const htmlContent = replaceVariables(template.email_body_html || template.body_html || template.body_text || "", allVariables);
|
||||
|
||||
// Wrap with master template for consistent branding
|
||||
const htmlBody = EmailTemplateRenderer.render({
|
||||
subject: subject,
|
||||
content: htmlContent,
|
||||
brandName: brandName,
|
||||
});
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
to: recipient_email,
|
||||
subject,
|
||||
html: htmlBody,
|
||||
from_name: settings.brand_email_from_name || settings.brand_name || "Notifikasi",
|
||||
from_email: settings.smtp_from_email || "noreply@example.com",
|
||||
from_name: platformSettings.integration_email_from_name || brandName || "Notifikasi",
|
||||
from_email: platformSettings.integration_email_from_email || "noreply@example.com",
|
||||
};
|
||||
|
||||
// Determine provider and send
|
||||
const provider = settings.integration_email_provider || "smtp";
|
||||
const provider = platformSettings.integration_email_provider || "mailketing";
|
||||
console.log(`Sending email via ${provider} to ${recipient_email}`);
|
||||
|
||||
switch (provider) {
|
||||
case "smtp":
|
||||
await sendViaSMTP(emailPayload, {
|
||||
host: settings.smtp_host,
|
||||
port: settings.smtp_port || 587,
|
||||
username: settings.smtp_username,
|
||||
password: settings.smtp_password,
|
||||
from_name: emailPayload.from_name,
|
||||
from_email: emailPayload.from_email,
|
||||
use_tls: settings.smtp_use_tls ?? true,
|
||||
});
|
||||
break;
|
||||
|
||||
case "resend":
|
||||
const resendKey = Deno.env.get("RESEND_API_KEY");
|
||||
if (!resendKey) throw new Error("RESEND_API_KEY not configured");
|
||||
await sendViaResend(emailPayload, resendKey);
|
||||
break;
|
||||
|
||||
case "elasticemail":
|
||||
const elasticKey = Deno.env.get("ELASTICEMAIL_API_KEY");
|
||||
if (!elasticKey) throw new Error("ELASTICEMAIL_API_KEY not configured");
|
||||
await sendViaElasticEmail(emailPayload, elasticKey);
|
||||
break;
|
||||
|
||||
case "sendgrid":
|
||||
const sendgridKey = Deno.env.get("SENDGRID_API_KEY");
|
||||
if (!sendgridKey) throw new Error("SENDGRID_API_KEY not configured");
|
||||
await sendViaSendGrid(emailPayload, sendgridKey);
|
||||
break;
|
||||
|
||||
case "mailgun":
|
||||
const mailgunKey = Deno.env.get("MAILGUN_API_KEY");
|
||||
const mailgunDomain = Deno.env.get("MAILGUN_DOMAIN");
|
||||
if (!mailgunKey || !mailgunDomain) throw new Error("MAILGUN credentials not configured");
|
||||
await sendViaMailgun(emailPayload, mailgunKey, mailgunDomain);
|
||||
case "mailketing":
|
||||
const mailketingToken = platformSettings.integration_email_api_token;
|
||||
if (!mailketingToken) throw new Error("Mailketing API token not configured");
|
||||
await sendViaMailketing(emailPayload, mailketingToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown email provider: ${provider}`);
|
||||
throw new Error(`Unknown email provider: ${provider}. Only 'mailketing' is supported.`);
|
||||
}
|
||||
|
||||
// Log notification
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface TestEmailRequest {
|
||||
to: 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;
|
||||
}
|
||||
|
||||
async function sendEmail(config: TestEmailRequest): Promise<{ success: boolean; message: string }> {
|
||||
const { to, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from_name, smtp_from_email, smtp_use_tls } = config;
|
||||
|
||||
// Build email content
|
||||
const boundary = "----=_Part_" + Math.random().toString(36).substr(2, 9);
|
||||
const emailContent = [
|
||||
`From: "${smtp_from_name}" <${smtp_from_email}>`,
|
||||
`To: ${to}`,
|
||||
`Subject: =?UTF-8?B?${btoa("Email Uji Coba - Konfigurasi SMTP Berhasil")}?=`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=UTF-8`,
|
||||
``,
|
||||
`Ini adalah email uji coba dari sistem notifikasi Anda.`,
|
||||
`Jika Anda menerima email ini, konfigurasi SMTP Anda sudah benar.`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/html; charset=UTF-8`,
|
||||
``,
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #0066cc;">Email Uji Coba Berhasil! ✓</h2>
|
||||
<p>Ini adalah email uji coba dari sistem notifikasi Anda.</p>
|
||||
<p>Jika Anda menerima email ini, konfigurasi SMTP Anda sudah benar.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
Dikirim dari: ${smtp_from_email}<br>
|
||||
Server: ${smtp_host}:${smtp_port}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
`--${boundary}--`,
|
||||
].join("\r\n");
|
||||
|
||||
// Connect to SMTP server
|
||||
const conn = smtp_use_tls
|
||||
? await Deno.connectTls({ hostname: smtp_host, port: smtp_port })
|
||||
: await Deno.connect({ hostname: smtp_host, port: smtp_port });
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
async function readResponse(): Promise<string> {
|
||||
const buffer = new Uint8Array(1024);
|
||||
const n = await conn.read(buffer);
|
||||
if (n === null) return "";
|
||||
return decoder.decode(buffer.subarray(0, n));
|
||||
}
|
||||
|
||||
async function sendCommand(cmd: string): Promise<string> {
|
||||
await conn.write(encoder.encode(cmd + "\r\n"));
|
||||
return await readResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
// Read greeting
|
||||
await readResponse();
|
||||
|
||||
// EHLO
|
||||
let response = await sendCommand(`EHLO localhost`);
|
||||
console.log("EHLO response:", response);
|
||||
|
||||
// For non-TLS connection on port 587, we may need STARTTLS
|
||||
if (!smtp_use_tls && response.includes("STARTTLS")) {
|
||||
await sendCommand("STARTTLS");
|
||||
// Upgrade to TLS - not supported in basic Deno.connect
|
||||
// For now, recommend using TLS directly
|
||||
}
|
||||
|
||||
// AUTH LOGIN
|
||||
response = await sendCommand("AUTH LOGIN");
|
||||
console.log("AUTH response:", response);
|
||||
|
||||
// Username (base64)
|
||||
response = await sendCommand(btoa(smtp_username));
|
||||
console.log("Username response:", response);
|
||||
|
||||
// Password (base64)
|
||||
response = await sendCommand(btoa(smtp_password));
|
||||
console.log("Password response:", response);
|
||||
|
||||
if (!response.includes("235") && !response.includes("Authentication successful")) {
|
||||
throw new Error("Authentication failed: " + response);
|
||||
}
|
||||
|
||||
// MAIL FROM
|
||||
response = await sendCommand(`MAIL FROM:<${smtp_from_email}>`);
|
||||
if (!response.includes("250")) {
|
||||
throw new Error("MAIL FROM failed: " + response);
|
||||
}
|
||||
|
||||
// RCPT TO
|
||||
response = await sendCommand(`RCPT TO:<${to}>`);
|
||||
if (!response.includes("250")) {
|
||||
throw new Error("RCPT TO failed: " + response);
|
||||
}
|
||||
|
||||
// DATA
|
||||
response = await sendCommand("DATA");
|
||||
if (!response.includes("354")) {
|
||||
throw new Error("DATA failed: " + response);
|
||||
}
|
||||
|
||||
// Send email content
|
||||
await conn.write(encoder.encode(emailContent + "\r\n.\r\n"));
|
||||
response = await readResponse();
|
||||
if (!response.includes("250")) {
|
||||
throw new Error("Email send failed: " + response);
|
||||
}
|
||||
|
||||
// QUIT
|
||||
await sendCommand("QUIT");
|
||||
conn.close();
|
||||
|
||||
return { success: true, message: "Email uji coba berhasil dikirim ke " + to };
|
||||
} catch (error) {
|
||||
conn.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const body: TestEmailRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!body.to || !body.smtp_host || !body.smtp_username || !body.smtp_password) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Missing required fields" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Attempting to send test email to:", body.to);
|
||||
console.log("SMTP config:", { host: body.smtp_host, port: body.smtp_port, user: body.smtp_username });
|
||||
|
||||
const result = await sendEmail(body);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(result),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Error sending test email:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: error.message || "Failed to send email" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
86
supabase/functions/trigger-calendar-cleanup/index.ts
Normal file
86
supabase/functions/trigger-calendar-cleanup/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
console.log("[CALENDAR-CLEANUP] Starting calendar cleanup for cancelled sessions");
|
||||
|
||||
// Find cancelled consulting sessions with calendar events
|
||||
const { data: cancelledSessions, error } = await supabase
|
||||
.from("consulting_sessions")
|
||||
.select("id, calendar_event_id")
|
||||
.eq("status", "cancelled")
|
||||
.not("calendar_event_id", "is", null);
|
||||
|
||||
if (error) {
|
||||
console.error("[CALENDAR-CLEANUP] Query error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!cancelledSessions || cancelledSessions.length === 0) {
|
||||
console.log("[CALENDAR-CLEANUP] No cancelled sessions with calendar events found");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "No calendar events to clean up",
|
||||
processed: 0
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[CALENDAR-CLEANUP] Found ${cancelledSessions.length} cancelled sessions with calendar events`);
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
// Delete calendar events for cancelled sessions
|
||||
for (const session of cancelledSessions) {
|
||||
if (session.calendar_event_id) {
|
||||
try {
|
||||
await supabase.functions.invoke('delete-calendar-event', {
|
||||
body: { session_id: session.id }
|
||||
});
|
||||
console.log(`[CALENDAR-CLEANUP] Deleted calendar event for session: ${session.id}`);
|
||||
processedCount++;
|
||||
} catch (err) {
|
||||
console.log(`[CALENDAR-CLEANUP] Failed to delete calendar event: ${err}`);
|
||||
// Continue with other events even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CALENDAR-CLEANUP] Successfully cleaned up ${processedCount} calendar events`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: `Successfully cleaned up ${processedCount} calendar events`,
|
||||
processed: processedCount
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[CALENDAR-CLEANUP] Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Internal server error"
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
278
supabase/functions/update-calendar-event/index.ts
Normal file
278
supabase/functions/update-calendar-event/index.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface GoogleOAuthConfig {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
refresh_token: string;
|
||||
access_token?: string;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
interface UpdateEventRequest {
|
||||
session_id: string;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
}
|
||||
|
||||
// Function to get access token from refresh token (OAuth2)
|
||||
async function getGoogleAccessToken(oauthConfig: GoogleOAuthConfig): Promise<{ access_token: string; expires_in: number }> {
|
||||
try {
|
||||
console.log("Attempting to exchange refresh token for access token...");
|
||||
|
||||
const tokenRequest = {
|
||||
client_id: oauthConfig.client_id,
|
||||
client_secret: oauthConfig.client_secret,
|
||||
refresh_token: oauthConfig.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
};
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(tokenRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Token response error:", errorText);
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error("No access token in response");
|
||||
}
|
||||
|
||||
console.log("Successfully obtained access token");
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
expires_in: data.expires_in || 3600
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Error getting Google access token:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const logs: string[] = [];
|
||||
const log = (msg: string) => {
|
||||
console.log(msg);
|
||||
logs.push(msg);
|
||||
};
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
const body: UpdateEventRequest = await req.json();
|
||||
const { session_id, date, start_time, end_time } = body;
|
||||
|
||||
log(`Updating calendar event for session: ${session_id}`);
|
||||
log(`New time: ${date} ${start_time} - ${end_time}`);
|
||||
|
||||
// Get session details including calendar_event_id
|
||||
const { data: session, error: sessionError } = await supabase
|
||||
.from("consulting_sessions")
|
||||
.select("id, calendar_event_id, topic_category, profiles(name, email), notes, meet_link")
|
||||
.eq("id", session_id)
|
||||
.single();
|
||||
|
||||
if (sessionError || !session) {
|
||||
log(`Session not found: ${sessionError?.message}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Session not found",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!session.calendar_event_id) {
|
||||
log("No calendar event ID found for this session");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "No calendar event linked to this session",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get platform settings
|
||||
log("Fetching platform settings...");
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("integration_google_calendar_id, google_oauth_config")
|
||||
.single();
|
||||
|
||||
if (settingsError || !settings) {
|
||||
log(`Error fetching settings: ${settingsError?.message}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Error fetching settings",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const calendarId = settings.integration_google_calendar_id;
|
||||
if (!calendarId) {
|
||||
log("Calendar ID not configured");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Google Calendar ID not configured",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get OAuth config
|
||||
const oauthConfigJson = settings.google_oauth_config;
|
||||
if (!oauthConfigJson) {
|
||||
log("OAuth config not found");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Google OAuth Config not configured",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
let oauthConfig: GoogleOAuthConfig;
|
||||
try {
|
||||
oauthConfig = JSON.parse(oauthConfigJson);
|
||||
} catch (error: any) {
|
||||
log(`Failed to parse OAuth config: ${error.message}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Invalid OAuth config format",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get access token
|
||||
let accessToken: string;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (oauthConfig.access_token && oauthConfig.expires_at && oauthConfig.expires_at > now + 60) {
|
||||
log(`Using cached access_token`);
|
||||
accessToken = oauthConfig.access_token;
|
||||
} else {
|
||||
log("Refreshing access token...");
|
||||
const tokenData = await getGoogleAccessToken(oauthConfig);
|
||||
accessToken = tokenData.access_token;
|
||||
|
||||
const newExpiresAt = now + tokenData.expires_in;
|
||||
const updatedConfig = {
|
||||
...oauthConfig,
|
||||
access_token: accessToken,
|
||||
expires_at: newExpiresAt
|
||||
};
|
||||
|
||||
await supabase
|
||||
.from("platform_settings")
|
||||
.update({ google_oauth_config: JSON.stringify(updatedConfig) })
|
||||
.eq("id", settings.id);
|
||||
|
||||
log("Updated cached access_token in database");
|
||||
}
|
||||
|
||||
// Build event data for update
|
||||
const startDate = new Date(`${date}T${start_time}+07:00`);
|
||||
const endDate = new Date(`${date}T${end_time}+07:00`);
|
||||
|
||||
log(`Event time: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||
|
||||
const eventData = {
|
||||
start: {
|
||||
dateTime: startDate.toISOString(),
|
||||
timeZone: "Asia/Jakarta",
|
||||
},
|
||||
end: {
|
||||
dateTime: endDate.toISOString(),
|
||||
timeZone: "Asia/Jakarta",
|
||||
},
|
||||
};
|
||||
|
||||
log(`Updating event ${session.calendar_event_id} in calendar ${calendarId}`);
|
||||
|
||||
// Update event via Google Calendar API
|
||||
const calendarResponse = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(session.calendar_event_id)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(eventData),
|
||||
}
|
||||
);
|
||||
|
||||
log(`Calendar API response status: ${calendarResponse.status}`);
|
||||
|
||||
if (!calendarResponse.ok) {
|
||||
const errorText = await calendarResponse.text();
|
||||
log(`Google Calendar API error: ${errorText}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Failed to update event in Google Calendar: " + errorText,
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const eventDataResult = await calendarResponse.json();
|
||||
log(`Event updated successfully: ${eventDataResult.id}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
event_id: eventDataResult.id,
|
||||
html_link: eventDataResult.htmlLink,
|
||||
logs: logs
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
log(`Error updating calendar event: ${error.message}`);
|
||||
log(`Stack: ${error.stack}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: error.message || "Internal server error",
|
||||
logs: logs
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
122
supabase/functions/verify-auth-otp/index.ts
Normal file
122
supabase/functions/verify-auth-otp/index.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface VerifyOTPRequest {
|
||||
user_id: string;
|
||||
otp_code: string;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user_id, otp_code }: VerifyOTPRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!user_id || !otp_code) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Missing required fields: user_id, otp_code" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate OTP format (6 digits)
|
||||
if (!/^\d{6}$/.test(otp_code)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Invalid OTP format. Must be 6 digits." }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Supabase client with service role
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
console.log(`Verifying OTP for user ${user_id}`);
|
||||
|
||||
// Find valid OTP (not expired, not used)
|
||||
const { data: otpRecord, error: otpError } = await supabase
|
||||
.from('auth_otps')
|
||||
.select('*')
|
||||
.eq('user_id', user_id)
|
||||
.eq('otp_code', otp_code)
|
||||
.is('used_at', null)
|
||||
.gt('expires_at', new Date().toISOString())
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (otpError) {
|
||||
console.error('Error fetching OTP:', otpError);
|
||||
throw new Error(`Failed to verify OTP: ${otpError.message}`);
|
||||
}
|
||||
|
||||
if (!otpRecord) {
|
||||
console.log('Invalid or expired OTP');
|
||||
|
||||
// Clean up expired OTPs
|
||||
await supabase.rpc('cleanup_expired_otps');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Invalid or expired OTP code"
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Mark OTP as used
|
||||
const { error: updateError } = await supabase
|
||||
.from('auth_otps')
|
||||
.update({ used_at: new Date().toISOString() })
|
||||
.eq('id', otpRecord.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error marking OTP as used:', updateError);
|
||||
throw new Error(`Failed to mark OTP as used: ${updateError.message}`);
|
||||
}
|
||||
|
||||
// Confirm email in Supabase Auth
|
||||
const { error: confirmError } = await supabase.auth.admin.updateUserById(
|
||||
user_id,
|
||||
{ email_confirm: true }
|
||||
);
|
||||
|
||||
if (confirmError) {
|
||||
console.error('Error confirming email:', confirmError);
|
||||
throw new Error(`Failed to confirm email: ${confirmError.message}`);
|
||||
}
|
||||
|
||||
console.log(`Email confirmed successfully for user ${user_id}`);
|
||||
|
||||
// Clean up expired OTPs
|
||||
await supabase.rpc('cleanup_expired_otps');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "Email verified successfully"
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error verifying OTP:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: error.message || "Failed to verify OTP"
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
12
supabase/migrations/20241228_add_calendar_event_id.sql
Normal file
12
supabase/migrations/20241228_add_calendar_event_id.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Add calendar_event_id column to consulting_sessions
|
||||
-- This stores the Google Calendar event ID for later deletion
|
||||
|
||||
ALTER TABLE consulting_sessions
|
||||
ADD COLUMN calendar_event_id TEXT;
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX idx_consulting_sessions_calendar_event
|
||||
ON consulting_sessions(calendar_event_id);
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON COLUMN consulting_sessions.calendar_event_id IS 'Google Calendar event ID - used to delete events when sessions are cancelled/refunded';
|
||||
13
supabase/migrations/20241228_remove_pg_cron_job.sql
Normal file
13
supabase/migrations/20241228_remove_pg_cron_job.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- ============================================
|
||||
-- Remove pg_cron job (migrating to Coolify-only approach)
|
||||
-- ============================================
|
||||
-- We're moving all cron jobs to Coolify for single source of truth
|
||||
|
||||
-- Remove the pg_cron job
|
||||
SELECT cron.unschedule('cancel-expired-consulting-orders');
|
||||
|
||||
-- Verify it's removed
|
||||
SELECT jobname, schedule, command
|
||||
FROM cron.job
|
||||
WHERE jobname LIKE 'cancel-expired%';
|
||||
-- Should return 0 rows
|
||||
118
supabase/migrations/20241228_schedule_cancel_expired_orders.sql
Normal file
118
supabase/migrations/20241228_schedule_cancel_expired_orders.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- ============================================
|
||||
-- SQL Function for Expired Consulting Orders Cleanup
|
||||
-- ============================================
|
||||
-- This creates a reusable SQL function that can be called from
|
||||
-- Coolify Scheduled Tasks to cancel expired consulting orders
|
||||
--
|
||||
-- NOTE: We use Coolify for ALL cron jobs (single source of truth)
|
||||
-- instead of mixing pg_cron and Coolify scheduled tasks
|
||||
|
||||
-- Drop existing function if exists (to handle return type change)
|
||||
DROP FUNCTION IF EXISTS cancel_expired_consulting_orders_sql();
|
||||
|
||||
-- Create SQL function to cancel expired orders
|
||||
CREATE OR REPLACE FUNCTION cancel_expired_consulting_orders_sql()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
expired_order RECORD;
|
||||
expired_session RECORD;
|
||||
processed_count INTEGER := 0;
|
||||
calendar_cleanup_count INTEGER := 0;
|
||||
BEGIN
|
||||
-- Log start
|
||||
RAISE NOTICE '[CANCEL-EXPIRED] Starting check for expired consulting orders';
|
||||
|
||||
-- Loop through expired consulting orders
|
||||
FOR expired_order IN
|
||||
SELECT o.id, o.payment_status, o.qr_expires_at
|
||||
FROM orders o
|
||||
INNER JOIN consulting_sessions cs ON cs.order_id = o.id
|
||||
WHERE o.payment_status = 'pending'
|
||||
AND o.qr_expires_at < NOW()
|
||||
AND o.status != 'cancelled'
|
||||
LOOP
|
||||
RAISE NOTICE '[CANCEL-EXPIRED] Processing order: %', expired_order.id;
|
||||
|
||||
-- Update order status to cancelled AND payment status to failed
|
||||
UPDATE orders
|
||||
SET status = 'cancelled',
|
||||
payment_status = 'failed'
|
||||
WHERE id = expired_order.id;
|
||||
|
||||
-- Cancel all consulting sessions for this order
|
||||
FOR expired_session IN
|
||||
SELECT id, calendar_event_id
|
||||
FROM consulting_sessions
|
||||
WHERE order_id = expired_order.id
|
||||
AND status != 'cancelled'
|
||||
LOOP
|
||||
-- Update session status to cancelled
|
||||
UPDATE consulting_sessions
|
||||
SET status = 'cancelled'
|
||||
WHERE id = expired_session.id;
|
||||
|
||||
-- Delete time slots to release them for re-booking
|
||||
DELETE FROM consulting_time_slots
|
||||
WHERE session_id = expired_session.id;
|
||||
|
||||
-- Clear calendar_event_id to mark for cleanup
|
||||
-- Note: The actual Google Calendar event deletion is handled separately
|
||||
-- via the trigger-calendar-cleanup edge function (if HTTP access is available)
|
||||
IF expired_session.calendar_event_id IS NOT NULL THEN
|
||||
UPDATE consulting_sessions
|
||||
SET calendar_event_id = NULL
|
||||
WHERE id = expired_session.id;
|
||||
calendar_cleanup_count := calendar_cleanup_count + 1;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[CANCEL-EXPIRED] Cancelled session: %', expired_session.id;
|
||||
END LOOP;
|
||||
|
||||
processed_count := processed_count + 1;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '[CANCEL-EXPIRED] Successfully processed % expired orders', processed_count;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'processed', processed_count,
|
||||
'calendar_references_cleared', calendar_cleanup_count,
|
||||
'message', format('Successfully cancelled %s expired consulting orders (cleared %s calendar references)', processed_count, calendar_cleanup_count)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================
|
||||
-- Coolify Scheduled Tasks Configuration
|
||||
-- ============================================
|
||||
-- Instead of using pg_cron, configure these in Coolify:
|
||||
--
|
||||
-- Task 1: Database Cleanup (every 10 minutes)
|
||||
-- -------------------------------------------
|
||||
-- Name: cancel-expired-consulting-orders-db
|
||||
-- Command: psql -h supabase-db -U postgres -d postgres -c "SELECT cancel_expired_consulting_orders_sql();"
|
||||
-- Frequency: */10 * * * *
|
||||
-- Timeout: 30 seconds
|
||||
-- Container: supabase-db (or supabase-rest if it has psql client)
|
||||
--
|
||||
-- NOTE: Calendar cleanup is now included in the SQL function above.
|
||||
-- The function clears calendar_event_id references to prevent stale data.
|
||||
-- Actual Google Calendar event deletion can be triggered manually via:
|
||||
-- curl -X POST http://your-domain/functions/v1/trigger-calendar-cleanup
|
||||
--
|
||||
-- Task 2 (DEPRECATED): Calendar cleanup edge function
|
||||
-- -------------------------------------------
|
||||
-- Due to Docker networking limitations between containers, we cannot
|
||||
-- automatically trigger the edge function from the scheduled task.
|
||||
-- The SQL function now handles cleanup of database references.
|
||||
-- To manually clean up Google Calendar events, trigger the edge function:
|
||||
-- POST http://your-supabase-project.supabase.co/functions/v1/trigger-calendar-cleanup
|
||||
|
||||
-- ============================================
|
||||
-- Manual Testing
|
||||
-- ============================================
|
||||
-- Test the function directly:
|
||||
-- SELECT cancel_expired_consulting_orders_sql();
|
||||
41
supabase/migrations/20241230_video_source_columns.sql
Normal file
41
supabase/migrations/20241230_video_source_columns.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- ============================================
|
||||
-- Add Video Source Columns for YouTube/Embed Toggle
|
||||
-- ============================================
|
||||
-- This migration adds support for dual video sources (YouTube and Embed)
|
||||
-- at the product level, allowing quick switching between sources
|
||||
|
||||
-- Add video source columns to bootcamp_lessons table
|
||||
ALTER TABLE bootcamp_lessons
|
||||
ADD COLUMN IF NOT EXISTS youtube_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS embed_code TEXT;
|
||||
|
||||
-- Migrate existing video_url to youtube_url
|
||||
UPDATE bootcamp_lessons
|
||||
SET youtube_url = video_url
|
||||
WHERE video_url IS NOT NULL
|
||||
AND youtube_url IS NULL;
|
||||
|
||||
-- Note: Keep old video_url column for backward compatibility
|
||||
-- Can drop after verification if desired
|
||||
|
||||
-- Add video_source column to products table
|
||||
ALTER TABLE products
|
||||
ADD COLUMN IF NOT EXISTS video_source TEXT DEFAULT 'youtube',
|
||||
ADD COLUMN IF NOT EXISTS video_source_config JSONB DEFAULT '{}';
|
||||
|
||||
-- Add constraint to ensure valid sources
|
||||
ALTER TABLE products
|
||||
DROP CONSTRAINT IF EXISTS products_video_source_check;
|
||||
|
||||
ALTER TABLE products
|
||||
ADD CONSTRAINT products_video_source_check
|
||||
CHECK (video_source IN ('youtube', 'embed'));
|
||||
|
||||
-- Create index for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_products_video_source ON products(video_source);
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON COLUMN products.video_source IS 'Active video source for bootcamp lessons: youtube or embed';
|
||||
COMMENT ON COLUMN products.video_source_config IS 'Configuration metadata for video source settings';
|
||||
COMMENT ON COLUMN bootcamp_lessons.youtube_url IS 'YouTube video URL for the lesson';
|
||||
COMMENT ON COLUMN bootcamp_lessons.embed_code IS 'Custom embed code (Adilo, Vimeo, iframe) for the lesson';
|
||||
37
supabase/migrations/20250101000001_adilo_video_support.sql
Normal file
37
supabase/migrations/20250101000001_adilo_video_support.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Add Adilo video columns to products table (webinars)
|
||||
ALTER TABLE products
|
||||
ADD COLUMN IF NOT EXISTS m3u8_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS mp4_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS video_host TEXT DEFAULT 'youtube',
|
||||
ADD COLUMN IF NOT EXISTS adilo_video_id TEXT;
|
||||
|
||||
-- Add Adilo video columns to bootcamp_lessons table
|
||||
ALTER TABLE bootcamp_lessons
|
||||
ADD COLUMN IF NOT EXISTS m3u8_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS mp4_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS video_host TEXT DEFAULT 'youtube',
|
||||
ADD COLUMN IF NOT EXISTS adilo_video_id TEXT;
|
||||
|
||||
-- Add constraint to ensure valid video hosts
|
||||
ALTER TABLE products
|
||||
ADD CONSTRAINT products_video_host_check
|
||||
CHECK (video_host IN ('youtube', 'adilo'));
|
||||
|
||||
ALTER TABLE bootcamp_lessons
|
||||
ADD CONSTRAINT bootcamp_lessons_video_host_check
|
||||
CHECK (video_host IN ('youtube', 'adilo'));
|
||||
|
||||
-- Create indexes for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_products_video_host ON products(video_host);
|
||||
CREATE INDEX IF NOT EXISTS idx_bootcamp_lessons_video_host ON bootcamp_lessons(video_host);
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON COLUMN products.m3u8_url IS 'M3U8 streaming URL from Adilo for HLS playback';
|
||||
COMMENT ON COLUMN products.mp4_url IS 'MP4 fallback URL from Adilo for direct download/legacy browsers';
|
||||
COMMENT ON COLUMN products.video_host IS 'Video hosting platform: youtube or adilo';
|
||||
COMMENT ON COLUMN products.adilo_video_id IS 'Adilo video identifier for API reference';
|
||||
|
||||
COMMENT ON COLUMN bootcamp_lessons.m3u8_url IS 'M3U8 streaming URL from Adilo for HLS playback';
|
||||
COMMENT ON COLUMN bootcamp_lessons.mp4_url IS 'MP4 fallback URL from Adilo for direct download/legacy browsers';
|
||||
COMMENT ON COLUMN bootcamp_lessons.video_host IS 'Video hosting platform: youtube or adilo';
|
||||
COMMENT ON COLUMN bootcamp_lessons.adilo_video_id IS 'Adilo video identifier for API reference';
|
||||
@@ -0,0 +1,94 @@
|
||||
-- Fix consulting_slots table: ensure user_id column exists, backfill from orders, and add RLS policies
|
||||
-- This fixes the 400 error when members try to fetch their consulting slots
|
||||
|
||||
-- Add user_id column if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'consulting_slots'
|
||||
AND column_name = 'user_id'
|
||||
) THEN
|
||||
ALTER TABLE consulting_slots
|
||||
ADD COLUMN user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
|
||||
RAISE NOTICE 'user_id column added to consulting_slots';
|
||||
ELSE
|
||||
RAISE NOTICE 'user_id column already exists in consulting_slots';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Backfill user_id from orders for existing records
|
||||
DO $$
|
||||
DECLARE
|
||||
backfill_count INTEGER;
|
||||
null_count INTEGER;
|
||||
BEGIN
|
||||
-- Count NULL user_ids before backfill
|
||||
SELECT COUNT(*) INTO null_count FROM consulting_slots WHERE user_id IS NULL;
|
||||
RAISE NOTICE 'Found % consulting_slots with NULL user_id', null_count;
|
||||
|
||||
-- Backfill from orders
|
||||
UPDATE consulting_slots cs
|
||||
SET user_id = o.user_id
|
||||
FROM orders o
|
||||
WHERE cs.order_id = o.id
|
||||
AND cs.user_id IS NULL;
|
||||
|
||||
GET DIAGNOSTICS backfill_count = ROW_COUNT;
|
||||
RAISE NOTICE 'Backfilled user_id for % consulting_slots from orders', backfill_count;
|
||||
|
||||
-- Check remaining NULLs
|
||||
SELECT COUNT(*) INTO null_count FROM consulting_slots WHERE user_id IS NULL;
|
||||
RAISE NOTICE 'Remaining consulting_slots with NULL user_id: %', null_count;
|
||||
END $$;
|
||||
|
||||
-- Create index for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_consulting_slots_user_id ON consulting_slots(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_consulting_slots_user_status ON consulting_slots(user_id, status);
|
||||
|
||||
-- Enable RLS on consulting_slots (if not already enabled)
|
||||
ALTER TABLE consulting_slots ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Drop ALL existing policies first to avoid conflicts
|
||||
DROP POLICY IF EXISTS "consulting_slots_select_own" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_insert_own" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_update_own" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_select_all" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_service_role" ON consulting_slots;
|
||||
|
||||
-- Create RLS policies for consulting_slots
|
||||
-- Policy for users to see their own slots
|
||||
CREATE POLICY "consulting_slots_select_own"
|
||||
ON consulting_slots
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Policy for users to insert their own slots
|
||||
CREATE POLICY "consulting_slots_insert_own"
|
||||
ON consulting_slots
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Policy for users to update their own slots
|
||||
CREATE POLICY "consulting_slots_update_own"
|
||||
ON consulting_slots
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id)
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Policy for service role (admins) to access all slots
|
||||
CREATE POLICY "consulting_slots_service_role"
|
||||
ON consulting_slots
|
||||
FOR ALL
|
||||
TO service_role
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Grant permissions
|
||||
GRANT USAGE ON SCHEMA public TO service_role;
|
||||
GRANT ALL ON consulting_slots TO service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON consulting_slots TO authenticated;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Clean up ALL consulting_slots RLS policies and recreate with simple working policies
|
||||
-- This fixes the 400 error caused by conflicting policies using has_role() function
|
||||
|
||||
-- Drop ALL existing policies (including the problematic ones with has_role)
|
||||
DROP POLICY IF EXISTS "Users see own slots" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "Admin manage slots" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "Users create own slots" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_select_own" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_insert_own" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_update_own" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_select_all" ON consulting_slots;
|
||||
DROP POLICY IF EXISTS "consulting_slots_service_role" ON consulting_slots;
|
||||
|
||||
-- Create simple, working policies
|
||||
-- Users can see their own consulting slots
|
||||
CREATE POLICY "Users can view own consulting slots"
|
||||
ON consulting_slots
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Users can insert their own consulting slots
|
||||
CREATE POLICY "Users can insert own consulting slots"
|
||||
ON consulting_slots
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Users can update their own consulting slots
|
||||
CREATE POLICY "Users can update own consulting slots"
|
||||
ON consulting_slots
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id)
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Users can delete their own consulting slots
|
||||
CREATE POLICY "Users can delete own consulting slots"
|
||||
ON consulting_slots
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Service role (for edge functions and admin operations) can do everything
|
||||
CREATE POLICY "Service role full access"
|
||||
ON consulting_slots
|
||||
FOR ALL
|
||||
TO service_role
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user