account, schduels, timezone profile and many imporant updates
This commit is contained in:
@@ -11,12 +11,66 @@ from django.db.models import Q, Count, Sum
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
|
||||||
from igny8_core.auth.models import Account
|
from igny8_core.auth.models import Account, PasswordResetToken
|
||||||
|
|
||||||
|
COUNTRY_TIMEZONE_MAP = {
|
||||||
|
'US': 'America/New_York',
|
||||||
|
'GB': 'Europe/London',
|
||||||
|
'CA': 'America/Toronto',
|
||||||
|
'AU': 'Australia/Sydney',
|
||||||
|
'IN': 'Asia/Kolkata',
|
||||||
|
'PK': 'Asia/Karachi',
|
||||||
|
'DE': 'Europe/Berlin',
|
||||||
|
'FR': 'Europe/Paris',
|
||||||
|
'ES': 'Europe/Madrid',
|
||||||
|
'IT': 'Europe/Rome',
|
||||||
|
'NL': 'Europe/Amsterdam',
|
||||||
|
'SE': 'Europe/Stockholm',
|
||||||
|
'NO': 'Europe/Oslo',
|
||||||
|
'DK': 'Europe/Copenhagen',
|
||||||
|
'FI': 'Europe/Helsinki',
|
||||||
|
'BE': 'Europe/Brussels',
|
||||||
|
'AT': 'Europe/Vienna',
|
||||||
|
'CH': 'Europe/Zurich',
|
||||||
|
'IE': 'Europe/Dublin',
|
||||||
|
'NZ': 'Pacific/Auckland',
|
||||||
|
'SG': 'Asia/Singapore',
|
||||||
|
'AE': 'Asia/Dubai',
|
||||||
|
'SA': 'Asia/Riyadh',
|
||||||
|
'ZA': 'Africa/Johannesburg',
|
||||||
|
'BR': 'America/Sao_Paulo',
|
||||||
|
'MX': 'America/Mexico_City',
|
||||||
|
'AR': 'America/Argentina/Buenos_Aires',
|
||||||
|
'CL': 'America/Santiago',
|
||||||
|
'CO': 'America/Bogota',
|
||||||
|
'JP': 'Asia/Tokyo',
|
||||||
|
'KR': 'Asia/Seoul',
|
||||||
|
'CN': 'Asia/Shanghai',
|
||||||
|
'TH': 'Asia/Bangkok',
|
||||||
|
'MY': 'Asia/Kuala_Lumpur',
|
||||||
|
'ID': 'Asia/Jakarta',
|
||||||
|
'PH': 'Asia/Manila',
|
||||||
|
'VN': 'Asia/Ho_Chi_Minh',
|
||||||
|
'BD': 'Asia/Dhaka',
|
||||||
|
'LK': 'Asia/Colombo',
|
||||||
|
'EG': 'Africa/Cairo',
|
||||||
|
'NG': 'Africa/Lagos',
|
||||||
|
'KE': 'Africa/Nairobi',
|
||||||
|
'GH': 'Africa/Accra',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _timezone_for_country(country_code: str | None) -> str:
|
||||||
|
if not country_code:
|
||||||
|
return 'UTC'
|
||||||
|
return COUNTRY_TIMEZONE_MAP.get(country_code.upper(), 'UTC')
|
||||||
from igny8_core.business.billing.models import CreditTransaction
|
from igny8_core.business.billing.models import CreditTransaction
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
@@ -43,6 +97,9 @@ class AccountSettingsViewSet(viewsets.ViewSet):
|
|||||||
'billing_country': account.billing_country or '',
|
'billing_country': account.billing_country or '',
|
||||||
'tax_id': account.tax_id or '',
|
'tax_id': account.tax_id or '',
|
||||||
'billing_email': account.billing_email or '',
|
'billing_email': account.billing_email or '',
|
||||||
|
'account_timezone': account.account_timezone or 'UTC',
|
||||||
|
'timezone_mode': account.timezone_mode or 'country',
|
||||||
|
'timezone_offset': account.timezone_offset or '',
|
||||||
'credits': account.credits,
|
'credits': account.credits,
|
||||||
'created_at': account.created_at.isoformat(),
|
'created_at': account.created_at.isoformat(),
|
||||||
'updated_at': account.updated_at.isoformat(),
|
'updated_at': account.updated_at.isoformat(),
|
||||||
@@ -56,13 +113,20 @@ class AccountSettingsViewSet(viewsets.ViewSet):
|
|||||||
allowed_fields = [
|
allowed_fields = [
|
||||||
'name', 'billing_address_line1', 'billing_address_line2',
|
'name', 'billing_address_line1', 'billing_address_line2',
|
||||||
'billing_city', 'billing_state', 'billing_postal_code',
|
'billing_city', 'billing_state', 'billing_postal_code',
|
||||||
'billing_country', 'tax_id', 'billing_email'
|
'billing_country', 'tax_id', 'billing_email',
|
||||||
|
'account_timezone', 'timezone_mode', 'timezone_offset'
|
||||||
]
|
]
|
||||||
|
|
||||||
for field in allowed_fields:
|
for field in allowed_fields:
|
||||||
if field in request.data:
|
if field in request.data:
|
||||||
setattr(account, field, request.data[field])
|
setattr(account, field, request.data[field])
|
||||||
|
|
||||||
|
# Derive timezone from country unless manual mode is selected
|
||||||
|
if getattr(account, 'timezone_mode', 'country') != 'manual':
|
||||||
|
country_code = account.billing_country
|
||||||
|
account.account_timezone = _timezone_for_country(country_code)
|
||||||
|
account.timezone_offset = ''
|
||||||
|
|
||||||
account.save()
|
account.save()
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@@ -79,6 +143,9 @@ class AccountSettingsViewSet(viewsets.ViewSet):
|
|||||||
'billing_country': account.billing_country,
|
'billing_country': account.billing_country,
|
||||||
'tax_id': account.tax_id,
|
'tax_id': account.tax_id,
|
||||||
'billing_email': account.billing_email,
|
'billing_email': account.billing_email,
|
||||||
|
'account_timezone': account.account_timezone,
|
||||||
|
'timezone_mode': account.timezone_mode,
|
||||||
|
'timezone_offset': account.timezone_offset,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -142,14 +209,38 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create user (simplified - in production, send invitation email)
|
# Generate username from email if not provided
|
||||||
|
base_username = email.split('@')[0]
|
||||||
|
username = base_username
|
||||||
|
counter = 1
|
||||||
|
while User.objects.filter(username=username).exists():
|
||||||
|
username = f"{base_username}{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Create user and send invitation email
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
|
username=username,
|
||||||
email=email,
|
email=email,
|
||||||
first_name=request.data.get('first_name', ''),
|
first_name=request.data.get('first_name', ''),
|
||||||
last_name=request.data.get('last_name', ''),
|
last_name=request.data.get('last_name', ''),
|
||||||
account=account
|
account=account
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create password reset token for invite
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = timezone.now() + timedelta(hours=24)
|
||||||
|
PasswordResetToken.objects.create(
|
||||||
|
user=user,
|
||||||
|
token=token,
|
||||||
|
expires_at=expires_at
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.services.email_service import send_team_invite_email
|
||||||
|
send_team_invite_email(user, request.user, account, token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send team invite email: {e}")
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'message': 'Team member invited successfully',
|
'message': 'Team member invited successfully',
|
||||||
'user': {
|
'user': {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0031_drop_all_blueprint_tables'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='account_timezone',
|
||||||
|
field=models.CharField(default='UTC', help_text='IANA timezone name', max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='timezone_mode',
|
||||||
|
field=models.CharField(choices=[('country', 'Country'), ('manual', 'Manual')], default='country', help_text='Timezone selection mode', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='timezone_offset',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Optional UTC offset label', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-01-19 00:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0032_add_account_timezone_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='account_timezone',
|
||||||
|
field=models.CharField(default='UTC', max_length=64, help_text='IANA timezone name'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='timezone_mode',
|
||||||
|
field=models.CharField(choices=[('country', 'Country'), ('manual', 'Manual')], default='country', max_length=20, help_text='Timezone selection mode'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='timezone_offset',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=10, help_text='Optional UTC offset label'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
backend/igny8_core/auth/migrations/0034_add_user_phone.py
Normal file
18
backend/igny8_core/auth/migrations/0034_add_user_phone.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-01-19 00:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0033_add_account_timezone_history_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='phone',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=30),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -107,6 +107,16 @@ class Account(SoftDeletableModel):
|
|||||||
billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code")
|
billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code")
|
||||||
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
||||||
|
|
||||||
|
# Account timezone (single source of truth for all users/sites)
|
||||||
|
account_timezone = models.CharField(max_length=64, default='UTC', help_text="IANA timezone name")
|
||||||
|
timezone_mode = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[('country', 'Country'), ('manual', 'Manual')],
|
||||||
|
default='country',
|
||||||
|
help_text="Timezone selection mode"
|
||||||
|
)
|
||||||
|
timezone_offset = models.CharField(max_length=10, blank=True, default='', help_text="Optional UTC offset label")
|
||||||
|
|
||||||
# Monthly usage tracking (reset on billing cycle)
|
# Monthly usage tracking (reset on billing cycle)
|
||||||
usage_ahrefs_queries = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Ahrefs queries used this month")
|
usage_ahrefs_queries = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Ahrefs queries used this month")
|
||||||
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
|
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
|
||||||
@@ -922,6 +932,7 @@ class User(AbstractUser):
|
|||||||
account = models.ForeignKey('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='users', null=True, blank=True, db_column='tenant_id')
|
account = models.ForeignKey('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='users', null=True, blank=True, db_column='tenant_id')
|
||||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
|
||||||
email = models.EmailField(_('email address'), unique=True)
|
email = models.EmailField(_('email address'), unique=True)
|
||||||
|
phone = models.CharField(max_length=30, blank=True, default='')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
|
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
|
||||||
'credits', 'status', 'payment_method',
|
'credits', 'status', 'payment_method',
|
||||||
'subscription', 'billing_country', 'created_at'
|
'subscription', 'billing_country',
|
||||||
|
'account_timezone', 'timezone_mode', 'timezone_offset',
|
||||||
|
'created_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['owner', 'created_at']
|
read_only_fields = ['owner', 'created_at']
|
||||||
|
|
||||||
@@ -270,7 +272,18 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ['id', 'username', 'email', 'role', 'account', 'accessible_sites', 'created_at']
|
fields = [
|
||||||
|
'id',
|
||||||
|
'username',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'role',
|
||||||
|
'account',
|
||||||
|
'accessible_sites',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
read_only_fields = ['created_at']
|
read_only_fields = ['created_at']
|
||||||
|
|
||||||
def get_accessible_sites(self, obj):
|
def get_accessible_sites(self, obj):
|
||||||
|
|||||||
@@ -255,6 +255,25 @@ class UsersViewSet(AccountModelViewSet):
|
|||||||
serializer = UserSerializer(user)
|
serializer = UserSerializer(user)
|
||||||
return success_response(data={'user': serializer.data}, request=request)
|
return success_response(data={'user': serializer.data}, request=request)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get', 'patch'], permission_classes=[IsAuthenticatedAndActive])
|
||||||
|
def me(self, request):
|
||||||
|
"""Get or update the current user profile."""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if request.method == 'PATCH':
|
||||||
|
serializer = UserSerializer(user, data=request.data, partial=True)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return error_response(
|
||||||
|
error='Validation failed',
|
||||||
|
errors=serializer.errors,
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
serializer = UserSerializer(user)
|
||||||
|
return success_response(data={'user': serializer.data}, request=request)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 3. ACCOUNTS - Register each unique organization/user space
|
# 3. ACCOUNTS - Register each unique organization/user space
|
||||||
|
|||||||
@@ -1944,29 +1944,39 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
def server_time(self, request):
|
def server_time(self, request):
|
||||||
"""
|
"""
|
||||||
GET /api/v1/automation/server_time/
|
GET /api/v1/automation/server_time/
|
||||||
Get current server time (UTC) used for all automation scheduling.
|
Get current time in the account timezone used for automation scheduling.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- server_time: Current UTC timestamp (ISO 8601 format)
|
- server_time: Current timestamp (ISO 8601 format, account timezone)
|
||||||
- server_time_formatted: Human-readable UTC time
|
- server_time_formatted: Human-readable time (account timezone)
|
||||||
- timezone: Server timezone setting (always UTC)
|
- timezone: Account timezone setting
|
||||||
- celery_timezone: Celery task timezone setting
|
- celery_timezone: Celery task timezone setting
|
||||||
- use_tz: Whether Django is timezone-aware
|
- use_tz: Whether Django is timezone-aware
|
||||||
|
|
||||||
Note: All automation schedules (scheduled_time) are in UTC.
|
Note: Automation schedules are shown in the account timezone.
|
||||||
When user sets "02:00", the automation runs at 02:00 UTC.
|
|
||||||
"""
|
"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
account = getattr(request.user, 'account', None)
|
||||||
|
account_timezone = getattr(account, 'account_timezone', None) or 'UTC'
|
||||||
|
|
||||||
|
try:
|
||||||
|
tzinfo = ZoneInfo(account_timezone)
|
||||||
|
except Exception:
|
||||||
|
tzinfo = timezone.utc
|
||||||
|
account_timezone = 'UTC'
|
||||||
|
|
||||||
|
local_now = now.astimezone(tzinfo)
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'server_time': now.isoformat(),
|
'server_time': local_now.isoformat(),
|
||||||
'server_time_formatted': now.strftime('%H:%M'),
|
'server_time_formatted': local_now.strftime('%H:%M'),
|
||||||
'server_time_date': now.strftime('%Y-%m-%d'),
|
'server_time_date': local_now.strftime('%Y-%m-%d'),
|
||||||
'server_time_time': now.strftime('%H:%M:%S'),
|
'server_time_time': local_now.strftime('%H:%M:%S'),
|
||||||
'timezone': settings.TIME_ZONE,
|
'timezone': account_timezone,
|
||||||
'celery_timezone': getattr(settings, 'CELERY_TIMEZONE', settings.TIME_ZONE),
|
'celery_timezone': getattr(settings, 'CELERY_TIMEZONE', settings.TIME_ZONE),
|
||||||
'use_tz': settings.USE_TZ,
|
'use_tz': settings.USE_TZ,
|
||||||
'note': 'All automation schedules are in UTC. When you set "02:00", the automation runs at 02:00 UTC.'
|
'note': 'Automation schedules are shown in the account timezone.'
|
||||||
})
|
})
|
||||||
@@ -1271,6 +1271,31 @@ def send_password_reset_email(user, reset_token):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_team_invite_email(invited_user, inviter, account, reset_token):
|
||||||
|
"""Send team invitation email with password setup link"""
|
||||||
|
service = get_email_service()
|
||||||
|
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||||
|
|
||||||
|
inviter_name = inviter.first_name or inviter.email
|
||||||
|
invited_name = invited_user.first_name or invited_user.email
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'inviter_name': inviter_name,
|
||||||
|
'invited_name': invited_name,
|
||||||
|
'account_name': account.name,
|
||||||
|
'reset_url': f'{frontend_url}/reset-password?token={reset_token}',
|
||||||
|
'frontend_url': frontend_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
return service.send_transactional(
|
||||||
|
to=invited_user.email,
|
||||||
|
subject=f"You're invited to join {account.name} on IGNY8",
|
||||||
|
template='emails/team_invite.html',
|
||||||
|
context=context,
|
||||||
|
tags=['auth', 'team-invite'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_email_verification(user, verification_token):
|
def send_email_verification(user, verification_token):
|
||||||
"""Send email verification link"""
|
"""Send email verification link"""
|
||||||
service = get_email_service()
|
service = get_email_service()
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-01-19 00:00
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def add_team_invite_template(apps, schema_editor):
|
||||||
|
EmailTemplate = apps.get_model('system', 'EmailTemplate')
|
||||||
|
|
||||||
|
EmailTemplate.objects.get_or_create(
|
||||||
|
template_name='team_invite',
|
||||||
|
defaults={
|
||||||
|
'template_path': 'emails/team_invite.html',
|
||||||
|
'display_name': 'Team Invitation',
|
||||||
|
'description': 'Sent when a team member is invited to join an account',
|
||||||
|
'template_type': 'auth',
|
||||||
|
'default_subject': "You're invited to join IGNY8",
|
||||||
|
'required_context': ['inviter_name', 'invited_name', 'account_name', 'reset_url', 'frontend_url'],
|
||||||
|
'sample_context': {
|
||||||
|
'inviter_name': 'Alex Johnson',
|
||||||
|
'invited_name': 'Jamie Lee',
|
||||||
|
'account_name': 'Acme Co',
|
||||||
|
'reset_url': 'https://app.igny8.com/reset-password?token=example',
|
||||||
|
'frontend_url': 'https://app.igny8.com',
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_team_invite_template(apps, schema_editor):
|
||||||
|
EmailTemplate = apps.get_model('system', 'EmailTemplate')
|
||||||
|
EmailTemplate.objects.filter(template_name='team_invite').delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('system', '0025_delete_accountintegrationoverride'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(add_team_invite_template, remove_team_invite_template),
|
||||||
|
]
|
||||||
@@ -1595,8 +1595,9 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from igny8_core.auth.models import Site
|
||||||
from igny8_core.business.integration.models import Site, SitePublishingSettings
|
from igny8_core.business.integration.models import PublishingSettings
|
||||||
|
from igny8_core.tasks.publishing_scheduler import _calculate_available_slots
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1621,7 +1622,7 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
# Get site and publishing settings
|
# Get site and publishing settings
|
||||||
try:
|
try:
|
||||||
site = Site.objects.get(id=site_id)
|
site = Site.objects.get(id=site_id)
|
||||||
pub_settings = SitePublishingSettings.objects.filter(site=site).first()
|
pub_settings, _ = PublishingSettings.get_or_create_for_site(site)
|
||||||
except Site.DoesNotExist:
|
except Site.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
error=f'Site {site_id} not found',
|
error=f'Site {site_id} not found',
|
||||||
@@ -1629,41 +1630,25 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default settings if none exist
|
|
||||||
base_time_str = '09:00 AM'
|
|
||||||
stagger_interval = 15 # minutes
|
|
||||||
timezone_str = 'America/New_York'
|
|
||||||
|
|
||||||
if pub_settings:
|
|
||||||
base_time_str = pub_settings.auto_publish_time or base_time_str
|
|
||||||
stagger_interval = pub_settings.stagger_interval_minutes or stagger_interval
|
|
||||||
timezone_str = pub_settings.timezone or timezone_str
|
|
||||||
|
|
||||||
# Get content items
|
# Get content items
|
||||||
content_qs = self.get_queryset().filter(id__in=content_ids)
|
content_qs = self.get_queryset().filter(id__in=content_ids)
|
||||||
|
|
||||||
# Generate schedule preview
|
# Generate schedule preview using the same slot logic as automation
|
||||||
schedule_preview = []
|
schedule_preview = []
|
||||||
now = timezone.now()
|
available_slots = _calculate_available_slots(pub_settings, site)
|
||||||
|
|
||||||
# Parse base time (format: "09:00 AM" or "14:30")
|
if not available_slots:
|
||||||
try:
|
return error_response(
|
||||||
from datetime import datetime
|
error='No available publishing slots based on site defaults',
|
||||||
if 'AM' in base_time_str or 'PM' in base_time_str:
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
time_obj = datetime.strptime(base_time_str, '%I:%M %p').time()
|
request=request
|
||||||
else:
|
)
|
||||||
time_obj = datetime.strptime(base_time_str, '%H:%M').time()
|
|
||||||
except ValueError:
|
|
||||||
time_obj = datetime.strptime('09:00', '%H:%M').time()
|
|
||||||
|
|
||||||
# Start from tomorrow at base time
|
|
||||||
start_date = now.replace(hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0)
|
|
||||||
if start_date <= now:
|
|
||||||
start_date += timedelta(days=1)
|
|
||||||
|
|
||||||
# Create schedule for each content item
|
# Create schedule for each content item
|
||||||
for index, content in enumerate(content_qs):
|
for index, content in enumerate(content_qs):
|
||||||
scheduled_at = start_date + timedelta(minutes=stagger_interval * index)
|
if index >= len(available_slots):
|
||||||
|
break
|
||||||
|
scheduled_at = available_slots[index]
|
||||||
schedule_preview.append({
|
schedule_preview.append({
|
||||||
'content_id': content.id,
|
'content_id': content.id,
|
||||||
'title': content.title,
|
'title': content.title,
|
||||||
@@ -1672,14 +1657,24 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
logger.info(f"[bulk_schedule_preview] Generated preview for {len(schedule_preview)} items")
|
logger.info(f"[bulk_schedule_preview] Generated preview for {len(schedule_preview)} items")
|
||||||
|
|
||||||
|
# Base time for display should match first scheduled slot
|
||||||
|
display_base_time = '09:00'
|
||||||
|
if schedule_preview:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
first_dt = datetime.fromisoformat(schedule_preview[0]['scheduled_at'])
|
||||||
|
display_base_time = first_dt.strftime('%H:%M')
|
||||||
|
except Exception:
|
||||||
|
display_base_time = '09:00'
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'scheduled_count': len(schedule_preview),
|
'scheduled_count': len(schedule_preview),
|
||||||
'schedule_preview': schedule_preview,
|
'schedule_preview': schedule_preview,
|
||||||
'site_settings': {
|
'site_settings': {
|
||||||
'base_time': base_time_str,
|
'base_time': display_base_time,
|
||||||
'stagger_interval': stagger_interval,
|
'stagger_interval': pub_settings.stagger_interval_minutes,
|
||||||
'timezone': timezone_str,
|
'timezone': site.account.account_timezone if hasattr(site, 'account') else 'UTC',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
message=f'Preview generated for {len(schedule_preview)} items',
|
message=f'Preview generated for {len(schedule_preview)} items',
|
||||||
@@ -1699,8 +1694,9 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from igny8_core.auth.models import Site
|
||||||
from igny8_core.business.integration.models import Site, SitePublishingSettings
|
from igny8_core.business.integration.models import PublishingSettings
|
||||||
|
from igny8_core.tasks.publishing_scheduler import _calculate_available_slots
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1726,7 +1722,7 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
# Get site and publishing settings
|
# Get site and publishing settings
|
||||||
try:
|
try:
|
||||||
site = Site.objects.get(id=site_id)
|
site = Site.objects.get(id=site_id)
|
||||||
pub_settings = SitePublishingSettings.objects.filter(site=site).first()
|
pub_settings, _ = PublishingSettings.get_or_create_for_site(site)
|
||||||
except Site.DoesNotExist:
|
except Site.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
error=f'Site {site_id} not found',
|
error=f'Site {site_id} not found',
|
||||||
@@ -1734,39 +1730,28 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default settings if none exist
|
|
||||||
base_time_str = '09:00 AM'
|
|
||||||
stagger_interval = 15 # minutes
|
|
||||||
|
|
||||||
if pub_settings and use_site_defaults:
|
|
||||||
base_time_str = pub_settings.auto_publish_time or base_time_str
|
|
||||||
stagger_interval = pub_settings.stagger_interval_minutes or stagger_interval
|
|
||||||
|
|
||||||
# Get content items
|
# Get content items
|
||||||
content_qs = self.get_queryset().filter(id__in=content_ids)
|
content_qs = self.get_queryset().filter(id__in=content_ids)
|
||||||
|
|
||||||
# Generate schedule and apply
|
# Generate schedule and apply using the same slot logic as automation
|
||||||
|
available_slots = _calculate_available_slots(pub_settings, site) if use_site_defaults else []
|
||||||
|
|
||||||
|
if use_site_defaults and not available_slots:
|
||||||
|
return error_response(
|
||||||
|
error='No available publishing slots based on site defaults',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|
||||||
# Parse base time
|
|
||||||
try:
|
|
||||||
from datetime import datetime
|
|
||||||
if 'AM' in base_time_str or 'PM' in base_time_str:
|
|
||||||
time_obj = datetime.strptime(base_time_str, '%I:%M %p').time()
|
|
||||||
else:
|
|
||||||
time_obj = datetime.strptime(base_time_str, '%H:%M').time()
|
|
||||||
except ValueError:
|
|
||||||
time_obj = datetime.strptime('09:00', '%H:%M').time()
|
|
||||||
|
|
||||||
# Start from tomorrow at base time
|
|
||||||
start_date = now.replace(hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0)
|
|
||||||
if start_date <= now:
|
|
||||||
start_date += timedelta(days=1)
|
|
||||||
|
|
||||||
# Schedule each content item
|
|
||||||
scheduled_count = 0
|
scheduled_count = 0
|
||||||
for index, content in enumerate(content_qs):
|
for index, content in enumerate(content_qs):
|
||||||
scheduled_at = start_date + timedelta(minutes=stagger_interval * index)
|
if use_site_defaults:
|
||||||
|
if index >= len(available_slots):
|
||||||
|
break
|
||||||
|
scheduled_at = available_slots[index]
|
||||||
|
else:
|
||||||
|
scheduled_at = now
|
||||||
content.site_status = 'scheduled'
|
content.site_status = 'scheduled'
|
||||||
content.scheduled_publish_at = scheduled_at
|
content.scheduled_publish_at = scheduled_at
|
||||||
content.site_status_updated_at = now
|
content.site_status_updated_at = now
|
||||||
|
|||||||
@@ -155,55 +155,27 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') ->
|
|||||||
allowed_days = [day_map.get(d.lower(), -1) for d in publish_days]
|
allowed_days = [day_map.get(d.lower(), -1) for d in publish_days]
|
||||||
allowed_days = [d for d in allowed_days if d >= 0]
|
allowed_days = [d for d in allowed_days if d >= 0]
|
||||||
|
|
||||||
# Calculate limits
|
# Calculate limits from configured publish days/slots
|
||||||
daily_limit = settings.daily_publish_limit
|
daily_limit = settings.daily_capacity
|
||||||
weekly_limit = settings.weekly_publish_limit
|
weekly_limit = settings.weekly_capacity
|
||||||
monthly_limit = settings.monthly_publish_limit
|
monthly_limit = settings.monthly_capacity
|
||||||
queue_limit = getattr(settings, 'queue_limit', 100) or 100
|
queue_limit = getattr(settings, 'queue_limit', 100) or 100
|
||||||
|
|
||||||
# Count existing scheduled/published content
|
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
week_start = today_start - timedelta(days=now.weekday())
|
|
||||||
month_start = today_start.replace(day=1)
|
|
||||||
|
|
||||||
daily_count = Content.objects.filter(
|
|
||||||
site=site,
|
|
||||||
site_status__in=['scheduled', 'publishing', 'published'],
|
|
||||||
scheduled_publish_at__gte=today_start
|
|
||||||
).count()
|
|
||||||
|
|
||||||
weekly_count = Content.objects.filter(
|
|
||||||
site=site,
|
|
||||||
site_status__in=['scheduled', 'publishing', 'published'],
|
|
||||||
scheduled_publish_at__gte=week_start
|
|
||||||
).count()
|
|
||||||
|
|
||||||
monthly_count = Content.objects.filter(
|
|
||||||
site=site,
|
|
||||||
site_status__in=['scheduled', 'publishing', 'published'],
|
|
||||||
scheduled_publish_at__gte=month_start
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Route to appropriate slot generator
|
# Route to appropriate slot generator
|
||||||
if settings.scheduling_mode == 'stagger':
|
# Always use time_slots mode for scheduling
|
||||||
return _generate_stagger_slots(
|
account_timezone = getattr(site.account, 'account_timezone', 'UTC') if hasattr(site, 'account') else 'UTC'
|
||||||
settings, site, now, allowed_days,
|
|
||||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
|
||||||
daily_count, weekly_count, monthly_count
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Default to time_slots mode
|
|
||||||
return _generate_time_slot_slots(
|
return _generate_time_slot_slots(
|
||||||
settings, site, now, allowed_days,
|
settings, site, now, allowed_days,
|
||||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
||||||
daily_count, weekly_count, monthly_count
|
account_timezone
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_time_slot_slots(
|
def _generate_time_slot_slots(
|
||||||
settings, site, now, allowed_days,
|
settings, site, now, allowed_days,
|
||||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
||||||
daily_count, weekly_count, monthly_count
|
account_timezone: str
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Generate slots based on specific time slots (original mode)."""
|
"""Generate slots based on specific time slots (original mode)."""
|
||||||
from igny8_core.business.content.models import Content
|
from igny8_core.business.content.models import Content
|
||||||
@@ -226,7 +198,9 @@ def _generate_time_slot_slots(
|
|||||||
current_date = now.date()
|
current_date = now.date()
|
||||||
slots_per_day = {}
|
slots_per_day = {}
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
week_start = today_start - timedelta(days=now.weekday())
|
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
tzinfo = ZoneInfo(account_timezone or 'UTC')
|
||||||
|
|
||||||
for day_offset in range(90): # Look 90 days ahead
|
for day_offset in range(90): # Look 90 days ahead
|
||||||
check_date = current_date + timedelta(days=day_offset)
|
check_date = current_date + timedelta(days=day_offset)
|
||||||
@@ -234,31 +208,75 @@ def _generate_time_slot_slots(
|
|||||||
if check_date.weekday() not in allowed_days:
|
if check_date.weekday() not in allowed_days:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for hour, minute in time_slots:
|
# Existing scheduled times for this day to avoid conflicts
|
||||||
slot_time = timezone.make_aware(
|
existing_times = set(
|
||||||
datetime.combine(check_date, datetime.min.time().replace(hour=hour, minute=minute))
|
Content.objects.filter(
|
||||||
|
site=site,
|
||||||
|
site_status__in=['scheduled', 'publishing', 'published'],
|
||||||
|
scheduled_publish_at__date=check_date
|
||||||
|
).values_list('scheduled_publish_at', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for hour, minute in time_slots:
|
||||||
|
slot_time = datetime.combine(check_date, datetime.min.time().replace(hour=hour, minute=minute))
|
||||||
|
slot_time = slot_time.replace(tzinfo=tzinfo)
|
||||||
|
|
||||||
# Skip if in the past
|
# Skip if in the past
|
||||||
if slot_time <= now:
|
if slot_time <= now:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip if slot already occupied
|
||||||
|
if slot_time in existing_times:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Count existing scheduled/published content for this day/week/month
|
||||||
|
day_start = timezone.make_aware(datetime.combine(check_date, datetime.min.time()))
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
existing_day_count = Content.objects.filter(
|
||||||
|
site=site,
|
||||||
|
site_status__in=['scheduled', 'publishing', 'published'],
|
||||||
|
scheduled_publish_at__gte=day_start,
|
||||||
|
scheduled_publish_at__lt=day_end
|
||||||
|
).count()
|
||||||
|
|
||||||
|
week_start = day_start - timedelta(days=day_start.weekday())
|
||||||
|
week_end = week_start + timedelta(days=7)
|
||||||
|
existing_week_count = Content.objects.filter(
|
||||||
|
site=site,
|
||||||
|
site_status__in=['scheduled', 'publishing', 'published'],
|
||||||
|
scheduled_publish_at__gte=week_start,
|
||||||
|
scheduled_publish_at__lt=week_end
|
||||||
|
).count()
|
||||||
|
|
||||||
|
month_start = day_start.replace(day=1)
|
||||||
|
if month_start.month == 12:
|
||||||
|
next_month_start = month_start.replace(year=month_start.year + 1, month=1)
|
||||||
|
else:
|
||||||
|
next_month_start = month_start.replace(month=month_start.month + 1)
|
||||||
|
existing_month_count = Content.objects.filter(
|
||||||
|
site=site,
|
||||||
|
site_status__in=['scheduled', 'publishing', 'published'],
|
||||||
|
scheduled_publish_at__gte=month_start,
|
||||||
|
scheduled_publish_at__lt=next_month_start
|
||||||
|
).count()
|
||||||
|
|
||||||
# Check daily limit
|
# Check daily limit
|
||||||
day_key = check_date.isoformat()
|
day_key = check_date.isoformat()
|
||||||
slots_this_day = slots_per_day.get(day_key, 0)
|
slots_this_day = slots_per_day.get(day_key, 0)
|
||||||
if daily_limit and (daily_count + slots_this_day) >= daily_limit:
|
if daily_limit and (existing_day_count + slots_this_day) >= daily_limit:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check weekly limit
|
# Check weekly limit
|
||||||
slot_week_start = slot_time - timedelta(days=slot_time.weekday())
|
scheduled_in_week = existing_week_count + len([
|
||||||
if slot_week_start.date() == week_start.date():
|
s for s in slots if s >= week_start and s < week_end
|
||||||
scheduled_in_week = weekly_count + len([s for s in slots if s >= week_start])
|
])
|
||||||
if weekly_limit and scheduled_in_week >= weekly_limit:
|
if weekly_limit and scheduled_in_week >= weekly_limit:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check monthly limit
|
# Check monthly limit
|
||||||
if slot_time.month == now.month and slot_time.year == now.year:
|
scheduled_in_month = existing_month_count + len([
|
||||||
scheduled_in_month = monthly_count + len([s for s in slots if s.month == now.month])
|
s for s in slots if s >= month_start and s < next_month_start
|
||||||
|
])
|
||||||
if monthly_limit and scheduled_in_month >= monthly_limit:
|
if monthly_limit and scheduled_in_month >= monthly_limit:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -274,8 +292,7 @@ def _generate_time_slot_slots(
|
|||||||
|
|
||||||
def _generate_stagger_slots(
|
def _generate_stagger_slots(
|
||||||
settings, site, now, allowed_days,
|
settings, site, now, allowed_days,
|
||||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
daily_limit, weekly_limit, monthly_limit, queue_limit
|
||||||
daily_count, weekly_count, monthly_count
|
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Generate slots spread evenly throughout the publishing window.
|
Generate slots spread evenly throughout the publishing window.
|
||||||
@@ -305,7 +322,6 @@ def _generate_stagger_slots(
|
|||||||
current_date = now.date()
|
current_date = now.date()
|
||||||
slots_per_day = {}
|
slots_per_day = {}
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
week_start = today_start - timedelta(days=now.weekday())
|
|
||||||
|
|
||||||
for day_offset in range(90): # Look 90 days ahead
|
for day_offset in range(90): # Look 90 days ahead
|
||||||
check_date = current_date + timedelta(days=day_offset)
|
check_date = current_date + timedelta(days=day_offset)
|
||||||
@@ -330,6 +346,37 @@ def _generate_stagger_slots(
|
|||||||
).values_list('scheduled_publish_at', flat=True)
|
).values_list('scheduled_publish_at', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Count existing scheduled/published content for this day/week/month
|
||||||
|
day_start = timezone.make_aware(datetime.combine(check_date, datetime.min.time()))
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
existing_day_count = Content.objects.filter(
|
||||||
|
site=site,
|
||||||
|
site_status__in=['scheduled', 'publishing', 'published'],
|
||||||
|
scheduled_publish_at__gte=day_start,
|
||||||
|
scheduled_publish_at__lt=day_end
|
||||||
|
).count()
|
||||||
|
|
||||||
|
week_start = day_start - timedelta(days=day_start.weekday())
|
||||||
|
week_end = week_start + timedelta(days=7)
|
||||||
|
existing_week_count = Content.objects.filter(
|
||||||
|
site=site,
|
||||||
|
site_status__in=['scheduled', 'publishing', 'published'],
|
||||||
|
scheduled_publish_at__gte=week_start,
|
||||||
|
scheduled_publish_at__lt=week_end
|
||||||
|
).count()
|
||||||
|
|
||||||
|
month_start = day_start.replace(day=1)
|
||||||
|
if month_start.month == 12:
|
||||||
|
next_month_start = month_start.replace(year=month_start.year + 1, month=1)
|
||||||
|
else:
|
||||||
|
next_month_start = month_start.replace(month=month_start.month + 1)
|
||||||
|
existing_month_count = Content.objects.filter(
|
||||||
|
site=site,
|
||||||
|
site_status__in=['scheduled', 'publishing', 'published'],
|
||||||
|
scheduled_publish_at__gte=month_start,
|
||||||
|
scheduled_publish_at__lt=next_month_start
|
||||||
|
).count()
|
||||||
|
|
||||||
# Start slot calculation
|
# Start slot calculation
|
||||||
current_slot = day_start
|
current_slot = day_start
|
||||||
if check_date == current_date and now > day_start:
|
if check_date == current_date and now > day_start:
|
||||||
@@ -343,20 +390,21 @@ def _generate_stagger_slots(
|
|||||||
while current_slot <= day_end:
|
while current_slot <= day_end:
|
||||||
# Check daily limit
|
# Check daily limit
|
||||||
slots_this_day = slots_per_day.get(day_key, 0)
|
slots_this_day = slots_per_day.get(day_key, 0)
|
||||||
if daily_limit and (daily_count + slots_this_day) >= daily_limit:
|
if daily_limit and (existing_day_count + slots_this_day) >= daily_limit:
|
||||||
break # Move to next day
|
break # Move to next day
|
||||||
|
|
||||||
# Check weekly limit
|
# Check weekly limit
|
||||||
slot_week_start = current_slot - timedelta(days=current_slot.weekday())
|
scheduled_in_week = existing_week_count + len([
|
||||||
if slot_week_start.date() == week_start.date():
|
s for s in slots if s >= week_start and s < week_end
|
||||||
scheduled_in_week = weekly_count + len([s for s in slots if s >= week_start])
|
])
|
||||||
if weekly_limit and scheduled_in_week >= weekly_limit:
|
if weekly_limit and scheduled_in_week >= weekly_limit:
|
||||||
current_slot += interval
|
current_slot += interval
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check monthly limit
|
# Check monthly limit
|
||||||
if current_slot.month == now.month and current_slot.year == now.year:
|
scheduled_in_month = existing_month_count + len([
|
||||||
scheduled_in_month = monthly_count + len([s for s in slots if s.month == now.month])
|
s for s in slots if s >= month_start and s < next_month_start
|
||||||
|
])
|
||||||
if monthly_limit and scheduled_in_month >= monthly_limit:
|
if monthly_limit and scheduled_in_month >= monthly_limit:
|
||||||
current_slot += interval
|
current_slot += interval
|
||||||
continue
|
continue
|
||||||
|
|||||||
31
backend/igny8_core/templates/emails/team_invite.html
Normal file
31
backend/igny8_core/templates/emails/team_invite.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
{% block title %}You're Invited to IGNY8{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>You're invited to join {{ account_name }}</h1>
|
||||||
|
|
||||||
|
<p>Hi {{ invited_name }},</p>
|
||||||
|
|
||||||
|
<p>{{ inviter_name }} has invited you to join the <strong>{{ account_name }}</strong> workspace on IGNY8.</p>
|
||||||
|
|
||||||
|
<p style="text-align: center;">
|
||||||
|
<a href="{{ reset_url }}" class="button">Set Your Password</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Next steps:</strong>
|
||||||
|
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||||
|
<li>Click the button above to set your password</li>
|
||||||
|
<li>Sign in to IGNY8 and start collaborating</li>
|
||||||
|
<li>This link expires in 24 hours</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||||
|
<p style="word-break: break-all; color: #6b7280; font-size: 14px;">{{ reset_url }}</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Best regards,<br>
|
||||||
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -19,6 +19,7 @@ import Input from '../form/input/InputField';
|
|||||||
import Checkbox from '../form/input/Checkbox';
|
import Checkbox from '../form/input/Checkbox';
|
||||||
import Button from '../ui/button/Button';
|
import Button from '../ui/button/Button';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { fetchCountries } from '../../utils/countries';
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -102,23 +103,8 @@ export default function SignUpFormUnified({
|
|||||||
const loadCountriesAndDetect = async () => {
|
const loadCountriesAndDetect = async () => {
|
||||||
setCountriesLoading(true);
|
setCountriesLoading(true);
|
||||||
try {
|
try {
|
||||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
const loadedCountries = await fetchCountries();
|
||||||
const response = await fetch(`${API_BASE_URL}/v1/auth/countries/`);
|
setCountries(loadedCountries);
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setCountries(data.countries || []);
|
|
||||||
} else {
|
|
||||||
// Fallback countries if backend fails
|
|
||||||
setCountries([
|
|
||||||
{ code: 'US', name: 'United States' },
|
|
||||||
{ code: 'GB', name: 'United Kingdom' },
|
|
||||||
{ code: 'CA', name: 'Canada' },
|
|
||||||
{ code: 'AU', name: 'Australia' },
|
|
||||||
{ code: 'PK', name: 'Pakistan' },
|
|
||||||
{ code: 'IN', name: 'India' },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to detect user's country for default selection
|
// Try to detect user's country for default selection
|
||||||
// Note: This may fail due to CORS - that's expected and handled gracefully
|
// Note: This may fail due to CORS - that's expected and handled gracefully
|
||||||
@@ -137,17 +123,6 @@ export default function SignUpFormUnified({
|
|||||||
// Silently fail - CORS or network error, keep default US
|
// Silently fail - CORS or network error, keep default US
|
||||||
// This is expected behavior and not a critical error
|
// This is expected behavior and not a critical error
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load countries:', err);
|
|
||||||
// Fallback countries
|
|
||||||
setCountries([
|
|
||||||
{ code: 'US', name: 'United States' },
|
|
||||||
{ code: 'GB', name: 'United Kingdom' },
|
|
||||||
{ code: 'CA', name: 'Canada' },
|
|
||||||
{ code: 'AU', name: 'Australia' },
|
|
||||||
{ code: 'PK', name: 'Pakistan' },
|
|
||||||
{ code: 'IN', name: 'India' },
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setCountriesLoading(false);
|
setCountriesLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Modal } from '../ui/modal';
|
import { Modal } from '../ui/modal';
|
||||||
import Button from '../ui/button/Button';
|
import Button from '../ui/button/Button';
|
||||||
import { CalendarIcon, ClockIcon, ErrorIcon } from '../../icons';
|
import { CalendarIcon, ClockIcon, ErrorIcon } from '../../icons';
|
||||||
|
import { getAccountTimezone } from '../../utils/timezone';
|
||||||
|
|
||||||
interface Content {
|
interface Content {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -72,7 +73,8 @@ const BulkScheduleModal: React.FC<BulkScheduleModalProps> = ({
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true
|
hour12: true,
|
||||||
|
timeZone: getAccountTimezone(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return '';
|
return '';
|
||||||
@@ -140,7 +142,7 @@ const BulkScheduleModal: React.FC<BulkScheduleModalProps> = ({
|
|||||||
{selectedDate && selectedTime && (
|
{selectedDate && selectedTime && (
|
||||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
|
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
|
||||||
<p className="text-sm font-medium text-blue-900">
|
<p className="text-sm font-medium text-blue-900">
|
||||||
Preview: {formatPreviewDate()}
|
Preview: {formatPreviewDate()} ({getAccountTimezone()})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Modal } from '../ui/modal';
|
import { Modal } from '../ui/modal';
|
||||||
import Button from '../ui/button/Button';
|
import Button from '../ui/button/Button';
|
||||||
import { CalendarIcon, InfoIcon, ExternalLinkIcon } from '../../icons';
|
import { CalendarIcon, InfoIcon, ExternalLinkIcon } from '../../icons';
|
||||||
|
import { getAccountTimezone } from '../../utils/timezone';
|
||||||
|
|
||||||
interface SchedulePreviewItem {
|
interface SchedulePreviewItem {
|
||||||
content_id: number;
|
content_id: number;
|
||||||
@@ -42,6 +43,8 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (!previewData) return null;
|
if (!previewData) return null;
|
||||||
|
|
||||||
|
const accountTimezone = getAccountTimezone();
|
||||||
|
|
||||||
const formatDate = (isoString: string) => {
|
const formatDate = (isoString: string) => {
|
||||||
try {
|
try {
|
||||||
const date = new Date(isoString);
|
const date = new Date(isoString);
|
||||||
@@ -51,7 +54,8 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true
|
hour12: true,
|
||||||
|
timeZone: accountTimezone
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return isoString;
|
return isoString;
|
||||||
@@ -68,7 +72,8 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true
|
hour12: true,
|
||||||
|
timeZone: accountTimezone
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return isoString;
|
return isoString;
|
||||||
@@ -102,8 +107,7 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
|
|||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||||
<p className="text-sm font-semibold text-blue-900 mb-2">Using site default schedule:</p>
|
<p className="text-sm font-semibold text-blue-900 mb-2">Using site default schedule:</p>
|
||||||
<ul className="space-y-1 text-sm text-blue-800">
|
<ul className="space-y-1 text-sm text-blue-800">
|
||||||
<li>• Start time: {previewData.site_settings.base_time} ({previewData.site_settings.timezone})</li>
|
<li>• First slot: {previewData.site_settings.base_time} ({accountTimezone})</li>
|
||||||
<li>• Stagger: {previewData.site_settings.stagger_interval} minutes between each</li>
|
|
||||||
<li>• First publish: {formatFullDate(firstPublish.scheduled_at)}</li>
|
<li>• First publish: {formatFullDate(firstPublish.scheduled_at)}</li>
|
||||||
<li>• Last publish: {formatFullDate(lastPublish.scheduled_at)}</li>
|
<li>• Last publish: {formatFullDate(lastPublish.scheduled_at)}</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -111,41 +115,27 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
|
|||||||
|
|
||||||
{/* Schedule Preview */}
|
{/* Schedule Preview */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-sm font-semibold text-gray-700 mb-3">Schedule Preview:</p>
|
<p className="text-sm font-semibold text-gray-700 mb-3">Schedule Preview (first 5):</p>
|
||||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
<div className="border border-gray-200 rounded-lg bg-white">
|
||||||
<div className="max-h-80 overflow-y-auto">
|
<ul className="divide-y divide-gray-100">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
{previewData.schedule_preview.slice(0, 5).map((item, index) => (
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<li key={item.content_id} className="px-4 py-3 flex items-start gap-3">
|
||||||
<tr>
|
<span className="text-xs font-semibold text-white bg-brand-500 rounded-full w-6 h-6 flex items-center justify-center">
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
#
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Article
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Scheduled Time
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{previewData.schedule_preview.map((item, index) => (
|
|
||||||
<tr key={item.content_id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-500">
|
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-4 py-3 text-sm text-gray-900 truncate max-w-md">
|
<div className="min-w-0">
|
||||||
{item.title}
|
<p className="text-sm font-medium text-gray-900 truncate">{item.title}</p>
|
||||||
</td>
|
<p className="text-xs text-gray-500 mt-0.5">{formatDate(item.scheduled_at)}</p>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">
|
</div>
|
||||||
{formatDate(item.scheduled_at)}
|
</li>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</ul>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{previewData.schedule_preview.length > 5 && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
+{previewData.schedule_preview.length - 5} more items will be scheduled in the next available slots.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
@@ -159,7 +149,7 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
|
|||||||
onClick={onChangeSettings}
|
onClick={onChangeSettings}
|
||||||
className="text-primary-600 hover:text-primary-700 font-medium inline-flex items-center gap-1"
|
className="text-primary-600 hover:text-primary-700 font-medium inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Site Settings → Publishing tab
|
Site Settings → Automation tab
|
||||||
<ExternalLinkIcon className="w-3 h-3" />
|
<ExternalLinkIcon className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Modal } from '../ui/modal';
|
import { Modal } from '../ui/modal';
|
||||||
import Button from '../ui/button/Button';
|
import Button from '../ui/button/Button';
|
||||||
import { CalendarIcon, ClockIcon } from '../../icons';
|
import { CalendarIcon, ClockIcon } from '../../icons';
|
||||||
|
import { getAccountTimezone } from '../../utils/timezone';
|
||||||
|
|
||||||
interface Content {
|
interface Content {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -84,7 +85,8 @@ const ScheduleContentModal: React.FC<ScheduleContentModalProps> = ({
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true
|
hour12: true,
|
||||||
|
timeZone: getAccountTimezone(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return '';
|
return '';
|
||||||
@@ -154,7 +156,7 @@ const ScheduleContentModal: React.FC<ScheduleContentModalProps> = ({
|
|||||||
{selectedDate && selectedTime && (
|
{selectedDate && selectedTime && (
|
||||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
|
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
|
||||||
<p className="text-sm font-medium text-blue-900">
|
<p className="text-sm font-medium text-blue-900">
|
||||||
Preview: {formatPreviewDate()}
|
Preview: {formatPreviewDate()} ({getAccountTimezone()})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
127
frontend/src/constants/timezones.ts
Normal file
127
frontend/src/constants/timezones.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
export const COUNTRIES = [
|
||||||
|
{ value: 'US', label: 'United States' },
|
||||||
|
{ value: 'GB', label: 'United Kingdom' },
|
||||||
|
{ value: 'CA', label: 'Canada' },
|
||||||
|
{ value: 'AU', label: 'Australia' },
|
||||||
|
{ value: 'IN', label: 'India' },
|
||||||
|
{ value: 'PK', label: 'Pakistan' },
|
||||||
|
{ value: 'DE', label: 'Germany' },
|
||||||
|
{ value: 'FR', label: 'France' },
|
||||||
|
{ value: 'ES', label: 'Spain' },
|
||||||
|
{ value: 'IT', label: 'Italy' },
|
||||||
|
{ value: 'NL', label: 'Netherlands' },
|
||||||
|
{ value: 'SE', label: 'Sweden' },
|
||||||
|
{ value: 'NO', label: 'Norway' },
|
||||||
|
{ value: 'DK', label: 'Denmark' },
|
||||||
|
{ value: 'FI', label: 'Finland' },
|
||||||
|
{ value: 'BE', label: 'Belgium' },
|
||||||
|
{ value: 'AT', label: 'Austria' },
|
||||||
|
{ value: 'CH', label: 'Switzerland' },
|
||||||
|
{ value: 'IE', label: 'Ireland' },
|
||||||
|
{ value: 'NZ', label: 'New Zealand' },
|
||||||
|
{ value: 'SG', label: 'Singapore' },
|
||||||
|
{ value: 'AE', label: 'United Arab Emirates' },
|
||||||
|
{ value: 'SA', label: 'Saudi Arabia' },
|
||||||
|
{ value: 'ZA', label: 'South Africa' },
|
||||||
|
{ value: 'BR', label: 'Brazil' },
|
||||||
|
{ value: 'MX', label: 'Mexico' },
|
||||||
|
{ value: 'AR', label: 'Argentina' },
|
||||||
|
{ value: 'CL', label: 'Chile' },
|
||||||
|
{ value: 'CO', label: 'Colombia' },
|
||||||
|
{ value: 'JP', label: 'Japan' },
|
||||||
|
{ value: 'KR', label: 'South Korea' },
|
||||||
|
{ value: 'CN', label: 'China' },
|
||||||
|
{ value: 'TH', label: 'Thailand' },
|
||||||
|
{ value: 'MY', label: 'Malaysia' },
|
||||||
|
{ value: 'ID', label: 'Indonesia' },
|
||||||
|
{ value: 'PH', label: 'Philippines' },
|
||||||
|
{ value: 'VN', label: 'Vietnam' },
|
||||||
|
{ value: 'BD', label: 'Bangladesh' },
|
||||||
|
{ value: 'LK', label: 'Sri Lanka' },
|
||||||
|
{ value: 'EG', label: 'Egypt' },
|
||||||
|
{ value: 'NG', label: 'Nigeria' },
|
||||||
|
{ value: 'KE', label: 'Kenya' },
|
||||||
|
{ value: 'GH', label: 'Ghana' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COUNTRY_TIMEZONE_MAP: Record<string, string> = {
|
||||||
|
US: 'America/New_York',
|
||||||
|
GB: 'Europe/London',
|
||||||
|
CA: 'America/Toronto',
|
||||||
|
AU: 'Australia/Sydney',
|
||||||
|
IN: 'Asia/Kolkata',
|
||||||
|
PK: 'Asia/Karachi',
|
||||||
|
DE: 'Europe/Berlin',
|
||||||
|
FR: 'Europe/Paris',
|
||||||
|
ES: 'Europe/Madrid',
|
||||||
|
IT: 'Europe/Rome',
|
||||||
|
NL: 'Europe/Amsterdam',
|
||||||
|
SE: 'Europe/Stockholm',
|
||||||
|
NO: 'Europe/Oslo',
|
||||||
|
DK: 'Europe/Copenhagen',
|
||||||
|
FI: 'Europe/Helsinki',
|
||||||
|
BE: 'Europe/Brussels',
|
||||||
|
AT: 'Europe/Vienna',
|
||||||
|
CH: 'Europe/Zurich',
|
||||||
|
IE: 'Europe/Dublin',
|
||||||
|
NZ: 'Pacific/Auckland',
|
||||||
|
SG: 'Asia/Singapore',
|
||||||
|
AE: 'Asia/Dubai',
|
||||||
|
SA: 'Asia/Riyadh',
|
||||||
|
ZA: 'Africa/Johannesburg',
|
||||||
|
BR: 'America/Sao_Paulo',
|
||||||
|
MX: 'America/Mexico_City',
|
||||||
|
AR: 'America/Argentina/Buenos_Aires',
|
||||||
|
CL: 'America/Santiago',
|
||||||
|
CO: 'America/Bogota',
|
||||||
|
JP: 'Asia/Tokyo',
|
||||||
|
KR: 'Asia/Seoul',
|
||||||
|
CN: 'Asia/Shanghai',
|
||||||
|
TH: 'Asia/Bangkok',
|
||||||
|
MY: 'Asia/Kuala_Lumpur',
|
||||||
|
ID: 'Asia/Jakarta',
|
||||||
|
PH: 'Asia/Manila',
|
||||||
|
VN: 'Asia/Ho_Chi_Minh',
|
||||||
|
BD: 'Asia/Dhaka',
|
||||||
|
LK: 'Asia/Colombo',
|
||||||
|
EG: 'Africa/Cairo',
|
||||||
|
NG: 'Africa/Lagos',
|
||||||
|
KE: 'Africa/Nairobi',
|
||||||
|
GH: 'Africa/Accra',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIMEZONE_OPTIONS = [
|
||||||
|
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
|
||||||
|
{ value: 'Etc/GMT+12', label: 'UTC-12:00 (Baker Island)' },
|
||||||
|
{ value: 'Etc/GMT+11', label: 'UTC-11:00 (American Samoa)' },
|
||||||
|
{ value: 'Etc/GMT+10', label: 'UTC-10:00 (Hawaii)' },
|
||||||
|
{ value: 'Etc/GMT+9', label: 'UTC-09:00 (Alaska)' },
|
||||||
|
{ value: 'Etc/GMT+8', label: 'UTC-08:00 (Pacific Time)' },
|
||||||
|
{ value: 'Etc/GMT+7', label: 'UTC-07:00 (Mountain Time)' },
|
||||||
|
{ value: 'Etc/GMT+6', label: 'UTC-06:00 (Central Time)' },
|
||||||
|
{ value: 'Etc/GMT+5', label: 'UTC-05:00 (Eastern Time)' },
|
||||||
|
{ value: 'Etc/GMT+4', label: 'UTC-04:00 (Atlantic Time)' },
|
||||||
|
{ value: 'Etc/GMT+3', label: 'UTC-03:00 (Buenos Aires)' },
|
||||||
|
{ value: 'Etc/GMT+2', label: 'UTC-02:00 (Mid-Atlantic)' },
|
||||||
|
{ value: 'Etc/GMT+1', label: 'UTC-01:00 (Azores)' },
|
||||||
|
{ value: 'UTC', label: 'UTC+00:00 (London)' },
|
||||||
|
{ value: 'Etc/GMT-1', label: 'UTC+01:00 (Berlin, Paris)' },
|
||||||
|
{ value: 'Etc/GMT-2', label: 'UTC+02:00 (Athens, Cairo)' },
|
||||||
|
{ value: 'Etc/GMT-3', label: 'UTC+03:00 (Riyadh)' },
|
||||||
|
{ value: 'Etc/GMT-4', label: 'UTC+04:00 (Dubai)' },
|
||||||
|
{ value: 'Etc/GMT-5', label: 'UTC+05:00 (Karachi)' },
|
||||||
|
{ value: 'Etc/GMT-6', label: 'UTC+06:00 (Dhaka)' },
|
||||||
|
{ value: 'Etc/GMT-7', label: 'UTC+07:00 (Bangkok)' },
|
||||||
|
{ value: 'Etc/GMT-8', label: 'UTC+08:00 (Singapore)' },
|
||||||
|
{ value: 'Etc/GMT-9', label: 'UTC+09:00 (Tokyo)' },
|
||||||
|
{ value: 'Etc/GMT-10', label: 'UTC+10:00 (Sydney)' },
|
||||||
|
{ value: 'Etc/GMT-11', label: 'UTC+11:00 (Solomon Islands)' },
|
||||||
|
{ value: 'Etc/GMT-12', label: 'UTC+12:00 (Auckland)' },
|
||||||
|
{ value: 'Etc/GMT-13', label: 'UTC+13:00 (Samoa)' },
|
||||||
|
{ value: 'Etc/GMT-14', label: 'UTC+14:00 (Line Islands)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getTimezoneForCountry = (countryCode?: string): string => {
|
||||||
|
if (!countryCode) return 'UTC';
|
||||||
|
return COUNTRY_TIMEZONE_MAP[countryCode.toUpperCase()] || 'UTC';
|
||||||
|
};
|
||||||
@@ -22,6 +22,8 @@ import PageHeader from '../../components/common/PageHeader';
|
|||||||
import ComponentCard from '../../components/common/ComponentCard';
|
import ComponentCard from '../../components/common/ComponentCard';
|
||||||
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
|
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
|
||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
|
import { formatDate, formatDateTime } from '../../utils/date';
|
||||||
|
import { getAccountTimezone } from '../../utils/timezone';
|
||||||
import {
|
import {
|
||||||
BoltIcon,
|
BoltIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
@@ -81,7 +83,7 @@ const AutomationPage: React.FC = () => {
|
|||||||
|
|
||||||
// Server time state - shows the actual time used for all operations
|
// Server time state - shows the actual time used for all operations
|
||||||
const [serverTime, setServerTime] = useState<string | null>(null);
|
const [serverTime, setServerTime] = useState<string | null>(null);
|
||||||
const [serverTimezone, setServerTimezone] = useState<string>('UTC');
|
const accountTimezone = getAccountTimezone();
|
||||||
|
|
||||||
// Track site ID to avoid duplicate calls when activeSite object reference changes
|
// Track site ID to avoid duplicate calls when activeSite object reference changes
|
||||||
const siteId = activeSite?.id;
|
const siteId = activeSite?.id;
|
||||||
@@ -91,8 +93,7 @@ const AutomationPage: React.FC = () => {
|
|||||||
const loadServerTime = async () => {
|
const loadServerTime = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await automationService.getServerTime();
|
const data = await automationService.getServerTime();
|
||||||
setServerTime(data.server_time_formatted);
|
setServerTime(data.server_time);
|
||||||
setServerTimezone(data.timezone);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load server time:', error);
|
console.error('Failed to load server time:', error);
|
||||||
}
|
}
|
||||||
@@ -111,12 +112,14 @@ const AutomationPage: React.FC = () => {
|
|||||||
const getNextRunTime = (config: AutomationConfig): string => {
|
const getNextRunTime = (config: AutomationConfig): string => {
|
||||||
if (!config.is_enabled || !config.scheduled_time) return '';
|
if (!config.is_enabled || !config.scheduled_time) return '';
|
||||||
|
|
||||||
const now = new Date();
|
const now = serverTime ? new Date(serverTime) : new Date();
|
||||||
const [schedHours, schedMinutes] = config.scheduled_time.split(':').map(Number);
|
const [schedHours, schedMinutes] = config.scheduled_time.split(':').map(Number);
|
||||||
|
|
||||||
// Create next run date
|
// Prefer server-provided next run time if available
|
||||||
const nextRun = new Date();
|
const nextRun = config.next_run_at ? new Date(config.next_run_at) : new Date();
|
||||||
|
if (!config.next_run_at) {
|
||||||
nextRun.setUTCHours(schedHours, schedMinutes, 0, 0);
|
nextRun.setUTCHours(schedHours, schedMinutes, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// If scheduled time has passed today, set to tomorrow
|
// If scheduled time has passed today, set to tomorrow
|
||||||
if (nextRun <= now) {
|
if (nextRun <= now) {
|
||||||
@@ -145,6 +148,17 @@ const AutomationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTime = (value: string | null) => {
|
||||||
|
if (!value) return '--:--';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return '--:--';
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: accountTimezone,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!siteId) return;
|
if (!siteId) return;
|
||||||
// Reset state when site changes
|
// Reset state when site changes
|
||||||
@@ -600,7 +614,7 @@ const AutomationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-px bg-white/25"></div>
|
<div className="h-4 w-px bg-white/25"></div>
|
||||||
<div className="text-sm text-white/80">
|
<div className="text-sm text-white/80">
|
||||||
Last: <span className="font-medium">{config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'}</span>
|
Last: <span className="font-medium">{config.last_run_at ? formatDate(config.last_run_at) : 'Never'}</span>
|
||||||
</div>
|
</div>
|
||||||
{config.is_enabled && (
|
{config.is_enabled && (
|
||||||
<>
|
<>
|
||||||
@@ -613,7 +627,7 @@ const AutomationPage: React.FC = () => {
|
|||||||
<div className="h-4 w-px bg-white/25"></div>
|
<div className="h-4 w-px bg-white/25"></div>
|
||||||
<div className="text-sm text-white inline-flex items-center gap-1">
|
<div className="text-sm text-white inline-flex items-center gap-1">
|
||||||
<TimeIcon className="size-3.5" />
|
<TimeIcon className="size-3.5" />
|
||||||
<span className="font-semibold tabular-nums">{serverTime ? serverTime.substring(0, 5) : '--:--'}</span>
|
<span className="font-semibold tabular-nums">{formatTime(serverTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -632,7 +646,7 @@ const AutomationPage: React.FC = () => {
|
|||||||
{!currentRun && totalPending === 0 && 'No Items Pending'}
|
{!currentRun && totalPending === 0 && 'No Items Pending'}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs ${totalPending > 0 || currentRun ? 'text-gray-600' : 'text-white/70'}`}>
|
<span className={`text-xs ${totalPending > 0 || currentRun ? 'text-gray-600' : 'text-white/70'}`}>
|
||||||
{currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
|
{currentRun ? `Started: ${formatDateTime(currentRun.started_at)}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function ContentCalendar() {
|
|||||||
|
|
||||||
// Schedule modal state
|
// Schedule modal state
|
||||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||||
const [scheduleContent, setScheduleContent] = useState<Content | null>(null);
|
const [scheduledContentItem, setScheduledContentItem] = useState<Content | null>(null);
|
||||||
const [isRescheduling, setIsRescheduling] = useState(false);
|
const [isRescheduling, setIsRescheduling] = useState(false);
|
||||||
|
|
||||||
// Derived state: Queue items (scheduled or publishing - exclude already published)
|
// Derived state: Queue items (scheduled or publishing - exclude already published)
|
||||||
@@ -87,11 +87,6 @@ export default function ContentCalendar() {
|
|||||||
return dateA - dateB;
|
return dateA - dateB;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[ContentCalendar] queueItems (derived):', items.length, 'items');
|
|
||||||
items.forEach(item => {
|
|
||||||
console.log(' Queue item:', item.id, item.title, 'scheduled:', item.scheduled_publish_at);
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [allContent]);
|
}, [allContent]);
|
||||||
|
|
||||||
@@ -122,11 +117,6 @@ export default function ContentCalendar() {
|
|||||||
return dateB - dateA;
|
return dateB - dateA;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[ContentCalendar] failedItems (derived):', items.length, 'items');
|
|
||||||
items.forEach(item => {
|
|
||||||
console.log(' Failed item:', item.id, item.title, 'error:', item.site_status_message);
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [allContent]);
|
}, [allContent]);
|
||||||
|
|
||||||
@@ -136,14 +126,9 @@ export default function ContentCalendar() {
|
|||||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// DEBUG: Check scheduled items in stats calculation
|
|
||||||
const scheduledItems = allContent.filter((c: Content) =>
|
const scheduledItems = allContent.filter((c: Content) =>
|
||||||
c.site_status === 'scheduled' && (!c.external_id || c.external_id === '')
|
c.site_status === 'scheduled' && (!c.external_id || c.external_id === '')
|
||||||
);
|
);
|
||||||
console.log('[ContentCalendar] STATS CALCULATION - Scheduled items:', scheduledItems.length);
|
|
||||||
scheduledItems.forEach(c => {
|
|
||||||
console.log(' Stats scheduled item:', c.id, c.title, 'external_id:', c.external_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Published in last 30 days - check EITHER external_id OR site_status='published'
|
// Published in last 30 days - check EITHER external_id OR site_status='published'
|
||||||
const publishedLast30Days = allContent.filter((c: Content) => {
|
const publishedLast30Days = allContent.filter((c: Content) => {
|
||||||
@@ -182,10 +167,7 @@ export default function ContentCalendar() {
|
|||||||
}, [allContent]);
|
}, [allContent]);
|
||||||
|
|
||||||
const loadQueue = useCallback(async () => {
|
const loadQueue = useCallback(async () => {
|
||||||
if (!activeSite?.id) {
|
if (!activeSite?.id) return;
|
||||||
console.log('[ContentCalendar] No active site selected, skipping load');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -196,11 +178,6 @@ export default function ContentCalendar() {
|
|||||||
|
|
||||||
const siteId = activeSite.id;
|
const siteId = activeSite.id;
|
||||||
|
|
||||||
console.log('[ContentCalendar] ========== SITE FILTERING DEBUG ==========');
|
|
||||||
console.log('[ContentCalendar] Active site ID:', siteId);
|
|
||||||
console.log('[ContentCalendar] Active site name:', activeSite.name);
|
|
||||||
console.log('[ContentCalendar] Fetching content with multiple targeted queries...');
|
|
||||||
|
|
||||||
// Fetch scheduled items (all of them, regardless of page)
|
// Fetch scheduled items (all of them, regardless of page)
|
||||||
const scheduledResponse = await fetchAPI(
|
const scheduledResponse = await fetchAPI(
|
||||||
`/v1/writer/content/?site_id=${siteId}&page_size=1000&site_status=scheduled`
|
`/v1/writer/content/?site_id=${siteId}&page_size=1000&site_status=scheduled`
|
||||||
@@ -240,34 +217,6 @@ export default function ContentCalendar() {
|
|||||||
new Map(allItems.map(item => [item.id, item])).values()
|
new Map(allItems.map(item => [item.id, item])).values()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Debug: Comprehensive logging
|
|
||||||
console.log('[ContentCalendar] ========== DATA LOAD DEBUG ==========');
|
|
||||||
console.log('[ContentCalendar] Scheduled query returned:', scheduledResponse.results?.length, 'items');
|
|
||||||
console.log('[ContentCalendar] Failed query returned:', failedResponse.results?.length, 'items');
|
|
||||||
console.log('[ContentCalendar] Review query returned:', reviewResponse.results?.length, 'items');
|
|
||||||
console.log('[ContentCalendar] Approved query returned:', approvedResponse.results?.length, 'items');
|
|
||||||
console.log('[ContentCalendar] Published query returned:', publishedResponse.results?.length, 'items');
|
|
||||||
console.log('[ContentCalendar] Total unique items after deduplication:', uniqueItems.length);
|
|
||||||
|
|
||||||
console.log('[ContentCalendar] ALL SCHEDULED ITEMS DETAILS:');
|
|
||||||
scheduledResponse.results?.forEach(c => {
|
|
||||||
console.log(' - ID:', c.id, '| Title:', c.title);
|
|
||||||
console.log(' status:', c.status, '| site_status:', c.site_status);
|
|
||||||
console.log(' scheduled_publish_at:', c.scheduled_publish_at);
|
|
||||||
console.log(' external_id:', c.external_id);
|
|
||||||
console.log(' ---');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[ContentCalendar] ALL FAILED ITEMS DETAILS:');
|
|
||||||
failedResponse.results?.forEach(c => {
|
|
||||||
console.log(' - ID:', c.id, '| Title:', c.title);
|
|
||||||
console.log(' status:', c.status, '| site_status:', c.site_status);
|
|
||||||
console.log(' site_status_message:', c.site_status_message);
|
|
||||||
console.log(' scheduled_publish_at:', c.scheduled_publish_at);
|
|
||||||
console.log(' ---');
|
|
||||||
});
|
|
||||||
console.log('[ContentCalendar] ====================================');
|
|
||||||
|
|
||||||
setAllContent(uniqueItems);
|
setAllContent(uniqueItems);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to load content: ${error.message}`);
|
toast.error(`Failed to load content: ${error.message}`);
|
||||||
@@ -279,11 +228,8 @@ export default function ContentCalendar() {
|
|||||||
// Load queue when active site changes
|
// Load queue when active site changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSite?.id) {
|
if (activeSite?.id) {
|
||||||
console.log('[ContentCalendar] Site changed to:', activeSite.id, activeSite.name);
|
|
||||||
console.log('[ContentCalendar] Triggering loadQueue...');
|
|
||||||
loadQueue();
|
loadQueue();
|
||||||
} else {
|
} else {
|
||||||
console.log('[ContentCalendar] No active site, clearing content');
|
|
||||||
setAllContent([]);
|
setAllContent([]);
|
||||||
}
|
}
|
||||||
}, [activeSite?.id]); // Only depend on activeSite.id, loadQueue is stable
|
}, [activeSite?.id]); // Only depend on activeSite.id, loadQueue is stable
|
||||||
@@ -306,7 +252,7 @@ export default function ContentCalendar() {
|
|||||||
|
|
||||||
// Open reschedule modal
|
// Open reschedule modal
|
||||||
const openRescheduleModal = useCallback((item: Content) => {
|
const openRescheduleModal = useCallback((item: Content) => {
|
||||||
setScheduleContent(item);
|
setScheduledContentItem(item);
|
||||||
setIsRescheduling(true);
|
setIsRescheduling(true);
|
||||||
setShowScheduleModal(true);
|
setShowScheduleModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -321,7 +267,7 @@ export default function ContentCalendar() {
|
|||||||
loadQueue();
|
loadQueue();
|
||||||
}
|
}
|
||||||
setShowScheduleModal(false);
|
setShowScheduleModal(false);
|
||||||
setScheduleContent(null);
|
setScheduledContentItem(null);
|
||||||
setIsRescheduling(false);
|
setIsRescheduling(false);
|
||||||
}, [isRescheduling, handleRescheduleContent, toast, loadQueue]);
|
}, [isRescheduling, handleRescheduleContent, toast, loadQueue]);
|
||||||
|
|
||||||
@@ -687,10 +633,14 @@ export default function ContentCalendar() {
|
|||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
tomorrow.setHours(9, 0, 0, 0);
|
tomorrow.setHours(9, 0, 0, 0);
|
||||||
scheduleContent(draggedItem.id, tomorrow.toISOString())
|
scheduleContent(draggedItem.id, tomorrow.toISOString())
|
||||||
.then((updatedContent) => {
|
.then((response) => {
|
||||||
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
||||||
setAllContent(prevContent => [
|
setAllContent(prevContent => [
|
||||||
...prevContent.map(c => c.id === draggedItem.id ? updatedContent : c)
|
...prevContent.map(c => c.id === draggedItem.id ? {
|
||||||
|
...c,
|
||||||
|
site_status: response.site_status,
|
||||||
|
scheduled_publish_at: response.scheduled_publish_at,
|
||||||
|
} : c)
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
.catch((err) => toast.error(`Failed to schedule: ${err.message}`));
|
.catch((err) => toast.error(`Failed to schedule: ${err.message}`));
|
||||||
@@ -1008,15 +958,15 @@ export default function ContentCalendar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schedule/Reschedule Modal */}
|
{/* Schedule/Reschedule Modal */}
|
||||||
{showScheduleModal && scheduleContent && (
|
{showScheduleModal && scheduledContentItem && (
|
||||||
<ScheduleContentModal
|
<ScheduleContentModal
|
||||||
isOpen={showScheduleModal}
|
isOpen={showScheduleModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowScheduleModal(false);
|
setShowScheduleModal(false);
|
||||||
setScheduleContent(null);
|
setScheduledContentItem(null);
|
||||||
setIsRescheduling(false);
|
setIsRescheduling(false);
|
||||||
}}
|
}}
|
||||||
content={scheduleContent}
|
content={scheduledContentItem}
|
||||||
onSchedule={handleScheduleFromModal}
|
onSchedule={handleScheduleFromModal}
|
||||||
mode={isRescheduling ? 'reschedule' : 'schedule'}
|
mode={isRescheduling ? 'reschedule' : 'schedule'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getAccountTimezone } from '../../utils/timezone';
|
||||||
/**
|
/**
|
||||||
* AI & Automation Settings Component
|
* AI & Automation Settings Component
|
||||||
* Per SETTINGS-CONSOLIDATION-PLAN.md
|
* Per SETTINGS-CONSOLIDATION-PLAN.md
|
||||||
@@ -914,11 +915,11 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Timezone</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Timezone</span>
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">UTC</p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">{getAccountTimezone()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
The scheduler runs at 5 minutes past each hour. Select the hour you want your automation to run — for example, selecting 2 PM means it will run at 2:05 PM UTC. Automations only run once per day. If it has already run today, the next run will be tomorrow (or the next scheduled day for weekly/monthly).
|
The scheduler runs at 5 minutes past each hour. Select the hour you want your automation to run — for example, selecting 2 PM means it will run at 2:05 PM {getAccountTimezone()}. Automations only run once per day. If it has already run today, the next run will be tomorrow (or the next scheduled day for weekly/monthly).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ export default function Approved() {
|
|||||||
// Open site settings in new tab
|
// Open site settings in new tab
|
||||||
const handleOpenSiteSettings = useCallback(() => {
|
const handleOpenSiteSettings = useCallback(() => {
|
||||||
if (activeSite) {
|
if (activeSite) {
|
||||||
window.open(`/sites/${activeSite.id}/settings?tab=publishing`, '_blank');
|
window.open(`/sites/${activeSite.id}/settings?tab=automation`, '_blank');
|
||||||
}
|
}
|
||||||
}, [activeSite]);
|
}, [activeSite]);
|
||||||
|
|
||||||
@@ -630,13 +630,16 @@ export default function Approved() {
|
|||||||
if (action === 'bulk_publish_site') {
|
if (action === 'bulk_publish_site') {
|
||||||
await handleBulkPublishToSite(ids);
|
await handleBulkPublishToSite(ids);
|
||||||
} else if (action === 'bulk_schedule_manual') {
|
} else if (action === 'bulk_schedule_manual') {
|
||||||
// Manual bulk scheduling (same time for all)
|
// Manual bulk scheduling (same time for all) via modal
|
||||||
handleBulkScheduleManual(ids);
|
const numericIds = ids.map(id => parseInt(id));
|
||||||
|
const items = content.filter(item => numericIds.includes(item.id));
|
||||||
|
setBulkScheduleItems(items);
|
||||||
|
setShowBulkScheduleModal(true);
|
||||||
} else if (action === 'bulk_schedule_defaults') {
|
} else if (action === 'bulk_schedule_defaults') {
|
||||||
// Schedule with site defaults
|
// Schedule with site defaults
|
||||||
handleBulkScheduleWithDefaults(ids);
|
handleBulkScheduleWithDefaults(ids);
|
||||||
}
|
}
|
||||||
}, [handleBulkPublishToSite, handleBulkScheduleManual, handleBulkScheduleWithDefaults]);
|
}, [handleBulkPublishToSite, handleBulkScheduleWithDefaults, content]);
|
||||||
|
|
||||||
// Bulk status update handler
|
// Bulk status update handler
|
||||||
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
|
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { Modal } from '../../components/ui/modal';
|
|||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||||
|
import { TIMEZONE_OPTIONS, getTimezoneForCountry } from '../../constants/timezones';
|
||||||
|
import { fetchCountries } from '../../utils/countries';
|
||||||
import {
|
import {
|
||||||
getAccountSettings,
|
getAccountSettings,
|
||||||
updateAccountSettings,
|
updateAccountSettings,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
inviteTeamMember,
|
inviteTeamMember,
|
||||||
removeTeamMember,
|
removeTeamMember,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
|
updateUserProfile,
|
||||||
changePassword,
|
changePassword,
|
||||||
type AccountSettings,
|
type AccountSettings,
|
||||||
type TeamMember,
|
type TeamMember,
|
||||||
@@ -40,6 +43,8 @@ export default function AccountSettingsPage() {
|
|||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [success, setSuccess] = useState<string>('');
|
const [success, setSuccess] = useState<string>('');
|
||||||
|
const [countries, setCountries] = useState<Array<{ code: string; name: string }>>([]);
|
||||||
|
const [countriesLoading, setCountriesLoading] = useState(false);
|
||||||
|
|
||||||
// Account settings state
|
// Account settings state
|
||||||
const [settings, setSettings] = useState<AccountSettings | null>(null);
|
const [settings, setSettings] = useState<AccountSettings | null>(null);
|
||||||
@@ -53,6 +58,9 @@ export default function AccountSettingsPage() {
|
|||||||
billing_country: '',
|
billing_country: '',
|
||||||
tax_id: '',
|
tax_id: '',
|
||||||
billing_email: '',
|
billing_email: '',
|
||||||
|
account_timezone: 'UTC',
|
||||||
|
timezone_mode: 'country' as 'country' | 'manual',
|
||||||
|
timezone_offset: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Profile settings state
|
// Profile settings state
|
||||||
@@ -61,7 +69,6 @@ export default function AccountSettingsPage() {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
timezone: 'America/New_York',
|
|
||||||
language: 'en',
|
language: 'en',
|
||||||
emailNotifications: true,
|
emailNotifications: true,
|
||||||
marketingEmails: false,
|
marketingEmails: false,
|
||||||
@@ -87,28 +94,26 @@ export default function AccountSettingsPage() {
|
|||||||
last_name: '',
|
last_name: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load profile from auth store user data
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
const [firstName = '', lastName = ''] = (user.username || '').split(' ');
|
|
||||||
setProfileForm({
|
|
||||||
firstName: firstName,
|
|
||||||
lastName: lastName,
|
|
||||||
email: user.email || '',
|
|
||||||
phone: '',
|
|
||||||
timezone: 'America/New_York',
|
|
||||||
language: 'en',
|
|
||||||
emailNotifications: true,
|
|
||||||
marketingEmails: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
loadTeamMembers();
|
loadTeamMembers();
|
||||||
|
loadProfile();
|
||||||
|
loadCountries();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load profile from auth store user data (fallback if API not ready)
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && !profileForm.email) {
|
||||||
|
const [firstName = '', lastName = ''] = (user.username || '').split(' ');
|
||||||
|
setProfileForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: user.email || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [user, profileForm.email]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
startLoading('Loading settings...');
|
startLoading('Loading settings...');
|
||||||
@@ -124,6 +129,9 @@ export default function AccountSettingsPage() {
|
|||||||
billing_country: accountData.billing_country || '',
|
billing_country: accountData.billing_country || '',
|
||||||
tax_id: accountData.tax_id || '',
|
tax_id: accountData.tax_id || '',
|
||||||
billing_email: accountData.billing_email || '',
|
billing_email: accountData.billing_email || '',
|
||||||
|
account_timezone: accountData.account_timezone || 'UTC',
|
||||||
|
timezone_mode: accountData.timezone_mode || 'country',
|
||||||
|
timezone_offset: accountData.timezone_offset || '',
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load settings');
|
setError(err.message || 'Failed to load settings');
|
||||||
@@ -144,6 +152,36 @@ export default function AccountSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadCountries = async () => {
|
||||||
|
try {
|
||||||
|
setCountriesLoading(true);
|
||||||
|
const loadedCountries = await fetchCountries();
|
||||||
|
setCountries(loadedCountries);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load countries:', err);
|
||||||
|
} finally {
|
||||||
|
setCountriesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getUserProfile();
|
||||||
|
const profile = response.user;
|
||||||
|
setProfileForm({
|
||||||
|
firstName: profile.first_name || '',
|
||||||
|
lastName: profile.last_name || '',
|
||||||
|
email: profile.email || '',
|
||||||
|
phone: profile.phone || '',
|
||||||
|
language: profile.language || 'en',
|
||||||
|
emailNotifications: profile.email_notifications ?? true,
|
||||||
|
marketingEmails: profile.marketing_emails ?? false,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load profile:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAccountSubmit = async (e: React.FormEvent) => {
|
const handleAccountSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +192,7 @@ export default function AccountSettingsPage() {
|
|||||||
setSuccess('Account settings updated successfully');
|
setSuccess('Account settings updated successfully');
|
||||||
toast.success('Account settings saved');
|
toast.success('Account settings saved');
|
||||||
await loadData();
|
await loadData();
|
||||||
|
await refreshUser();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to update account settings');
|
setError(err.message || 'Failed to update account settings');
|
||||||
toast.error(err.message || 'Failed to save settings');
|
toast.error(err.message || 'Failed to save settings');
|
||||||
@@ -166,8 +205,12 @@ export default function AccountSettingsPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
setSavingProfile(true);
|
setSavingProfile(true);
|
||||||
// Profile data is stored in auth user - refresh after save
|
await updateUserProfile({
|
||||||
// Note: Full profile API would go here when backend supports it
|
first_name: profileForm.firstName,
|
||||||
|
last_name: profileForm.lastName,
|
||||||
|
email: profileForm.email,
|
||||||
|
phone: profileForm.phone,
|
||||||
|
});
|
||||||
toast.success('Profile settings saved');
|
toast.success('Profile settings saved');
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -245,6 +288,25 @@ export default function AccountSettingsPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCountryChange = (value: string) => {
|
||||||
|
const derivedTimezone = getTimezoneForCountry(value);
|
||||||
|
setAccountForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
billing_country: value,
|
||||||
|
account_timezone: prev.timezone_mode === 'country' ? derivedTimezone : prev.account_timezone,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimezoneModeChange = (value: string) => {
|
||||||
|
const mode = value === 'manual' ? 'manual' : 'country';
|
||||||
|
setAccountForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
timezone_mode: mode,
|
||||||
|
account_timezone: mode === 'country' ? getTimezoneForCountry(prev.billing_country) : prev.account_timezone,
|
||||||
|
timezone_offset: mode === 'country' ? '' : prev.timezone_offset,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta title="Account Settings" description="Manage your account, profile, and team" />
|
<PageMeta title="Account Settings" description="Manage your account, profile, and team" />
|
||||||
@@ -334,15 +396,60 @@ export default function AccountSettingsPage() {
|
|||||||
value={accountForm.billing_postal_code}
|
value={accountForm.billing_postal_code}
|
||||||
onChange={handleAccountChange}
|
onChange={handleAccountChange}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<div>
|
||||||
type="text"
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
name="billing_country"
|
Country
|
||||||
label="Country"
|
</label>
|
||||||
value={accountForm.billing_country}
|
<Select
|
||||||
onChange={handleAccountChange}
|
key={`country-${accountForm.billing_country}`}
|
||||||
|
options={countries.map((country) => ({
|
||||||
|
value: country.code,
|
||||||
|
label: country.name,
|
||||||
|
}))}
|
||||||
|
placeholder={countriesLoading ? 'Loading countries...' : 'Select a country'}
|
||||||
|
defaultValue={accountForm.billing_country}
|
||||||
|
onChange={handleCountryChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Account Timezone (single source)
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
key={`tzmode-${accountForm.timezone_mode}`}
|
||||||
|
options={[
|
||||||
|
{ value: 'country', label: 'Derive from Country' },
|
||||||
|
{ value: 'manual', label: 'Manual UTC Offset' },
|
||||||
|
]}
|
||||||
|
defaultValue={accountForm.timezone_mode}
|
||||||
|
onChange={handleTimezoneModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{accountForm.timezone_mode === 'manual' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Manual UTC Offset
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
key={`tzmanual-${accountForm.account_timezone}`}
|
||||||
|
options={TIMEZONE_OPTIONS}
|
||||||
|
defaultValue={accountForm.account_timezone}
|
||||||
|
onChange={(value) => setAccountForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
account_timezone: value,
|
||||||
|
timezone_offset: TIMEZONE_OPTIONS.find(opt => opt.value === value)?.label?.split(' ')[0] || '',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Timezone derived from country: <span className="font-medium">{accountForm.account_timezone || 'UTC'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tax Information Card */}
|
{/* Tax Information Card */}
|
||||||
@@ -424,24 +531,6 @@ export default function AccountSettingsPage() {
|
|||||||
<Card className="p-6 border-l-4 border-l-purple-500">
|
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Preferences</h3>
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Preferences</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Your Timezone
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 'America/New_York', label: 'Eastern Time' },
|
|
||||||
{ value: 'America/Chicago', label: 'Central Time' },
|
|
||||||
{ value: 'America/Denver', label: 'Mountain Time' },
|
|
||||||
{ value: 'America/Los_Angeles', label: 'Pacific Time' },
|
|
||||||
{ value: 'UTC', label: 'UTC' },
|
|
||||||
{ value: 'Europe/London', label: 'London' },
|
|
||||||
{ value: 'Asia/Kolkata', label: 'India' },
|
|
||||||
]}
|
|
||||||
defaultValue={profileForm.timezone}
|
|
||||||
onChange={(value) => setProfileForm({ ...profileForm, timezone: value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Language
|
Language
|
||||||
|
|||||||
@@ -664,9 +664,9 @@ export async function inviteTeamMember(data: {
|
|||||||
role?: string;
|
role?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
member?: TeamMember;
|
user?: TeamMember;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI('/v1/account/team/invite/', {
|
return fetchAPI('/v1/account/team/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@@ -851,6 +851,9 @@ export interface AccountSettings {
|
|||||||
billing_country?: string;
|
billing_country?: string;
|
||||||
tax_id?: string;
|
tax_id?: string;
|
||||||
billing_email?: string;
|
billing_email?: string;
|
||||||
|
account_timezone?: string;
|
||||||
|
timezone_mode?: 'country' | 'manual';
|
||||||
|
timezone_offset?: string;
|
||||||
credit_balance: number;
|
credit_balance: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface User {
|
|||||||
credits: number;
|
credits: number;
|
||||||
status: string;
|
status: string;
|
||||||
plan?: any; // plan info is optional but required for access gating
|
plan?: any; // plan info is optional but required for access gating
|
||||||
|
account_timezone?: string;
|
||||||
|
timezone_mode?: 'country' | 'manual';
|
||||||
|
timezone_offset?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
frontend/src/utils/countries.ts
Normal file
21
frontend/src/utils/countries.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { COUNTRIES } from '../constants/timezones';
|
||||||
|
|
||||||
|
export interface CountryOption {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchCountries = async (): Promise<CountryOption[]> => {
|
||||||
|
try {
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/auth/countries/`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.countries || [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore - fallback below
|
||||||
|
}
|
||||||
|
|
||||||
|
return COUNTRIES.map(c => ({ code: c.value, name: c.label }));
|
||||||
|
};
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Global Date Formatting Utility
|
* Global Date Formatting Utility
|
||||||
* Formats dates to relative time strings (today, yesterday, etc.)
|
* Uses account-level timezone for all formatting
|
||||||
* Usage: formatRelativeDate('2025-01-15') or formatRelativeDate(new Date())
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getAccountTimezone } from './timezone';
|
||||||
|
|
||||||
|
const getDatePartsInTimezone = (date: Date, timeZone: string) => {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const year = parseInt(parts.find(p => p.type === 'year')?.value || '0', 10);
|
||||||
|
const month = parseInt(parts.find(p => p.type === 'month')?.value || '0', 10);
|
||||||
|
const day = parseInt(parts.find(p => p.type === 'day')?.value || '0', 10);
|
||||||
|
return { year, month, day };
|
||||||
|
};
|
||||||
|
|
||||||
export function formatRelativeDate(dateString: string | Date): string {
|
export function formatRelativeDate(dateString: string | Date): string {
|
||||||
if (!dateString) {
|
if (!dateString) {
|
||||||
return 'Today';
|
return 'Today';
|
||||||
@@ -17,10 +32,13 @@ export function formatRelativeDate(dateString: string | Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const timeZone = getAccountTimezone();
|
||||||
|
|
||||||
// Set time to midnight for both dates to compare days only
|
// Compare dates using account timezone
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const nowParts = getDatePartsInTimezone(now, timeZone);
|
||||||
const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
const dateParts = getDatePartsInTimezone(date, timeZone);
|
||||||
|
const today = new Date(nowParts.year, nowParts.month - 1, nowParts.day);
|
||||||
|
const dateOnly = new Date(dateParts.year, dateParts.month - 1, dateParts.day);
|
||||||
|
|
||||||
const diffTime = today.getTime() - dateOnly.getTime();
|
const diffTime = today.getTime() - dateOnly.getTime();
|
||||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
@@ -93,7 +111,10 @@ export function formatDate(
|
|||||||
|
|
||||||
if (isNaN(date.getTime())) return '-';
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', options);
|
return date.toLocaleDateString('en-US', {
|
||||||
|
...options,
|
||||||
|
timeZone: getAccountTimezone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,7 +137,8 @@ export function formatDateTime(
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true
|
hour12: true,
|
||||||
|
timeZone: getAccountTimezone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
frontend/src/utils/timezone.ts
Normal file
11
frontend/src/utils/timezone.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export const getAccountTimezone = (): string => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('auth-storage');
|
||||||
|
if (!raw) return 'UTC';
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const tz = parsed?.state?.user?.account?.account_timezone;
|
||||||
|
return tz || 'UTC';
|
||||||
|
} catch {
|
||||||
|
return 'UTC';
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user