refactor(pinterest): reuse InputError, extract PinterestBoard type

- PinterestSettings: replace inline error markup with the shared
  InputError component (matches the rest of the form patterns)
- Extract PinterestBoard type to @/types and use it across
  PinterestSettings, ScheduleTab, PostEditorSidebar, Edit — drops
  the duplicated inline {id, name} shape from 4 call sites
- Block publish/schedule when Pinterest is selected but no board is
  picked (mirrors tiktokComplianceValid). Tooltip surfaces the same
  i18n key the backend uses, so frontend + backend speak the same
  language
This commit is contained in:
Paulo Castellano 2026-05-15 12:04:01 -03:00
parent 44d891ef08
commit 1f5a16ef2c
5 changed files with 77 additions and 39 deletions

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue';
import { Avatar } from '@/components/ui/avatar';
import {
Combobox,
@ -16,6 +18,7 @@ import {
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import type { PinterestBoard } from '@/types';
interface SocialAccount {
id: string;
@ -34,7 +37,7 @@ interface Props {
socialAccount: SocialAccount | null;
contentType: string;
media: MediaItem[];
boards: Array<{ id: string; name: string }>;
boards: PinterestBoard[];
meta: Record<string, any>;
disabled?: boolean;
}
@ -71,6 +74,17 @@ const selectedBoard = computed<BoardOption | undefined>({
get: () => boardOptions.value.find((b) => b.value === props.meta?.board_id),
set: (board) => emit('update:meta', { ...props.meta, board_id: board?.value ?? null }),
});
// Surface the backend validation error keyed by platform index
// (`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 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];
});
</script>
<template>
@ -135,40 +149,43 @@ const selectedBoard = computed<BoardOption | undefined>({
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t('posts.form.pinterest.no_boards') }}
</p>
<Combobox
v-else
v-model="selectedBoard"
:display-value="(b: any) => b?.label ?? ''"
:disabled="disabled"
>
<ComboboxAnchor class="w-full">
<ComboboxTrigger as-child>
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border-2 border-foreground/30 bg-card px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground disabled:cursor-not-allowed disabled:opacity-50"
:disabled="disabled"
>
<span :class="selectedBoard ? 'text-foreground' : 'text-foreground/50'">
{{ selectedBoard ? selectedBoard.label : $t('posts.form.pinterest.select_board') }}
</span>
<IconChevronDown class="size-4 shrink-0 text-foreground/60" />
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxInput :placeholder="$t('posts.form.pinterest.search_board')" />
<ComboboxEmpty>{{ $t('posts.form.pinterest.no_board_found') }}</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem
v-for="board in boardOptions"
:key="board.value"
:value="board"
>
{{ board.label }}
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<template v-else>
<Combobox
v-model="selectedBoard"
:display-value="(b: BoardOption | undefined) => b?.label ?? ''"
:disabled="disabled"
>
<ComboboxAnchor class="w-full">
<ComboboxTrigger as-child>
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border-2 bg-card px-3 py-2 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="boardError ? 'border-rose-500' : 'border-foreground/30 hover:border-foreground'"
:disabled="disabled"
>
<span :class="selectedBoard ? 'text-foreground' : 'text-foreground/50'">
{{ selectedBoard ? selectedBoard.label : $t('posts.form.pinterest.select_board') }}
</span>
<IconChevronDown class="size-4 shrink-0 text-foreground/60" />
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxInput :placeholder="$t('posts.form.pinterest.search_board')" />
<ComboboxEmpty>{{ $t('posts.form.pinterest.no_board_found') }}</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem
v-for="board in boardOptions"
:key="board.value"
:value="board"
>
{{ board.label }}
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<InputError :message="boardError" />
</template>
</div>
<p

View file

@ -5,6 +5,7 @@ import CommentsTab from '@/components/posts/editor/CommentsTab.vue';
import PreviewTab from '@/components/posts/editor/PreviewTab.vue';
import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { PinterestBoard } from '@/types';
interface MediaItem {
id: string;
@ -71,7 +72,7 @@ const props = defineProps<{
labels: { id: string; name: string; color: string }[];
selectedLabelIds: string[];
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
pinterestBoards?: Record<string, Array<{ id: string; name: string }>> | null;
pinterestBoards?: Record<string, PinterestBoard[]> | null;
isReadOnly: boolean;
authUserId: string;
initialHighlightCommentId: string | null;

View file

@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { Platform } from '@/enums/platform';
import type { PinterestBoard } from '@/types';
interface SocialAccount {
id: string;
@ -86,7 +87,7 @@ const props = defineProps<{
platformContentTypes: Record<string, string>;
platformIssues?: Record<string, string>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
pinterestBoards?: Record<string, Array<{ id: string; name: string }>> | null;
pinterestBoards?: Record<string, PinterestBoard[]> | null;
media?: MediaItem[];
}>();
@ -135,7 +136,7 @@ const getPublishConfig = (pp: PostPlatform): Record<string, any> | null =>
const getCreatorInfo = (pp: PostPlatform): TikTokCreatorInfo | null =>
pp.social_account_id ? props.tiktokCreatorInfos?.[pp.social_account_id] ?? null : null;
const getBoards = (pp: PostPlatform): Array<{ id: string; name: string }> =>
const getBoards = (pp: PostPlatform): PinterestBoard[] =>
pp.social_account_id ? props.pinterestBoards?.[pp.social_account_id] ?? [] : [];
const videoDurationSec = computed(() => {

View file

@ -19,6 +19,7 @@ import debounce from '@/debounce';
import { Platform } from '@/enums/platform';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts';
import type { PinterestBoard } from '@/types';
interface MediaItem {
id: string;
@ -88,7 +89,7 @@ const props = defineProps<{
post: Post;
socialAccounts: SocialAccount[];
platformConfigs: Record<string, any>;
pinterestBoards: Record<string, Array<{ id: string; name: string }>>;
pinterestBoards: Record<string, PinterestBoard[]>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
labels: { id: string; name: string; color: string }[];
signatures: { id: string; name: string; content: string }[];
@ -276,6 +277,14 @@ const tiktokComplianceValid = computed(() => {
});
});
// Pinterest needs a board_id selected to publish (the API rejects without it).
const pinterestComplianceValid = computed(() => {
const pinterestPlatforms = post.value.post_platforms.filter(
(pp) => pp.platform === Platform.Pinterest && selectedPlatformIds.value.includes(pp.id),
);
return pinterestPlatforms.every((pp) => Boolean(platformMeta.value[pp.id]?.board_id));
});
const contentLengthOverflows = computed(() => {
const len = content.value.length;
return platformLimits.value
@ -286,6 +295,7 @@ const contentLengthOverflows = computed(() => {
const canSchedule = computed(
() => mediaCompliancePerPlatformValid.value
&& tiktokComplianceValid.value
&& pinterestComplianceValid.value
&& contentLengthOverflows.value.length === 0,
);
@ -321,6 +331,10 @@ const postActionTooltip = computed(() => {
return trans('posts.form.tiktok.compliance_incomplete');
}
if (!pinterestComplianceValid.value) {
return trans('posts.form.pinterest.board_required');
}
return trans('posts.edit.compliance_incomplete');
});

View file

@ -90,3 +90,8 @@ export type BreadcrumbItem = {
href?: string;
};
export interface PinterestBoard {
id: string;
name: string;
}