account, schduels, timezone profile and many imporant updates

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-19 15:37:03 +00:00
parent 618ed8b8c6
commit e7219a2390
28 changed files with 919 additions and 358 deletions

View File

@@ -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': {

View File

@@ -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),
),
]

View File

@@ -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'),
),
]

View 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),
),
]

View File

@@ -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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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.'
}) })

View File

@@ -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()

View File

@@ -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),
]

View File

@@ -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

View File

@@ -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

View 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 %}

View File

@@ -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);
} }

View File

@@ -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>
)} )}

View File

@@ -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>

View File

@@ -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>
)} )}

View 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';
};

View File

@@ -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>

View File

@@ -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'}
/> />

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}; };
} }

View 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 }));
};

View File

@@ -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(),
}); });
} }

View 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';
}
};