diff --git a/app/Ai/Templates/AiContentTemplate.php b/app/Ai/Templates/AiContentTemplate.php index d49fb10c..033e7a4d 100644 --- a/app/Ai/Templates/AiContentTemplate.php +++ b/app/Ai/Templates/AiContentTemplate.php @@ -27,6 +27,13 @@ public function previewAsset(): string; /** Whether a social account must be selected for this template. */ public function needsAccount(): bool; + /** + * Whether this template's generated image honors the workspace brand + * palette (so the "Brand colors" choice is meaningful). Tweet-card styles + * are brand-colored by design and ignore the flag. + */ + public function appliesBrandVisuals(): bool; + /** * Content types (by value) this template can produce. Empty = all AI formats. * diff --git a/app/Ai/Templates/ImageCardTemplate.php b/app/Ai/Templates/ImageCardTemplate.php index ce2102fd..5fedbad5 100644 --- a/app/Ai/Templates/ImageCardTemplate.php +++ b/app/Ai/Templates/ImageCardTemplate.php @@ -44,6 +44,11 @@ public function needsAccount(): bool return $this->style()->needsAccount(); } + public function appliesBrandVisuals(): bool + { + return true; + } + public function supportedFormats(): array { return []; diff --git a/app/Ai/Templates/TweetCardImageTemplate.php b/app/Ai/Templates/TweetCardImageTemplate.php index 59dfe7e2..87291439 100644 --- a/app/Ai/Templates/TweetCardImageTemplate.php +++ b/app/Ai/Templates/TweetCardImageTemplate.php @@ -41,6 +41,11 @@ public function needsAccount(): bool return $this->style()->needsAccount(); } + public function appliesBrandVisuals(): bool + { + return false; + } + /** @return array */ public function supportedFormats(): array { diff --git a/app/Ai/Templates/TweetCardTemplate.php b/app/Ai/Templates/TweetCardTemplate.php index 5811f24d..9e7c73f0 100644 --- a/app/Ai/Templates/TweetCardTemplate.php +++ b/app/Ai/Templates/TweetCardTemplate.php @@ -41,6 +41,11 @@ public function needsAccount(): bool return $this->style()->needsAccount(); } + public function appliesBrandVisuals(): bool + { + return false; + } + /** @return array */ public function supportedFormats(): array { diff --git a/app/Http/Controllers/App/PostAiCreateController.php b/app/Http/Controllers/App/PostAiCreateController.php index 1d40db64..d9410758 100644 --- a/app/Http/Controllers/App/PostAiCreateController.php +++ b/app/Http/Controllers/App/PostAiCreateController.php @@ -52,6 +52,7 @@ public function start(StartPostCreationRequest $request): JsonResponse prompt: $request->string('prompt')->toString(), date: $request->input('date'), template: $request->input('template', 'image_card'), + applyBrandVisuals: $request->boolean('apply_brand_visuals', true), ); return response()->json([ diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index a573f8da..3b6e6ef7 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -153,6 +153,7 @@ public function create(Request $request): Response 'preview' => $t->previewAsset(), 'needs_account' => $t->needsAccount(), 'supported_formats' => $t->supportedFormats(), + 'applies_brand_visuals' => $t->appliesBrandVisuals(), ], $registry->all()); return Inertia::render('posts/Create', [ diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index 2b72365e..dcbe1333 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -37,6 +37,7 @@ public function rules(): array 'prompt' => AiPromptRules::wizardPromptRule(), 'date' => ['nullable', 'date_format:Y-m-d'], 'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)], + 'apply_brand_visuals' => ['sometimes', 'boolean'], ]; } diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 78bfdcad..f8c9eb14 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -43,6 +43,7 @@ public function __construct( public string $prompt, public ?string $date = null, public string $template = 'image_card', + public bool $applyBrandVisuals = true, ) { $this->onQueue('ai'); } @@ -64,6 +65,7 @@ public function handle(): void format: $this->format, imageCount: $this->imageCount, isCarousel: $isCarousel, + applyBrandVisuals: $this->applyBrandVisuals, ); $agent = new PostContentGenerator( diff --git a/lang/ar/posts.php b/lang/ar/posts.php index a85c56fc..f3e316bd 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'كم عدد الصور؟', 'media_none' => 'بلا', 'media_count_label' => 'عدد الصور', + 'brand_colors_label' => 'ألوان العلامة التجارية', + 'brand_colors_description' => 'طبّق لوحة ألوان علامتك التجارية على الصور المُنشأة. عطّله لترك الذكاء الاصطناعي يختار الألوان التي تناسب المحتوى.', 'prompt_title' => 'صِف منشورك', 'prompt_label' => 'ما موضوع هذا المنشور؟', 'prompt_placeholder' => 'مثال: الإعلان عن ميزة العرض الدائري الجديدة لدينا على Instagram', diff --git a/lang/de/posts.php b/lang/de/posts.php index 77c50c03..21bfbfae 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -608,6 +608,8 @@ 'media_optional_label' => 'Wie viele Bilder?', 'media_none' => 'Keine', 'media_count_label' => 'Anzahl der Bilder', + 'brand_colors_label' => 'Markenfarben', + 'brand_colors_description' => 'Wende die Markenpalette deines Workspace auf die generierten Bilder an. Deaktiviere die Option, damit die KI passende Farben zum Inhalt wählt.', 'prompt_title' => 'Beschreibe deinen Beitrag', 'prompt_label' => 'Worum geht es in diesem Beitrag?', 'prompt_placeholder' => 'z. B. Unser neues Karussell-Feature für Instagram ankündigen', diff --git a/lang/el/posts.php b/lang/el/posts.php index 3fb7a85c..a4d6dce2 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'Πόσες εικόνες;', 'media_none' => 'Καμία', 'media_count_label' => 'Αριθμός εικόνων', + 'brand_colors_label' => 'Χρώματα επωνυμίας', + 'brand_colors_description' => 'Εφάρμοσε την παλέτα της επωνυμίας σου στις εικόνες που δημιουργούνται. Απενεργοποίησέ το για να επιλέξει το AI χρώματα που ταιριάζουν στο περιεχόμενο.', 'prompt_title' => 'Περιγράψτε τη δημοσίευσή σας', 'prompt_label' => 'Ποιο είναι το θέμα αυτής της δημοσίευσης;', 'prompt_placeholder' => 'π.χ. Ανακοινώστε τη νέα μας λειτουργία carousel για το Instagram', diff --git a/lang/en/posts.php b/lang/en/posts.php index 35814545..b0a647e5 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'How many images?', 'media_none' => 'None', 'media_count_label' => 'Number of images', + 'brand_colors_label' => 'Brand colors', + 'brand_colors_description' => 'Apply your workspace brand palette to the generated images. Turn it off to let the AI pick colors that fit the content.', 'prompt_title' => 'Describe your post', 'prompt_label' => 'What is this post about?', 'prompt_placeholder' => 'e.g. Announce our new carousel feature for Instagram', diff --git a/lang/es/posts.php b/lang/es/posts.php index 7bd1e4bd..0570a629 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -607,6 +607,8 @@ 'media_optional_label' => '¿Cuántas imágenes?', 'media_none' => 'Ninguna', 'media_count_label' => 'Número de imágenes', + 'brand_colors_label' => 'Colores de marca', + 'brand_colors_description' => 'Aplica la paleta de tu marca a las imágenes generadas. Desactívalo para que la IA elija los colores que mejor encajen con el contenido.', 'prompt_title' => 'Describe tu post', 'prompt_label' => '¿De qué trata este post?', 'prompt_placeholder' => 'Ej. Anuncia nuestra nueva función de carrusel para Instagram', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 599f37b4..ae94b65a 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'Combien d\'images ?', 'media_none' => 'Aucune', 'media_count_label' => 'Nombre d\'images', + 'brand_colors_label' => 'Couleurs de la marque', + 'brand_colors_description' => 'Appliquez la palette de votre marque aux images générées. Désactivez pour laisser l’IA choisir des couleurs adaptées au contenu.', 'prompt_title' => 'Décrivez votre publication', 'prompt_label' => 'De quoi parle cette publication ?', 'prompt_placeholder' => 'par ex. Annoncer notre nouvelle fonctionnalité de carrousel pour Instagram', diff --git a/lang/it/posts.php b/lang/it/posts.php index dd525823..8e9d4670 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'Quante immagini?', 'media_none' => 'Nessuna', 'media_count_label' => 'Numero di immagini', + 'brand_colors_label' => 'Colori del brand', + 'brand_colors_description' => 'Applica la palette del tuo brand alle immagini generate. Disattivalo per lasciare che l’IA scelga i colori più adatti al contenuto.', 'prompt_title' => 'Descrivi il tuo post', 'prompt_label' => 'Di cosa parla questo post?', 'prompt_placeholder' => 'es. Annuncia la nostra nuova funzione carosello per Instagram', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index b1f850be..01423419 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => '画像は何枚にしますか?', 'media_none' => 'なし', 'media_count_label' => '画像の枚数', + 'brand_colors_label' => 'ブランドカラー', + 'brand_colors_description' => '生成される画像にワークスペースのブランドカラーを適用します。オフにすると、AI がコンテンツに合った色を選びます。', 'prompt_title' => '投稿を説明', 'prompt_label' => 'この投稿は何についてですか?', 'prompt_placeholder' => '例: Instagram 向けの新しいカルーセル機能を発表', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 7da6343e..cccdc7fb 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => '이미지는 몇 장인가요?', 'media_none' => '없음', 'media_count_label' => '이미지 수', + 'brand_colors_label' => '브랜드 색상', + 'brand_colors_description' => '생성되는 이미지에 워크스페이스 브랜드 색상을 적용합니다. 끄면 AI가 콘텐츠에 어울리는 색상을 선택합니다.', 'prompt_title' => '게시물을 설명하세요', 'prompt_label' => '이 게시물은 무엇에 관한 것인가요?', 'prompt_placeholder' => '예: Instagram용 새 캐러셀 기능 발표', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 4b37a488..ef6f2228 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'Hoeveel afbeeldingen?', 'media_none' => 'Geen', 'media_count_label' => 'Aantal afbeeldingen', + 'brand_colors_label' => 'Merkkleuren', + 'brand_colors_description' => 'Pas het merkkleurenpalet van je workspace toe op de gegenereerde afbeeldingen. Zet het uit om de AI kleuren te laten kiezen die bij de inhoud passen.', 'prompt_title' => 'Beschrijf je post', 'prompt_label' => 'Waar gaat deze post over?', 'prompt_placeholder' => 'bijv. Kondig onze nieuwe carrouselfunctie voor Instagram aan', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 8a9daddb..694d521a 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'Ile obrazów?', 'media_none' => 'Brak', 'media_count_label' => 'Liczba obrazów', + 'brand_colors_label' => 'Kolory marki', + 'brand_colors_description' => 'Zastosuj paletę marki Twojego obszaru roboczego do generowanych obrazów. Wyłącz, aby AI dobrała kolory pasujące do treści.', 'prompt_title' => 'Opisz swój post', 'prompt_label' => 'O czym jest ten post?', 'prompt_placeholder' => 'np. Ogłoś naszą nową funkcję karuzeli na Instagramie', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index e5eb157c..588a674e 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'Quantas imagens?', 'media_none' => 'Nenhuma', 'media_count_label' => 'Número de imagens', + 'brand_colors_label' => 'Cores da marca', + 'brand_colors_description' => 'Aplica a paleta da sua marca nas imagens geradas. Desative para deixar a IA escolher as cores que combinam com o conteúdo.', 'prompt_title' => 'Descreva seu post', 'prompt_label' => 'Sobre o que é este post?', 'prompt_placeholder' => 'Ex. Anunciar nossa nova função de carrossel para o Instagram', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 5e64fb0b..7c346dd7 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => 'Сколько изображений?', 'media_none' => 'Нет', 'media_count_label' => 'Количество изображений', + 'brand_colors_label' => 'Цвета бренда', + 'brand_colors_description' => 'Применяйте фирменную палитру вашего рабочего пространства к создаваемым изображениям. Отключите, чтобы ИИ подобрал цвета под содержание.', 'prompt_title' => 'Опишите свой пост', 'prompt_label' => 'О чём этот пост?', 'prompt_placeholder' => 'например, Анонсировать нашу новую функцию карусели для Instagram', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 418f7e65..89a217d2 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -608,6 +608,8 @@ 'media_optional_label' => 'Kaç görsel?', 'media_none' => 'Hiçbiri', 'media_count_label' => 'Görsel sayısı', + 'brand_colors_label' => 'Marka renkleri', + 'brand_colors_description' => 'Oluşturulan görsellere çalışma alanınızın marka paletini uygular. Yapay zekânın içeriğe uygun renkleri seçmesi için kapatın.', 'prompt_title' => 'Gönderinizi açıklayın', 'prompt_label' => 'Bu gönderi ne hakkında?', 'prompt_placeholder' => 'örn. Instagram için yeni karusel özelliğimizi duyur', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 90293186..066eba91 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -606,6 +606,8 @@ 'media_optional_label' => '要多少张图片?', 'media_none' => '无', 'media_count_label' => '图片数量', + 'brand_colors_label' => '品牌颜色', + 'brand_colors_description' => '将工作区的品牌配色应用到生成的图片。关闭后由 AI 选择适合内容的颜色。', 'prompt_title' => '描述你的帖子', 'prompt_label' => '这条帖子讲的是什么?', 'prompt_placeholder' => '例如 为 Instagram 宣布我们全新的轮播功能', diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 49b277d5..9c2dcfd8 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -13,6 +13,7 @@ import { start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCr import ContentStylePicker from '@/components/ai/ContentStylePicker.vue'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; import { loading as loadingRoute } from '@/routes/app/posts/ai'; @@ -33,6 +34,7 @@ interface AiTemplate { preview: string; needs_account: boolean; supported_formats: string[]; + applies_brand_visuals: boolean; } interface Props { @@ -63,6 +65,8 @@ const selectedAccountId = ref(null); const includeImages = ref(true); const imageCount = ref(2); const promptText = ref(''); +// true = images use the workspace brand palette; false = the AI picks colors freely. +const useBrandColors = ref(true); const PROMPT_MIN = 3; const PROMPT_MAX = 2000; @@ -75,7 +79,8 @@ const httpStart = useHttp<{ prompt: string; date: string | null; template: string; -}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card' }); + apply_brand_visuals: boolean; +}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card', apply_brand_visuals: true }); const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [ { value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] }, @@ -212,6 +217,7 @@ const startGeneration = async () => { httpStart.prompt = promptText.value.trim(); httpStart.date = props.date; httpStart.template = resolvedTemplate.value; + httpStart.apply_brand_visuals = useBrandColors.value; try { const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string }; @@ -354,6 +360,19 @@ const startGeneration = async () => { + +
+
+ +

{{ $t('posts.create.steps.brand_colors_description') }}

+
+ +
+
diff --git a/tests/Feature/Ai/PostAiCreateTest.php b/tests/Feature/Ai/PostAiCreateTest.php index ec296325..deb01d67 100644 --- a/tests/Feature/Ai/PostAiCreateTest.php +++ b/tests/Feature/Ai/PostAiCreateTest.php @@ -200,6 +200,33 @@ Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->date === '2026-06-15'); }); +test('start carries apply_brand_visuals=false when the user lets the AI decide colors', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), [ + 'prompt' => 'hello', + 'format' => 'x_post', + 'apply_brand_visuals' => false, + ]) + ->assertAccepted(); + + Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->applyBrandVisuals === false); +}); + +test('start defaults apply_brand_visuals to true when omitted', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), [ + 'prompt' => 'hello', + 'format' => 'x_post', + ]) + ->assertAccepted(); + + Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->applyBrandVisuals === true); +}); + test('start rejects invalid date format', function () { Bus::fake(); diff --git a/tests/Feature/Ai/Templates/TemplateContractTest.php b/tests/Feature/Ai/Templates/TemplateContractTest.php index 61066b2a..bb234449 100644 --- a/tests/Feature/Ai/Templates/TemplateContractTest.php +++ b/tests/Feature/Ai/Templates/TemplateContractTest.php @@ -4,6 +4,8 @@ use App\Ai\Templates\ImageCardTemplate; use App\Ai\Templates\TemplateContext; +use App\Ai\Templates\TweetCardImageTemplate; +use App\Ai\Templates\TweetCardTemplate; use App\Enums\Ai\ContentStyle; use App\Models\Workspace; use Illuminate\JsonSchema\JsonSchemaTypeFactory; @@ -16,10 +18,16 @@ expect($t->style())->toBe(ContentStyle::ImageCard) ->and($t->key())->toBe('image_card') ->and($t->needsAccount())->toBeFalse() + ->and($t->appliesBrandVisuals())->toBeTrue() ->and($t->promptView($context))->toBe('prompts.post_content.generator') ->and($t->supportedFormats())->toBe([]); }); +test('tweet-card templates do not apply brand visuals', function () { + expect((new TweetCardTemplate)->appliesBrandVisuals())->toBeFalse() + ->and((new TweetCardImageTemplate)->appliesBrandVisuals())->toBeFalse(); +}); + test('image card carousel context returns carousel schema shape', function () { $t = new ImageCardTemplate; $workspace = Workspace::factory()->create();