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

@@ -19,6 +19,7 @@ import Input from '../form/input/InputField';
import Checkbox from '../form/input/Checkbox';
import Button from '../ui/button/Button';
import { useAuthStore } from '../../store/authStore';
import { fetchCountries } from '../../utils/countries';
interface Plan {
id: number;
@@ -102,23 +103,8 @@ export default function SignUpFormUnified({
const loadCountriesAndDetect = async () => {
setCountriesLoading(true);
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();
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' },
]);
}
const loadedCountries = await fetchCountries();
setCountries(loadedCountries);
// Try to detect user's country for default selection
// 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
// 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 {
setCountriesLoading(false);
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import { CalendarIcon, ClockIcon, ErrorIcon } from '../../icons';
import { getAccountTimezone } from '../../utils/timezone';
interface Content {
id: number;
@@ -72,7 +73,8 @@ const BulkScheduleModal: React.FC<BulkScheduleModalProps> = ({
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
hour12: true,
timeZone: getAccountTimezone(),
});
} catch (error) {
return '';
@@ -140,7 +142,7 @@ const BulkScheduleModal: React.FC<BulkScheduleModalProps> = ({
{selectedDate && selectedTime && (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
<p className="text-sm font-medium text-blue-900">
Preview: {formatPreviewDate()}
Preview: {formatPreviewDate()} ({getAccountTimezone()})
</p>
</div>
)}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import { CalendarIcon, InfoIcon, ExternalLinkIcon } from '../../icons';
import { getAccountTimezone } from '../../utils/timezone';
interface SchedulePreviewItem {
content_id: number;
@@ -42,6 +43,8 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
}) => {
if (!previewData) return null;
const accountTimezone = getAccountTimezone();
const formatDate = (isoString: string) => {
try {
const date = new Date(isoString);
@@ -51,7 +54,8 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
hour12: true,
timeZone: accountTimezone
});
} catch (error) {
return isoString;
@@ -68,7 +72,8 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
hour12: true,
timeZone: accountTimezone
});
} catch (error) {
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">
<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">
<li> Start time: {previewData.site_settings.base_time} ({previewData.site_settings.timezone})</li>
<li> Stagger: {previewData.site_settings.stagger_interval} minutes between each</li>
<li> First slot: {previewData.site_settings.base_time} ({accountTimezone})</li>
<li> First publish: {formatFullDate(firstPublish.scheduled_at)}</li>
<li> Last publish: {formatFullDate(lastPublish.scheduled_at)}</li>
</ul>
@@ -111,41 +115,27 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
{/* Schedule Preview */}
<div className="mb-4">
<p className="text-sm font-semibold text-gray-700 mb-3">Schedule Preview:</p>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className="max-h-80 overflow-y-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0">
<tr>
<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}
</td>
<td className="px-4 py-3 text-sm text-gray-900 truncate max-w-md">
{item.title}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{formatDate(item.scheduled_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-sm font-semibold text-gray-700 mb-3">Schedule Preview (first 5):</p>
<div className="border border-gray-200 rounded-lg bg-white">
<ul className="divide-y divide-gray-100">
{previewData.schedule_preview.slice(0, 5).map((item, index) => (
<li key={item.content_id} className="px-4 py-3 flex items-start gap-3">
<span className="text-xs font-semibold text-white bg-brand-500 rounded-full w-6 h-6 flex items-center justify-center">
{index + 1}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{item.title}</p>
<p className="text-xs text-gray-500 mt-0.5">{formatDate(item.scheduled_at)}</p>
</div>
</li>
))}
</ul>
</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>
{/* Info Box */}
@@ -159,7 +149,7 @@ const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
onClick={onChangeSettings}
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" />
</button>
</p>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import { CalendarIcon, ClockIcon } from '../../icons';
import { getAccountTimezone } from '../../utils/timezone';
interface Content {
id: number;
@@ -84,7 +85,8 @@ const ScheduleContentModal: React.FC<ScheduleContentModalProps> = ({
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
hour12: true,
timeZone: getAccountTimezone(),
});
} catch (error) {
return '';
@@ -154,7 +156,7 @@ const ScheduleContentModal: React.FC<ScheduleContentModalProps> = ({
{selectedDate && selectedTime && (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
<p className="text-sm font-medium text-blue-900">
Preview: {formatPreviewDate()}
Preview: {formatPreviewDate()} ({getAccountTimezone()})
</p>
</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 DebugSiteSelector from '../../components/common/DebugSiteSelector';
import Button from '../../components/ui/button/Button';
import { formatDate, formatDateTime } from '../../utils/date';
import { getAccountTimezone } from '../../utils/timezone';
import {
BoltIcon,
ListIcon,
@@ -81,7 +83,7 @@ const AutomationPage: React.FC = () => {
// Server time state - shows the actual time used for all operations
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
const siteId = activeSite?.id;
@@ -91,8 +93,7 @@ const AutomationPage: React.FC = () => {
const loadServerTime = async () => {
try {
const data = await automationService.getServerTime();
setServerTime(data.server_time_formatted);
setServerTimezone(data.timezone);
setServerTime(data.server_time);
} catch (error) {
console.error('Failed to load server time:', error);
}
@@ -111,12 +112,14 @@ const AutomationPage: React.FC = () => {
const getNextRunTime = (config: AutomationConfig): string => {
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);
// Create next run date
const nextRun = new Date();
nextRun.setUTCHours(schedHours, schedMinutes, 0, 0);
// Prefer server-provided next run time if available
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);
}
// If scheduled time has passed today, set to tomorrow
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(() => {
if (!siteId) return;
// Reset state when site changes
@@ -600,7 +614,7 @@ const AutomationPage: React.FC = () => {
</div>
<div className="h-4 w-px bg-white/25"></div>
<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>
{config.is_enabled && (
<>
@@ -613,7 +627,7 @@ const AutomationPage: React.FC = () => {
<div className="h-4 w-px bg-white/25"></div>
<div className="text-sm text-white inline-flex items-center gap-1">
<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>
@@ -632,7 +646,7 @@ const AutomationPage: React.FC = () => {
{!currentRun && totalPending === 0 && 'No Items Pending'}
</span>
<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>
</div>
</div>

View File

@@ -71,7 +71,7 @@ export default function ContentCalendar() {
// Schedule modal state
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [scheduleContent, setScheduleContent] = useState<Content | null>(null);
const [scheduledContentItem, setScheduledContentItem] = useState<Content | null>(null);
const [isRescheduling, setIsRescheduling] = useState(false);
// Derived state: Queue items (scheduled or publishing - exclude already published)
@@ -87,11 +87,6 @@ export default function ContentCalendar() {
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;
}, [allContent]);
@@ -122,11 +117,6 @@ export default function ContentCalendar() {
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;
}, [allContent]);
@@ -136,14 +126,9 @@ export default function ContentCalendar() {
const thirtyDaysAgo = 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) =>
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'
const publishedLast30Days = allContent.filter((c: Content) => {
@@ -182,10 +167,7 @@ export default function ContentCalendar() {
}, [allContent]);
const loadQueue = useCallback(async () => {
if (!activeSite?.id) {
console.log('[ContentCalendar] No active site selected, skipping load');
return;
}
if (!activeSite?.id) return;
try {
setLoading(true);
@@ -196,11 +178,6 @@ export default function ContentCalendar() {
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)
const scheduledResponse = await fetchAPI(
`/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()
);
// 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);
} catch (error: any) {
toast.error(`Failed to load content: ${error.message}`);
@@ -279,11 +228,8 @@ export default function ContentCalendar() {
// Load queue when active site changes
useEffect(() => {
if (activeSite?.id) {
console.log('[ContentCalendar] Site changed to:', activeSite.id, activeSite.name);
console.log('[ContentCalendar] Triggering loadQueue...');
loadQueue();
} else {
console.log('[ContentCalendar] No active site, clearing content');
setAllContent([]);
}
}, [activeSite?.id]); // Only depend on activeSite.id, loadQueue is stable
@@ -306,7 +252,7 @@ export default function ContentCalendar() {
// Open reschedule modal
const openRescheduleModal = useCallback((item: Content) => {
setScheduleContent(item);
setScheduledContentItem(item);
setIsRescheduling(true);
setShowScheduleModal(true);
}, []);
@@ -321,7 +267,7 @@ export default function ContentCalendar() {
loadQueue();
}
setShowScheduleModal(false);
setScheduleContent(null);
setScheduledContentItem(null);
setIsRescheduling(false);
}, [isRescheduling, handleRescheduleContent, toast, loadQueue]);
@@ -687,10 +633,14 @@ export default function ContentCalendar() {
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
scheduleContent(draggedItem.id, tomorrow.toISOString())
.then((updatedContent) => {
.then((response) => {
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
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}`));
@@ -1008,15 +958,15 @@ export default function ContentCalendar() {
</div>
{/* Schedule/Reschedule Modal */}
{showScheduleModal && scheduleContent && (
{showScheduleModal && scheduledContentItem && (
<ScheduleContentModal
isOpen={showScheduleModal}
onClose={() => {
setShowScheduleModal(false);
setScheduleContent(null);
setScheduledContentItem(null);
setIsRescheduling(false);
}}
content={scheduleContent}
content={scheduledContentItem}
onSchedule={handleScheduleFromModal}
mode={isRescheduling ? 'reschedule' : 'schedule'}
/>

View File

@@ -1,3 +1,4 @@
import { getAccountTimezone } from '../../utils/timezone';
/**
* AI & Automation Settings Component
* Per SETTINGS-CONSOLIDATION-PLAN.md
@@ -914,11 +915,11 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
</div>
<div>
<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>
<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>
</div>
</div>

View File

@@ -474,7 +474,7 @@ export default function Approved() {
// Open site settings in new tab
const handleOpenSiteSettings = useCallback(() => {
if (activeSite) {
window.open(`/sites/${activeSite.id}/settings?tab=publishing`, '_blank');
window.open(`/sites/${activeSite.id}/settings?tab=automation`, '_blank');
}
}, [activeSite]);
@@ -630,13 +630,16 @@ export default function Approved() {
if (action === 'bulk_publish_site') {
await handleBulkPublishToSite(ids);
} else if (action === 'bulk_schedule_manual') {
// Manual bulk scheduling (same time for all)
handleBulkScheduleManual(ids);
// Manual bulk scheduling (same time for all) via modal
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') {
// Schedule with site defaults
handleBulkScheduleWithDefaults(ids);
}
}, [handleBulkPublishToSite, handleBulkScheduleManual, handleBulkScheduleWithDefaults]);
}, [handleBulkPublishToSite, handleBulkScheduleWithDefaults, content]);
// Bulk status update handler
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 { useAuthStore } from '../../store/authStore';
import { usePageLoading } from '../../context/PageLoadingContext';
import { TIMEZONE_OPTIONS, getTimezoneForCountry } from '../../constants/timezones';
import { fetchCountries } from '../../utils/countries';
import {
getAccountSettings,
updateAccountSettings,
@@ -27,6 +29,7 @@ import {
inviteTeamMember,
removeTeamMember,
getUserProfile,
updateUserProfile,
changePassword,
type AccountSettings,
type TeamMember,
@@ -40,6 +43,8 @@ export default function AccountSettingsPage() {
const [savingProfile, setSavingProfile] = useState(false);
const [error, setError] = useState<string>('');
const [success, setSuccess] = useState<string>('');
const [countries, setCountries] = useState<Array<{ code: string; name: string }>>([]);
const [countriesLoading, setCountriesLoading] = useState(false);
// Account settings state
const [settings, setSettings] = useState<AccountSettings | null>(null);
@@ -53,6 +58,9 @@ export default function AccountSettingsPage() {
billing_country: '',
tax_id: '',
billing_email: '',
account_timezone: 'UTC',
timezone_mode: 'country' as 'country' | 'manual',
timezone_offset: '',
});
// Profile settings state
@@ -61,7 +69,6 @@ export default function AccountSettingsPage() {
lastName: '',
email: '',
phone: '',
timezone: 'America/New_York',
language: 'en',
emailNotifications: true,
marketingEmails: false,
@@ -87,28 +94,26 @@ export default function AccountSettingsPage() {
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(() => {
loadData();
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 () => {
try {
startLoading('Loading settings...');
@@ -124,6 +129,9 @@ export default function AccountSettingsPage() {
billing_country: accountData.billing_country || '',
tax_id: accountData.tax_id || '',
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) {
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) => {
e.preventDefault();
try {
@@ -154,6 +192,7 @@ export default function AccountSettingsPage() {
setSuccess('Account settings updated successfully');
toast.success('Account settings saved');
await loadData();
await refreshUser();
} catch (err: any) {
setError(err.message || 'Failed to update account settings');
toast.error(err.message || 'Failed to save settings');
@@ -166,8 +205,12 @@ export default function AccountSettingsPage() {
e.preventDefault();
try {
setSavingProfile(true);
// Profile data is stored in auth user - refresh after save
// Note: Full profile API would go here when backend supports it
await updateUserProfile({
first_name: profileForm.firstName,
last_name: profileForm.lastName,
email: profileForm.email,
phone: profileForm.phone,
});
toast.success('Profile settings saved');
await refreshUser();
} 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 (
<>
<PageMeta title="Account Settings" description="Manage your account, profile, and team" />
@@ -334,13 +396,58 @@ export default function AccountSettingsPage() {
value={accountForm.billing_postal_code}
onChange={handleAccountChange}
/>
<InputField
type="text"
name="billing_country"
label="Country"
value={accountForm.billing_country}
onChange={handleAccountChange}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Country
</label>
<Select
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 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>
@@ -424,24 +531,6 @@ export default function AccountSettingsPage() {
<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>
<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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Language

View File

@@ -664,9 +664,9 @@ export async function inviteTeamMember(data: {
role?: string;
}): Promise<{
message: string;
member?: TeamMember;
user?: TeamMember;
}> {
return fetchAPI('/v1/account/team/invite/', {
return fetchAPI('/v1/account/team/', {
method: 'POST',
body: JSON.stringify(data),
});
@@ -851,6 +851,9 @@ export interface AccountSettings {
billing_country?: string;
tax_id?: string;
billing_email?: string;
account_timezone?: string;
timezone_mode?: 'country' | 'manual';
timezone_offset?: string;
credit_balance: number;
created_at: string;
updated_at: string;

View File

@@ -27,6 +27,9 @@ interface User {
credits: number;
status: string;
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
* Formats dates to relative time strings (today, yesterday, etc.)
* Usage: formatRelativeDate('2025-01-15') or formatRelativeDate(new Date())
* Uses account-level timezone for all formatting
*/
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 {
if (!dateString) {
return 'Today';
@@ -17,10 +32,13 @@ export function formatRelativeDate(dateString: string | Date): string {
}
const now = new Date();
// Set time to midnight for both dates to compare days only
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const timeZone = getAccountTimezone();
// Compare dates using account timezone
const nowParts = getDatePartsInTimezone(now, timeZone);
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 diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
@@ -93,7 +111,10 @@ export function formatDate(
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',
hour: 'numeric',
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';
}
};