From e8360a6703f803413f815f65ea1b258f9f8afa66 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 12 Jan 2026 15:30:15 +0000 Subject: [PATCH] fix fix fi x fix --- backend/igny8_core/auth/admin.py | 25 ---- .../automation/services/automation_service.py | 17 ++- .../billing/services/email_service.py | 119 ++++++++++++++++ .../billing/tasks/subscription_renewal.py | 131 +++++++++++++++++- backend/igny8_core/celery.py | 17 +++ backend/igny8_core/modules/billing/views.py | 17 ++- backend/igny8_core/modules/writer/views.py | 4 +- frontend/src/components/common/SiteCard.tsx | 36 ++--- frontend/src/pages/Settings/Sites.tsx | 38 ++++- .../src/pages/account/ContentSettingsPage.tsx | 57 +++++++- .../src/templates/ContentViewTemplate.tsx | 98 ++++++++++++- 11 files changed, 488 insertions(+), 71 deletions(-) diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index d66292fb..acf1bc49 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -893,31 +893,6 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): }), ) - def has_delete_permission(self, request, obj=None): - """Allow deletion for superusers and developers""" - return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()) - - def delete_model(self, request, obj): - """Override delete to handle PROTECT relationship with Keywords""" - from igny8_core.business.planning.models import Keywords - # Soft-delete all Keywords referencing this SeedKeyword first - site_keywords = Keywords.objects.filter(seed_keyword=obj) - for kw in site_keywords: - kw.soft_delete(user=request.user, reason=f"Parent seed keyword '{obj.keyword}' deleted") - # Now we can safely delete the SeedKeyword - super().delete_model(request, obj) - - def delete_queryset(self, request, queryset): - """Override bulk delete to handle PROTECT relationship with Keywords""" - from igny8_core.business.planning.models import Keywords - for seed_keyword in queryset: - # Soft-delete all Keywords referencing this SeedKeyword first - site_keywords = Keywords.objects.filter(seed_keyword=seed_keyword) - for kw in site_keywords: - kw.soft_delete(user=request.user, reason=f"Parent seed keyword '{seed_keyword.keyword}' deleted") - # Now we can safely delete the SeedKeywords - queryset.delete() - def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS) diff --git a/backend/igny8_core/business/automation/services/automation_service.py b/backend/igny8_core/business/automation/services/automation_service.py index 5f3a87f8..ab79309d 100644 --- a/backend/igny8_core/business/automation/services/automation_service.py +++ b/backend/igny8_core/business/automation/services/automation_service.py @@ -1896,18 +1896,27 @@ class AutomationService: """ Get total credits used by this run so far. Uses CreditUsageLog (same source as /account/usage/credits endpoint) for accuracy. + Filters by site to only count credits used by this specific automation run. """ if not self.run: return 0 - # FIXED: Use CreditUsageLog instead of counting AITaskLog records - # This matches the source of truth used by /account/usage/credits endpoint + # Use CreditUsageLog - the source of truth for credit usage + # Filter by site to get only credits used by this automation run from igny8_core.business.billing.models import CreditUsageLog from django.db.models import Sum + filters = { + 'account': self.account, + 'created_at__gte': self.run.started_at + } + + # Filter by site if available for more accurate per-run counting + if self.site: + filters['site'] = self.site + total = CreditUsageLog.objects.filter( - account=self.account, - created_at__gte=self.run.started_at + **filters ).aggregate(total=Sum('credits_used'))['total'] or 0 return total diff --git a/backend/igny8_core/business/billing/services/email_service.py b/backend/igny8_core/business/billing/services/email_service.py index e299b49a..f3e9e001 100644 --- a/backend/igny8_core/business/billing/services/email_service.py +++ b/backend/igny8_core/business/billing/services/email_service.py @@ -1055,6 +1055,125 @@ Current Balance: {current_credits} credits To avoid service interruption, please top up your credits: {context['topup_url']} +Thank you, +The IGNY8 Team + """.strip(), + ) + + @staticmethod + def send_invoice_email(invoice, is_reminder=False): + """ + Send invoice email to the account owner. + Used for manual payment methods (bank transfer, local wallet, manual). + + Args: + invoice: Invoice model instance + is_reminder: If True, sends as a reminder email + """ + service = get_email_service() + frontend_url = BillingEmailService._get_frontend_url() + + account = invoice.account + + subject_prefix = 'Reminder: ' if is_reminder else '' + + context = { + 'account_name': account.name, + 'invoice_number': invoice.invoice_number or f'INV-{invoice.id}', + 'invoice_date': invoice.created_at.strftime('%Y-%m-%d'), + 'due_date': invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A', + 'total_amount': invoice.total_amount, + 'currency': invoice.currency or 'USD', + 'items': invoice.items or [], + 'payment_instructions': invoice.metadata.get('payment_instructions', ''), + 'is_reminder': is_reminder, + 'frontend_url': frontend_url, + 'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}', + } + + subject = f'{subject_prefix}Invoice #{context["invoice_number"]} - Payment Required' + + try: + result = service.send_transactional( + to=account.billing_email or account.owner.email, + subject=subject, + template='emails/invoice.html', + context=context, + tags=['billing', 'invoice', 'reminder' if is_reminder else 'new'], + ) + logger.info(f'Invoice email sent for Invoice {invoice.id} (reminder={is_reminder})') + return result + except Exception as e: + logger.error(f'Failed to send invoice email: {str(e)}') + reminder_text = 'This is a reminder that your ' if is_reminder else '' + return service.send_transactional( + to=account.billing_email or account.owner.email, + subject=subject, + text=f""" +Hi {account.name}, + +{reminder_text}Invoice #{context['invoice_number']} requires your attention. + +Invoice Details: +- Invoice Number: {context['invoice_number']} +- Date: {context['invoice_date']} +- Due Date: {context['due_date']} +- Amount: {context['currency']} {context['total_amount']} + +To view and pay your invoice: +{context['invoice_url']} + +{context['payment_instructions']} + +If you have any questions, please contact our support team. + +Thank you, +The IGNY8 Team + """.strip(), + ) + + @staticmethod + def send_subscription_expired_email(account, subscription): + """ + Send email when subscription expires due to non-payment. + """ + service = get_email_service() + frontend_url = BillingEmailService._get_frontend_url() + + context = { + 'account_name': account.name, + 'plan_name': subscription.plan.name if subscription.plan else 'N/A', + 'frontend_url': frontend_url, + 'billing_url': f'{frontend_url}/account/plans', + } + + try: + result = service.send_transactional( + to=account.billing_email or account.owner.email, + subject='Subscription Expired - Action Required', + template='emails/subscription_expired.html', + context=context, + tags=['billing', 'subscription-expired'], + ) + logger.info(f'Subscription expired email sent for account {account.id}') + return result + except Exception as e: + logger.error(f'Failed to send subscription expired email: {str(e)}') + return service.send_transactional( + to=account.billing_email or account.owner.email, + subject='Subscription Expired - Action Required', + text=f""" +Hi {account.name}, + +Your subscription to the {context['plan_name']} plan has expired due to non-payment. + +Your account access may be limited until you renew your subscription. + +To reactivate your subscription: +{context['billing_url']} + +If you have any questions or need assistance, please contact our support team. + Thank you, The IGNY8 Team """.strip(), diff --git a/backend/igny8_core/business/billing/tasks/subscription_renewal.py b/backend/igny8_core/business/billing/tasks/subscription_renewal.py index 618295ff..991f3218 100644 --- a/backend/igny8_core/business/billing/tasks/subscription_renewal.py +++ b/backend/igny8_core/business/billing/tasks/subscription_renewal.py @@ -14,6 +14,11 @@ import logging logger = logging.getLogger(__name__) +# Grace period in days for manual payment before subscription expires +RENEWAL_GRACE_PERIOD_DAYS = 7 +# Days between invoice reminder emails +INVOICE_REMINDER_INTERVAL_DAYS = 3 + @shared_task(name='billing.send_renewal_notices') def send_renewal_notices(): @@ -99,7 +104,7 @@ def renew_subscription(subscription_id: int): # Attempt automatic payment if payment method on file payment_attempted = False - # Check if account has saved payment method + # Check if account has saved payment method for automatic billing if subscription.metadata.get('stripe_subscription_id'): payment_attempted = _attempt_stripe_renewal(subscription, invoice) elif subscription.metadata.get('paypal_subscription_id'): @@ -110,15 +115,24 @@ def renew_subscription(subscription_id: int): logger.info(f"Automatic payment initiated for subscription {subscription_id}") else: # No automatic payment - send invoice for manual payment + # This handles all payment methods: bank_transfer, local_wallet, manual logger.info(f"Manual payment required for subscription {subscription_id}") - # Mark subscription as pending renewal + # Mark subscription as pending renewal with grace period + grace_period_end = timezone.now() + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS) subscription.status = 'pending_renewal' subscription.metadata['renewal_invoice_id'] = invoice.id subscription.metadata['renewal_required_at'] = timezone.now().isoformat() + subscription.metadata['grace_period_end'] = grace_period_end.isoformat() + subscription.metadata['last_invoice_reminder_at'] = timezone.now().isoformat() subscription.save(update_fields=['status', 'metadata']) - # TODO: Send invoice email + # Send invoice email for manual payment + try: + BillingEmailService.send_invoice_email(invoice, is_reminder=False) + logger.info(f"Invoice email sent for subscription {subscription_id}") + except Exception as e: + logger.exception(f"Failed to send invoice email for subscription {subscription_id}: {str(e)}") # Clear renewal notice flag if 'renewal_notice_sent' in subscription.metadata: @@ -131,6 +145,117 @@ def renew_subscription(subscription_id: int): logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}") +@shared_task(name='billing.send_invoice_reminders') +def send_invoice_reminders(): + """ + Send invoice reminder emails for pending renewals + Run daily to remind accounts with pending invoices + """ + now = timezone.now() + reminder_threshold = now - timedelta(days=INVOICE_REMINDER_INTERVAL_DAYS) + + # Get subscriptions pending renewal + subscriptions = Subscription.objects.filter( + status='pending_renewal' + ).select_related('account', 'plan') + + for subscription in subscriptions: + # Check if enough time has passed since last reminder + last_reminder = subscription.metadata.get('last_invoice_reminder_at') + if last_reminder: + from datetime import datetime + last_reminder_dt = datetime.fromisoformat(last_reminder.replace('Z', '+00:00')) + if hasattr(last_reminder_dt, 'tzinfo') and last_reminder_dt.tzinfo is None: + last_reminder_dt = timezone.make_aware(last_reminder_dt) + if last_reminder_dt > reminder_threshold: + continue + + # Get the renewal invoice + invoice_id = subscription.metadata.get('renewal_invoice_id') + if not invoice_id: + continue + + try: + invoice = Invoice.objects.get(id=invoice_id) + + # Only send reminder for unpaid invoices + if invoice.status not in ['pending', 'overdue']: + continue + + BillingEmailService.send_invoice_email(invoice, is_reminder=True) + + # Update last reminder timestamp + subscription.metadata['last_invoice_reminder_at'] = now.isoformat() + subscription.save(update_fields=['metadata']) + + logger.info(f"Invoice reminder sent for subscription {subscription.id}") + + except Invoice.DoesNotExist: + logger.warning(f"Invoice {invoice_id} not found for subscription {subscription.id}") + except Exception as e: + logger.exception(f"Failed to send invoice reminder for subscription {subscription.id}: {str(e)}") + + +@shared_task(name='billing.check_expired_renewals') +def check_expired_renewals(): + """ + Check for subscriptions that have exceeded their grace period + and automatically change their status to expired + Run daily + """ + now = timezone.now() + + # Get subscriptions pending renewal + subscriptions = Subscription.objects.filter( + status='pending_renewal' + ).select_related('account', 'plan') + + expired_count = 0 + + for subscription in subscriptions: + grace_period_end = subscription.metadata.get('grace_period_end') + + if not grace_period_end: + # No grace period set, use default from renewal_required_at + renewal_required_at = subscription.metadata.get('renewal_required_at') + if renewal_required_at: + from datetime import datetime + required_dt = datetime.fromisoformat(renewal_required_at.replace('Z', '+00:00')) + if hasattr(required_dt, 'tzinfo') and required_dt.tzinfo is None: + required_dt = timezone.make_aware(required_dt) + grace_period_end = (required_dt + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)).isoformat() + else: + continue + + # Check if grace period has expired + from datetime import datetime + grace_end_dt = datetime.fromisoformat(grace_period_end.replace('Z', '+00:00')) + if hasattr(grace_end_dt, 'tzinfo') and grace_end_dt.tzinfo is None: + grace_end_dt = timezone.make_aware(grace_end_dt) + + if now > grace_end_dt: + # Grace period expired - change status to expired + subscription.status = 'expired' + subscription.metadata['expired_at'] = now.isoformat() + subscription.metadata['expired_reason'] = 'Grace period exceeded without payment' + subscription.save(update_fields=['status', 'metadata']) + + expired_count += 1 + logger.info(f"Subscription {subscription.id} expired due to non-payment") + + # Send expiration notification + try: + BillingEmailService.send_subscription_expired_email( + subscription.account, + subscription + ) + except Exception as e: + logger.exception(f"Failed to send expiration email for subscription {subscription.id}: {str(e)}") + + if expired_count > 0: + logger.info(f"Expired {expired_count} subscriptions due to non-payment") + + def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool: """ Attempt to charge Stripe subscription diff --git a/backend/igny8_core/celery.py b/backend/igny8_core/celery.py index da7cb3b2..51e0bb2e 100644 --- a/backend/igny8_core/celery.py +++ b/backend/igny8_core/celery.py @@ -37,6 +37,23 @@ app.conf.beat_schedule = { 'task': 'check_approaching_limits', 'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users }, + # Subscription Renewal Tasks + 'send-renewal-notices': { + 'task': 'billing.send_renewal_notices', + 'schedule': crontab(hour=9, minute=0), # Daily at 09:00 + }, + 'process-subscription-renewals': { + 'task': 'billing.process_subscription_renewals', + 'schedule': crontab(hour=0, minute=5), # Daily at 00:05 + }, + 'send-invoice-reminders': { + 'task': 'billing.send_invoice_reminders', + 'schedule': crontab(hour=10, minute=0), # Daily at 10:00 + }, + 'check-expired-renewals': { + 'task': 'billing.check_expired_renewals', + 'schedule': crontab(hour=0, minute=15), # Daily at 00:15 + }, # Automation Tasks 'check-scheduled-automations': { 'task': 'automation.check_scheduled_automations', diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index 82e8548b..c745092b 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -147,17 +147,26 @@ class CreditUsageViewSet(AccountModelViewSet): # Default to current month if not provided now = timezone.now() + + def parse_iso_datetime(dt_str): + """Parse ISO datetime string, handling Z suffix for UTC""" + if not dt_str: + return None + # Handle Z suffix (UTC indicator) which Django's parse_datetime doesn't support + if dt_str.endswith('Z'): + dt_str = dt_str[:-1] + '+00:00' + from django.utils.dateparse import parse_datetime + return parse_datetime(dt_str) + if not start_date: start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) else: - from django.utils.dateparse import parse_datetime - start_date = parse_datetime(start_date) or start_date + start_date = parse_iso_datetime(start_date) or start_date if not end_date: end_date = now else: - from django.utils.dateparse import parse_datetime - end_date = parse_datetime(end_date) or end_date + end_date = parse_iso_datetime(end_date) or end_date # Get usage logs in date range usage_logs = CreditUsageLog.objects.filter( diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 818cdd28..bdd46d7c 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -1114,7 +1114,9 @@ class ContentViewSet(SiteSectorModelViewSet): request=request ) - # Parse datetime + # Parse datetime - handle Z suffix (UTC indicator) which Django's parse_datetime doesn't support + if scheduled_at_str.endswith('Z'): + scheduled_at_str = scheduled_at_str[:-1] + '+00:00' scheduled_at = parse_datetime(scheduled_at_str) if not scheduled_at: return error_response( diff --git a/frontend/src/components/common/SiteCard.tsx b/frontend/src/components/common/SiteCard.tsx index 3c6311d9..ec21d813 100644 --- a/frontend/src/components/common/SiteCard.tsx +++ b/frontend/src/components/common/SiteCard.tsx @@ -90,13 +90,16 @@ export default function SiteCard({ disabled={isToggling} onChange={handleToggle} /> - - {site.is_active ? 'Active' : 'Inactive'} - +
+ + {site.is_active ? 'Active' : 'Inactive'} + + Button +
@@ -128,16 +131,15 @@ export default function SiteCard({ > Settings - {onDelete && ( -
diff --git a/frontend/src/pages/Settings/Sites.tsx b/frontend/src/pages/Settings/Sites.tsx index 286a0bef..76c9f94f 100644 --- a/frontend/src/pages/Settings/Sites.tsx +++ b/frontend/src/pages/Settings/Sites.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import PageMeta from '../../components/common/PageMeta'; import SiteCard from '../../components/common/SiteCard'; import FormModal, { FormField } from '../../components/common/FormModal'; +import ConfirmDialog from '../../components/common/ConfirmDialog'; import Button from '../../components/ui/button/Button'; import { useToast } from '../../components/ui/toast/ToastContainer'; import Alert from '../../components/ui/alert/Alert'; @@ -39,6 +40,9 @@ export default function Sites() { const [showSiteModal, setShowSiteModal] = useState(false); const [showSectorsModal, setShowSectorsModal] = useState(false); const [showDetailsModal, setShowDetailsModal] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [siteToDelete, setSiteToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); const [isSaving, setIsSaving] = useState(false); const [togglingSiteId, setTogglingSiteId] = useState(null); const [industries, setIndustries] = useState([]); @@ -293,13 +297,17 @@ export default function Sites() { }; - const handleDeleteSite = async (site: Site) => { - if (!window.confirm(`Are you sure you want to delete "${site.name}"? This action cannot be undone.`)) { - return; - } + const handleDeleteSite = (site: Site) => { + setSiteToDelete(site); + setShowDeleteConfirm(true); + }; + const confirmDeleteSite = async () => { + if (!siteToDelete) return; + + setIsDeleting(true); try { - await deleteSite(site.id); + await deleteSite(siteToDelete.id); toast.success('Site deleted successfully'); await loadSites(); if (showDetailsModal) { @@ -307,6 +315,10 @@ export default function Sites() { } } catch (error: any) { toast.error(`Failed to delete site: ${error.message}`); + } finally { + setIsDeleting(false); + setShowDeleteConfirm(false); + setSiteToDelete(null); } }; @@ -604,6 +616,22 @@ export default function Sites() { /> )} + + {/* Delete Confirmation Dialog */} + { + setShowDeleteConfirm(false); + setSiteToDelete(null); + }} + onConfirm={confirmDeleteSite} + title="Delete Site" + message={`Are you sure you want to delete "${siteToDelete?.name}"? This action cannot be undone.`} + confirmText="Delete" + cancelText="Cancel" + variant="danger" + isLoading={isDeleting} + /> ); } diff --git a/frontend/src/pages/account/ContentSettingsPage.tsx b/frontend/src/pages/account/ContentSettingsPage.tsx index cb301a19..afc37e77 100644 --- a/frontend/src/pages/account/ContentSettingsPage.tsx +++ b/frontend/src/pages/account/ContentSettingsPage.tsx @@ -49,6 +49,17 @@ interface ContentGenerationSettings { defaultLength: string; } +// AI Model Config from API +interface AIModelConfig { + model_name: string; + display_name: string; + model_type: string; + provider: string; + valid_sizes?: string[]; + quality_tier?: string; + credits_per_image?: number; +} + // Map user-friendly quality to internal service/model configuration const QUALITY_TO_CONFIG: Record = { standard: { service: 'openai', model: 'dall-e-2' }, @@ -63,8 +74,24 @@ const getQualityFromConfig = (service?: string, model?: string): 'standard' | 'p return 'standard'; }; -// Get available image sizes based on provider and model -const getImageSizes = (provider: string, model: string) => { + +// Get available image sizes based on provider and model (from API or fallback) +const getImageSizes = (provider: string, model: string, imageModels: AIModelConfig[]) => { + // First, try to find the model in the fetched models + const modelConfig = imageModels.find(m => + m.model_name === model || + (m.provider === provider && m.model_name.includes(model)) + ); + + // If found and has valid_sizes, use them + if (modelConfig?.valid_sizes && modelConfig.valid_sizes.length > 0) { + return modelConfig.valid_sizes.map(size => ({ + value: size, + label: `${size.replace('x', '×')} pixels` + })); + } + + // Fallback to hardcoded sizes for backward compatibility if (provider === 'runware') { return [ { value: '1280x832', label: '1280×832 pixels' }, @@ -101,6 +128,9 @@ export default function ContentSettingsPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + // Image Models from API - for dynamic size options + const [imageModels, setImageModels] = useState([]); + // Content Generation Settings const [contentSettings, setContentSettings] = useState({ appendToPrompt: '', @@ -141,20 +171,21 @@ export default function ContentSettingsPage() { }; }, [imageQuality]); - // Get available sizes for current quality + // Get available sizes for current quality (uses imageModels from API) const availableSizes = getImageSizes( getCurrentConfig().service, - getCurrentConfig().model + getCurrentConfig().model, + imageModels ); useEffect(() => { loadSettings(); }, []); - // Update image sizes when quality changes + // Update image sizes when quality changes or imageModels are loaded useEffect(() => { const config = getCurrentConfig(); - const sizes = getImageSizes(config.service, config.model); + const sizes = getImageSizes(config.service, config.model, imageModels); const defaultSize = sizes.length > 0 ? sizes[0].value : '1024x1024'; const validSizes = sizes.map(s => s.value); @@ -178,12 +209,24 @@ export default function ContentSettingsPage() { model: config.model, })); } - }, [imageQuality, getCurrentConfig]); + }, [imageQuality, getCurrentConfig, imageModels]); const loadSettings = async () => { try { setLoading(true); + // Load available image models from API (for dynamic sizes) + try { + const modelsResponse = await fetchAPI('/v1/billing/models/?type=image'); + if (modelsResponse?.data) { + setImageModels(modelsResponse.data); + } else if (Array.isArray(modelsResponse)) { + setImageModels(modelsResponse); + } + } catch (err) { + console.log('Image models not available, using hardcoded sizes'); + } + // Load image generation settings const imageData = await fetchAPI('/v1/system/settings/integrations/image_generation/'); if (imageData) { diff --git a/frontend/src/templates/ContentViewTemplate.tsx b/frontend/src/templates/ContentViewTemplate.tsx index c576f8d3..e0015a58 100644 --- a/frontend/src/templates/ContentViewTemplate.tsx +++ b/frontend/src/templates/ContentViewTemplate.tsx @@ -16,10 +16,11 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import { Content, fetchImages, ImageRecord } from '../services/api'; +import { Content, fetchImages, ImageRecord, fetchAPI } from '../services/api'; import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon, PencilIcon, ImageIcon, BoltIcon } from '../icons'; import { useNavigate } from 'react-router-dom'; import Button from '../components/ui/button/Button'; +import { useToast } from '../components/ui/toast/ToastContainer'; interface ContentViewTemplateProps { content: Content | null; @@ -618,9 +619,47 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) { const navigate = useNavigate(); + const toast = useToast(); const [imageRecords, setImageRecords] = useState([]); const [imagesLoading, setImagesLoading] = useState(false); const [imagesError, setImagesError] = useState(null); + + // Schedule editing state + const [isEditingSchedule, setIsEditingSchedule] = useState(false); + const [scheduleDateTime, setScheduleDateTime] = useState(''); + const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false); + + // Initialize schedule datetime when content loads + useEffect(() => { + if (content?.scheduled_publish_at) { + // Convert ISO string to datetime-local format (YYYY-MM-DDTHH:mm) + const date = new Date(content.scheduled_publish_at); + const localDateTime = date.toISOString().slice(0, 16); + setScheduleDateTime(localDateTime); + } + }, [content?.scheduled_publish_at]); + + // Handler to update schedule + const handleUpdateSchedule = async () => { + if (!content?.id || !scheduleDateTime) return; + + setIsUpdatingSchedule(true); + try { + const isoDateTime = new Date(scheduleDateTime).toISOString(); + await fetchAPI(`/v1/writer/content/${content.id}/schedule/`, { + method: 'POST', + body: JSON.stringify({ scheduled_publish_at: isoDateTime }), + }); + toast.success('Schedule updated successfully'); + setIsEditingSchedule(false); + // Trigger content refresh by reloading the page + window.location.reload(); + } catch (error: any) { + toast.error(`Failed to update schedule: ${error.message}`); + } finally { + setIsUpdatingSchedule(false); + } + }; const metadataPrompts = useMemo(() => extractImagePromptsFromMetadata(content?.metadata), [content?.metadata]); @@ -1140,11 +1179,60 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten {content.site_status === 'failed' && 'Failed'} - {content.scheduled_publish_at && content.site_status === 'scheduled' && ( - - {formatDate(content.scheduled_publish_at)} - + + {/* Schedule Date/Time Editor - Only for scheduled content */} + {content.site_status === 'scheduled' && ( +
+ {isEditingSchedule ? ( + <> + setScheduleDateTime(e.target.value)} + className="text-sm px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-500" + /> + + + + ) : ( + <> + + {content.scheduled_publish_at ? formatDate(content.scheduled_publish_at) : ''} + + + + )} +
)} + {content.external_url && content.site_status === 'published' && (