diff --git a/lang/en/posts.php b/lang/en/posts.php
index 5653d9c1..785c5736 100644
--- a/lang/en/posts.php
+++ b/lang/en/posts.php
@@ -240,6 +240,7 @@
'no_labels' => 'No labels created yet',
'schedule' => 'Schedule',
'pick_time' => 'Pick time',
+ 'pick_time_past' => 'Pick a future date and time.',
'post_now' => 'Post now',
'time' => 'Time',
'cancel' => 'Cancel',
diff --git a/lang/es/posts.php b/lang/es/posts.php
index 91a6d2c6..253c11e2 100644
--- a/lang/es/posts.php
+++ b/lang/es/posts.php
@@ -262,6 +262,7 @@
'organize' => 'Organizar',
'no_labels' => 'Todavía no hay etiquetas creadas',
'pick_time' => 'Elegir hora',
+ 'pick_time_past' => 'Elige una fecha y hora en el futuro.',
'post_now' => 'Publicar ahora',
'time' => 'Hora',
'cancel' => 'Cancelar',
diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php
index daaf75c7..f62548b4 100644
--- a/lang/pt-BR/posts.php
+++ b/lang/pt-BR/posts.php
@@ -262,6 +262,7 @@
'organize' => 'Organizar',
'no_labels' => 'Nenhuma etiqueta criada ainda',
'pick_time' => 'Escolher horário',
+ 'pick_time_past' => 'Escolha uma data e hora no futuro.',
'post_now' => 'Publicar agora',
'time' => 'Horário',
'cancel' => 'Cancelar',
diff --git a/resources/js/components/posts/PickTimePopover.vue b/resources/js/components/posts/PickTimePopover.vue
index f367238b..a4bf3357 100644
--- a/resources/js/components/posts/PickTimePopover.vue
+++ b/resources/js/components/posts/PickTimePopover.vue
@@ -65,11 +65,14 @@ const buildDateTime = (): string => {
return `${dateStr}T${selectedHour.value}:${selectedMinute.value}:00`;
};
+const isPastDateTime = computed(() => dayjs(buildDateTime()).isBefore(dayjs()));
+
const cancel = () => {
open.value = false;
};
const confirm = () => {
+ if (isPastDateTime.value) return;
const value = buildDateTime();
emit('update:modelValue', value);
emit('confirm', value);
@@ -112,6 +115,9 @@ const remove = () => {
{{ timezoneAbbr }}
+
+ {{ $t('posts.edit.pick_time_past') }}
+
@@ -126,7 +132,7 @@ const remove = () => {
{{ $t('posts.edit.unschedule') }}
-
+
diff --git a/resources/js/components/posts/editor/PinterestSettings.vue b/resources/js/components/posts/editor/PinterestSettings.vue
index 4e294e03..3ec207bf 100644
--- a/resources/js/components/posts/editor/PinterestSettings.vue
+++ b/resources/js/components/posts/editor/PinterestSettings.vue
@@ -1,5 +1,4 @@
diff --git a/resources/js/components/posts/editor/PostEditorHeader.vue b/resources/js/components/posts/editor/PostEditorHeader.vue
index 82c3b3bb..950c37fe 100644
--- a/resources/js/components/posts/editor/PostEditorHeader.vue
+++ b/resources/js/components/posts/editor/PostEditorHeader.vue
@@ -2,9 +2,11 @@
import { IconCalendar, IconCircleCheck, IconLoader2, IconTrash } from '@tabler/icons-vue';
import { computed } from 'vue';
+import InputError from '@/components/InputError.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { usePageErrors } from '@/composables/usePageErrors';
import { PostStatus } from '@/types/post';
interface Props {
@@ -34,6 +36,9 @@ const PUBLISHED_STATUSES: readonly string[] = [PostStatus.Published, PostStatus.
const isReadOnly = computed(() => READONLY_STATUSES.includes(props.post.status));
const isScheduled = computed(() => props.post.status === PostStatus.Scheduled);
const isPublished = computed(() => PUBLISHED_STATUSES.includes(props.post.status));
+
+const errors = usePageErrors();
+const scheduledAtError = computed(() => errors.value.scheduled_at);
@@ -86,49 +91,68 @@ const isPublished = computed(() => PUBLISHED_STATUSES.includes(props.post.status
-
-
-
-
-
-
- {{ $t('posts.edit.delete') }}
-
-
+
+
+
+
+
+
+
+ {{ $t('posts.edit.delete') }}
+
-
-
-
+
+
+
+
+
+
+
+
+
+ {{ postActionTooltip }}
+
+
-
+
+
+
+
+
+
+
+ {{ postActionTooltip }}
+
+
+
+
+
diff --git a/resources/js/components/posts/editor/ScheduleTab.vue b/resources/js/components/posts/editor/ScheduleTab.vue
index b106c719..15a8a597 100644
--- a/resources/js/components/posts/editor/ScheduleTab.vue
+++ b/resources/js/components/posts/editor/ScheduleTab.vue
@@ -11,6 +11,7 @@ import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { Platform } from '@/enums/platform';
import type { PinterestBoard } from '@/types';
@@ -152,6 +153,24 @@ const getPlatformDisplayName = (pp: PostPlatform): string =>
const getPlatformAvatar = (pp: PostPlatform): string | null =>
pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;
+// Map pp.id → submit-array index, matching what Edit.vue sends as `platforms[i]`.
+// Backend validation errors are keyed `platforms.{i}.content_type`, so we use this
+// to surface the right error under each platform's variant picker.
+const errors = usePageErrors();
+const submitIndexByPpId = computed
>(() => {
+ const map: Record = {};
+ props.postPlatforms
+ .filter((pp) => props.selectedPlatformIds.includes(pp.id))
+ .forEach((pp, index) => { map[pp.id] = index; });
+ return map;
+});
+
+const contentTypeErrorFor = (pp: PostPlatform): string | undefined => {
+ const index = submitIndexByPpId.value[pp.id];
+ if (index === undefined) return undefined;
+ return errors.value[`platforms.${index}.content_type`];
+};
+
@@ -274,6 +293,7 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
:creator-info="getCreatorInfo(pp)"
:video-duration-sec="videoDurationSec"
:content-type="platformContentTypes[pp.id] ?? ''"
+ :content-type-error="contentTypeErrorFor(pp)"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
diff --git a/resources/js/components/posts/editor/TikTokSettings.vue b/resources/js/components/posts/editor/TikTokSettings.vue
index 94018032..3b53d1c2 100644
--- a/resources/js/components/posts/editor/TikTokSettings.vue
+++ b/resources/js/components/posts/editor/TikTokSettings.vue
@@ -4,6 +4,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
+import InputError from '@/components/InputError.vue';
import { Avatar } from '@/components/ui/avatar';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
@@ -42,6 +43,7 @@ interface Props {
creatorInfo?: CreatorInfo | null;
videoDurationSec?: number | null;
contentType: string;
+ contentTypeError?: string;
meta: Record;
disabled?: boolean;
}
@@ -49,6 +51,7 @@ interface Props {
const props = withDefaults(defineProps(), {
creatorInfo: null,
videoDurationSec: null,
+ contentTypeError: undefined,
disabled: false,
});
@@ -232,6 +235,7 @@ watch(
{{ $t(variant.labelKey) }}
+
diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts
index 5927fba5..46ff62c3 100644
--- a/resources/js/composables/useMediaRules.ts
+++ b/resources/js/composables/useMediaRules.ts
@@ -87,6 +87,10 @@ const CONTENT_TYPE_RULES: Record = {
acceptsGif: false,
// maxVideoDurationSec is enforced dynamically via creator_info
},
+ tiktok_photo: {
+ maxFiles: 35, minFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true,
+ acceptsGif: false,
+ },
// YouTube
youtube_short: {
diff --git a/resources/js/composables/usePageErrors.ts b/resources/js/composables/usePageErrors.ts
new file mode 100644
index 00000000..79eb6170
--- /dev/null
+++ b/resources/js/composables/usePageErrors.ts
@@ -0,0 +1,14 @@
+import { usePage } from '@inertiajs/vue3';
+import { computed, type ComputedRef } from 'vue';
+
+/**
+ * Reactive access to Inertia's page-level validation errors, keyed by field
+ * path (e.g. `scheduled_at`, `platforms.0.content_type`). Returns an empty
+ * object when no errors are present so consumers can index without null
+ * checks. Cast to `Record` because the app only ever flashes
+ * single string messages (no nested error bags).
+ */
+export const usePageErrors = (): ComputedRef> => {
+ const page = usePage();
+ return computed(() => (page.props.errors ?? {}) as Record);
+};
diff --git a/resources/js/date.ts b/resources/js/date.ts
index d2fad2c2..c087a29f 100644
--- a/resources/js/date.ts
+++ b/resources/js/date.ts
@@ -165,6 +165,17 @@ export default {
return dayjs(date).format('YYYY-MM-DD');
},
+ /**
+ * Converte uma data UTC para o formato esperado por inputs HTML `datetime-local`
+ * (YYYY-MM-DDTHH:mm:00) no timezone do usuário.
+ * @param date - Data em UTC (ISO string) ou nulo
+ * @returns String no formato YYYY-MM-DDTHH:mm:00 ou string vazia quando não houver data
+ */
+ formatUtcForDateTimeLocalInput(date: string | null | undefined): string {
+ if (!date) return '';
+ return dayjs.utc(date).tz(getUserTimezone()).format('YYYY-MM-DDTHH:mm:00');
+ },
+
/**
* Formata minutos para formato legível
* @param minutes - Número de minutos
diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue
index 7ed4f92c..d8662d08 100644
--- a/resources/js/pages/posts/Edit.vue
+++ b/resources/js/pages/posts/Edit.vue
@@ -16,6 +16,7 @@ import {
getMediaIncompatibilityReason,
usePostCompliance,
} from '@/composables/usePostCompliance';
+import date from '@/date';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
@@ -156,11 +157,7 @@ const {
});
// Schedule
-const getLocalSchedule = () => {
- if (!post.value.scheduled_at) return '';
- return dayjs.utc(post.value.scheduled_at).local().format('YYYY-MM-DDTHH:mm:00');
-};
-const scheduledDateTime = ref(getLocalSchedule());
+const scheduledDateTime = ref(date.formatUtcForDateTimeLocalInput(post.value.scheduled_at));
const hasPickedTime = ref(post.value.status === PostStatus.Scheduled && !! post.value.scheduled_at);
const pickTimeLabel = computed(() => {