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:
parent
1d61904bc8
commit
df35d1b671
12 changed files with 133 additions and 51 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
14
resources/js/composables/usePageErrors.ts
Normal file
14
resources/js/composables/usePageErrors.ts
Normal 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>);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue