feat(posts): add validation message for past date selection in PickTimePopover

- Introduced a new translation key for future date selection prompts in English, Spanish, and Portuguese.
- Implemented logic to disable the confirm button and display a warning message when a past date is selected in the PickTimePopover component.
- Updated date formatting utility to handle UTC dates for HTML datetime-local inputs.
This commit is contained in:
Paulo Castellano 2026-05-19 16:26:40 -03:00
parent 1d61904bc8
commit df35d1b671
12 changed files with 133 additions and 51 deletions

View file

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

View file

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

View file

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

View file

@ -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 = () => {
</Select>
<span v-if="timezoneAbbr" class="ml-1 text-xs text-muted-foreground">{{ timezoneAbbr }}</span>
</div>
<p v-if="isPastDateTime" class="mt-2 text-xs font-semibold text-rose-700">
{{ $t('posts.edit.pick_time_past') }}
</p>
</div>
<div class="flex items-center justify-between gap-2 border-t p-3">
@ -126,7 +132,7 @@ const remove = () => {
{{ $t('posts.edit.unschedule') }}
</Button>
<Button v-else type="button" variant="ghost" size="sm" @click="cancel">{{ $t('posts.edit.cancel') }}</Button>
<Button type="button" size="sm" @click="confirm">{{ $t('posts.edit.pick_time') }}</Button>
<Button type="button" size="sm" :disabled="isPastDateTime" @click="confirm">{{ $t('posts.edit.pick_time') }}</Button>
</div>
</DialogContent>
</Dialog>

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
@ -16,6 +15,7 @@ import {
ComboboxTrigger,
} from '@/components/ui/combobox';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import type { PinterestBoard } from '@/types';
@ -79,11 +79,10 @@ const selectedBoard = computed<BoardOption | undefined>({
// (`platforms.0.meta.board_id`). Suffix match avoids threading the index
// through props. Cleared as soon as a board is picked locally so the user
// doesn't see a stale error after fixing the issue.
const page = usePage();
const errors = usePageErrors();
const boardError = computed<string | undefined>(() => {
if (props.meta?.board_id) return undefined;
const errors = (page.props.errors as Record<string, string> | undefined) ?? {};
return Object.entries(errors).find(([key]) => key.endsWith('.meta.board_id'))?.[1];
return Object.entries(errors.value).find(([key]) => key.endsWith('.meta.board_id'))?.[1];
});
</script>

View file

@ -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);
</script>
<template>
@ -86,49 +91,68 @@ const isPublished = computed(() => PUBLISHED_STATUSES.includes(props.post.status
</span>
</div>
<div v-if="!isReadOnly" class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="emit('delete')"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div v-if="!isReadOnly" class="flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="emit('delete')"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
<Button
type="button"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="emit('submit', hasPickedTime ? PostStatus.Scheduled : PostStatus.Publishing)"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<Button
type="button"
:disabled="isPostActionDisabled"
@click="emit('submit', hasPickedTime ? PostStatus.Scheduled : PostStatus.Publishing)"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<InputError :message="scheduledAtError" />
</div>
</template>
</header>

View file

@ -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<Record<string, number>>(() => {
const map: Record<string, number> = {};
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`];
};
</script>
<template>
@ -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)"

View file

@ -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<string, any>;
disabled?: boolean;
}
@ -49,6 +51,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
creatorInfo: null,
videoDurationSec: null,
contentTypeError: undefined,
disabled: false,
});
@ -232,6 +235,7 @@ watch(
{{ $t(variant.labelKey) }}
</button>
</div>
<InputError :message="contentTypeError" />
</div>
<!-- Creator identity -->

View file

@ -87,6 +87,10 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
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: {

View file

@ -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<string, string>` because the app only ever flashes
* single string messages (no nested error bags).
*/
export const usePageErrors = (): ComputedRef<Record<string, string>> => {
const page = usePage();
return computed(() => (page.props.errors ?? {}) as Record<string, string>);
};

View file

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

View file

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