Files
geo/apps/admin-web/src/views/BrandOnboardingView.vue
T
root 41f5623791
Frontend CI / Frontend (push) Successful in 4m25s
Backend CI / Backend (push) Successful in 15m23s
feat: require brand description in create and update operations, add validation messages
2026-06-09 14:09:05 +08:00

1124 lines
28 KiB
Vue

<script setup lang="ts">
import {
ArrowRightOutlined,
CheckCircleOutlined,
GlobalOutlined,
PlusOutlined,
ShopOutlined,
TagsOutlined,
} from '@ant-design/icons-vue'
import { ApiClientError } from '@geo/http-client'
import type { Brand, BrandLibrarySummary, BrandRequest, QuestionCandidate } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import AiWaitingModal from '@/components/AiWaitingModal.vue'
import { brandsApi } from '@/lib/api'
import {
formatError,
getQuestionMaterializeErrorMessage,
isDuplicateQuestionSkipReason,
} from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const DEFAULT_SELECTED_QUESTION_COUNT = 5
interface CandidateRow extends QuestionCandidate {
id: string
selected: boolean
}
const router = useRouter()
const queryClient = useQueryClient()
const companyStore = useCompanyStore()
const { t } = useI18n()
const brandModalOpen = ref(false)
const questionModalOpen = ref(false)
const createdBrand = ref<Brand | null>(null)
const candidateRows = ref<CandidateRow[]>([])
const questionNotice = ref('')
const aiWaitingOpen = ref(false)
const aiProgress = ref(12)
const aiStageIndex = ref(0)
let aiProgressTimer: number | null = null
let aiCloseTimer: number | null = null
const brandForm = reactive({
name: '',
website: '',
description: '',
})
const brandLibrarySummaryQuery = useQuery({
queryKey: ['brands', 'library-summary'],
queryFn: () => brandsApi.getLibrarySummary(),
})
const brandLibrarySummary = computed<BrandLibrarySummary | null>(
() => brandLibrarySummaryQuery.data.value ?? null,
)
const brandLimitReached = computed(() => {
if (!brandLibrarySummary.value) {
return false
}
return brandLibrarySummary.value.remaining_brands <= 0
})
const selectableQuestionLimit = computed(() => {
const summary = brandLibrarySummary.value
if (!summary) {
return Number.POSITIVE_INFINITY
}
if (typeof summary.remaining_questions === 'number') {
return Math.max(0, summary.remaining_questions)
}
if (typeof summary.max_questions === 'number' && typeof summary.used_questions === 'number') {
return Math.max(0, summary.max_questions - summary.used_questions)
}
if (typeof summary.max_questions === 'number') {
return Math.max(0, summary.max_questions)
}
return Number.POSITIVE_INFINITY
})
const selectableQuestionLimitText = computed(() => {
if (Number.isFinite(selectableQuestionLimit.value)) {
return String(selectableQuestionLimit.value)
}
return '--'
})
const selectedCandidates = computed(() => candidateRows.value.filter((item) => item.selected))
const hasExistingBrands = computed(() => companyStore.brands.length > 0)
const createBrandMutation = useMutation({
mutationFn: (payload: BrandRequest) => brandsApi.create(payload),
})
const questionGenerationMutation = useMutation({
mutationFn: (payload: { brandId: number; seedTopic: string }) =>
brandsApi.distillQuestions(payload.brandId, {
seed_topic: payload.seedTopic,
}),
})
const materializeQuestionsMutation = useMutation({
mutationFn: (payload: { brandId: number; questions: string[] }) =>
brandsApi.materializeQuestions(payload.brandId, {
source: 'ai_distill',
questions: payload.questions.map((text) => ({ text })),
}),
})
const aiStages = computed(() => [
t('onboardingBrand.ai.stages.fetch'),
t('onboardingBrand.ai.stages.generate'),
t('onboardingBrand.ai.stages.refine'),
])
function openBrandModal(): void {
if (brandLimitReached.value) {
message.warning(
t('brands.messages.brandLimitReached', {
limit: brandLibrarySummary.value?.max_brands ?? 0,
}),
)
return
}
brandForm.name = ''
brandForm.website = ''
brandForm.description = ''
brandModalOpen.value = true
}
function clearAiTimers(): void {
if (aiProgressTimer !== null) {
window.clearInterval(aiProgressTimer)
aiProgressTimer = null
}
if (aiCloseTimer !== null) {
window.clearTimeout(aiCloseTimer)
aiCloseTimer = null
}
}
function beginAiTask(): void {
clearAiTimers()
aiWaitingOpen.value = true
aiProgress.value = 12
aiStageIndex.value = 0
aiProgressTimer = window.setInterval(() => {
const nextProgress = Math.min(92, aiProgress.value + 8)
aiProgress.value = nextProgress
aiStageIndex.value = nextProgress >= 66 ? 2 : nextProgress >= 38 ? 1 : 0
}, 700)
}
function finishAiTask(): void {
clearAiTimers()
aiProgress.value = 100
aiStageIndex.value = aiStages.value.length - 1
aiCloseTimer = window.setTimeout(() => {
aiWaitingOpen.value = false
aiCloseTimer = null
}, 180)
}
onBeforeUnmount(clearAiTimers)
function buildSeedTopic(brand: Brand): string {
return [
brand.name,
brand.website ?? brandForm.website.trim(),
brand.description ?? brandForm.description.trim(),
]
.filter(Boolean)
.join(' ')
}
function normalizeQuestionText(value: string): string {
return value.trim().replace(/\s+/g, '')
}
function normalizeQuestionKey(value: string): string {
return normalizeQuestionText(value)
.replace(/[\p{P}]/gu, '')
.toLowerCase()
}
function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
const rows: CandidateRow[] = []
const seen = new Set<string>()
for (const candidate of candidates) {
const text = normalizeQuestionText(candidate.text)
const key = normalizeQuestionKey(text)
if (!text || !key || seen.has(key)) {
continue
}
seen.add(key)
rows.push({
...candidate,
text,
id: `${Date.now()}-${rows.length}-${key}`,
selected: false,
})
}
return rows
}
function applyDefaultSelection(rows: CandidateRow[]): void {
const limit = Number.isFinite(selectableQuestionLimit.value)
? Math.min(DEFAULT_SELECTED_QUESTION_COUNT, selectableQuestionLimit.value)
: DEFAULT_SELECTED_QUESTION_COUNT
if (limit <= 0) {
return
}
let selected = 0
const preferredRows = rows.filter((row) => !row.suggest_skip && !row.too_short && !row.duplicate)
const orderedRows = [...preferredRows, ...rows.filter((row) => !preferredRows.includes(row))]
for (const row of orderedRows) {
if (selected >= limit) {
break
}
row.selected = true
selected += 1
}
}
async function refreshAppData(): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['brands'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['tracking'] }),
queryClient.invalidateQueries({ queryKey: ['schedules'] }),
queryClient.invalidateQueries({ queryKey: ['instantTasks'] }),
])
}
async function enterWorkspace(brand: Brand | null = createdBrand.value): Promise<void> {
if (brand) {
companyStore.setCurrentBrand(brand.id)
}
await refreshAppData()
if (brand) {
await companyStore.refreshBrands()
companyStore.setCurrentBrand(brand.id)
}
await router.replace({ name: 'workspace' })
}
async function enterExistingBrand(brand: Brand): Promise<void> {
await enterWorkspace(brand)
}
function toggleCandidate(row: CandidateRow): void {
if (row.selected) {
row.selected = false
return
}
if (
Number.isFinite(selectableQuestionLimit.value) &&
selectedCandidates.value.length >= selectableQuestionLimit.value
) {
message.warning(
t('onboardingBrand.questions.selectionLimit', {
limit: selectableQuestionLimitText.value,
}),
)
return
}
row.selected = true
}
async function submitBrand(): Promise<void> {
const name = brandForm.name.trim()
const website = brandForm.website.trim()
const description = brandForm.description.trim()
if (!name) {
message.warning(t('onboardingBrand.messages.nameRequired'))
return
}
if (!description) {
message.warning(t('onboardingBrand.messages.descriptionRequired'))
return
}
if (brandLimitReached.value) {
message.warning(
t('brands.messages.brandLimitReached', {
limit: brandLibrarySummary.value?.max_brands ?? 0,
}),
)
return
}
try {
const brand = await createBrandMutation.mutateAsync({
name,
website: website || null,
description,
})
createdBrand.value = brand
brandModalOpen.value = false
message.success(t('brands.messages.createBrand'))
await queryClient.invalidateQueries({ queryKey: ['brands'] })
await companyStore.refreshBrands()
companyStore.setCurrentBrand(brand.id)
beginAiTask()
try {
const result = await questionGenerationMutation.mutateAsync({
brandId: brand.id,
seedTopic: buildSeedTopic(brand),
})
const rows = toCandidateRows(result.candidates)
applyDefaultSelection(rows)
candidateRows.value = rows
questionNotice.value = rows.length ? '' : t('onboardingBrand.questions.empty')
if (result.ai_points_charged) {
message.success(
t('onboardingBrand.messages.aiCharged', {
count: result.ai_points_charged,
}),
)
}
} catch (error) {
candidateRows.value = []
questionNotice.value = formatError(error)
} finally {
finishAiTask()
questionModalOpen.value = true
}
} catch (error) {
if (error instanceof ApiClientError && error.message === 'brand_limit_reached') {
message.warning(
t('brands.messages.brandLimitReached', {
limit: brandLibrarySummary.value?.max_brands ?? 0,
}),
)
return
}
message.error(formatError(error))
}
}
async function saveSelectedQuestions(): Promise<void> {
const brand = createdBrand.value
if (!brand) {
return
}
if (!selectedCandidates.value.length) {
message.warning(t('onboardingBrand.messages.questionRequired'))
return
}
try {
const result = await materializeQuestionsMutation.mutateAsync({
brandId: brand.id,
questions: selectedCandidates.value.map((item) => item.text.trim()).filter(Boolean),
})
if (result.created_questions <= 0) {
const skippedAllDuplicates =
result.skipped_questions.length > 0 &&
result.skipped_questions.every((item) => isDuplicateQuestionSkipReason(item.reason))
message.warning(
skippedAllDuplicates
? t('brands.questions.errors.duplicateExisting')
: t('brands.questions.errors.noValidQuestions'),
)
return
}
message.success(
t('brands.questions.messages.partialSaved', {
created: result.created_questions,
skipped: result.skipped_questions.length,
}),
)
questionModalOpen.value = false
await enterWorkspace(brand)
} catch (error) {
const materializeMessage = getQuestionMaterializeErrorMessage(error, {
duplicate: t('brands.questions.errors.duplicateExisting'),
noValid: t('brands.questions.errors.noValidQuestions'),
})
if (materializeMessage) {
message.warning(materializeMessage)
return
}
message.error(formatError(error))
}
}
</script>
<template>
<div class="brand-onboarding">
<div class="glow-orb glow-orb--1"></div>
<div class="glow-orb glow-orb--2"></div>
<main class="onboarding-panel">
<section class="onboarding-panel__intro">
<div class="brand-mark">
<ShopOutlined />
</div>
<h1>{{ t('onboardingBrand.title') }}</h1>
<p class="intro-copy">{{ t('onboardingBrand.subtitle') }}</p>
</section>
<section class="brand-entry">
<div v-if="companyStore.loading && !companyStore.initialized" class="brand-entry__loading">
<a-skeleton active :paragraph="{ rows: 4 }" />
</div>
<template v-else>
<div v-if="hasExistingBrands" class="brand-entry__grid">
<button
v-for="brand in companyStore.brands"
:key="brand.id"
type="button"
class="brand-choice"
@click="enterExistingBrand(brand)"
>
<span class="brand-choice__icon"><TagsOutlined /></span>
<strong>{{ brand.name }}</strong>
<span>{{ brand.website || t('onboardingBrand.noWebsite') }}</span>
<ArrowRightOutlined class="brand-choice__arrow" />
</button>
<button type="button" class="brand-choice brand-choice--create" @click="openBrandModal">
<span class="brand-choice__icon"><PlusOutlined /></span>
<strong>{{ t('onboardingBrand.createBrand') }}</strong>
<span>{{ t('onboardingBrand.createHint') }}</span>
</button>
</div>
<div v-else class="empty-brand-state">
<button type="button" class="create-brand-card" @click="openBrandModal">
<span class="create-brand-card__icon"><PlusOutlined /></span>
<strong>{{ t('onboardingBrand.createBrand') }}</strong>
<span>{{ t('onboardingBrand.emptyDescription') }}</span>
</button>
</div>
</template>
</section>
</main>
<a-modal
v-model:open="brandModalOpen"
:title="t('onboardingBrand.form.title')"
centered
:footer="null"
:width="560"
:mask-closable="false"
>
<a-form layout="vertical" class="brand-create-form" @submit.prevent="submitBrand">
<a-form-item :label="t('onboardingBrand.form.name')" required>
<a-input
v-model:value="brandForm.name"
size="large"
:placeholder="t('onboardingBrand.form.namePlaceholder')"
/>
</a-form-item>
<a-form-item :label="t('onboardingBrand.form.website')">
<a-input
v-model:value="brandForm.website"
size="large"
:placeholder="t('onboardingBrand.form.websitePlaceholder')"
>
<template #prefix><GlobalOutlined /></template>
</a-input>
</a-form-item>
<a-form-item :label="t('onboardingBrand.form.description')" required>
<a-textarea
v-model:value="brandForm.description"
:rows="4"
:placeholder="t('onboardingBrand.form.descriptionPlaceholder')"
/>
</a-form-item>
<div class="modal-actions">
<a-button size="large" @click="brandModalOpen = false">
{{ t('common.cancel') }}
</a-button>
<a-button
size="large"
type="primary"
html-type="submit"
:loading="createBrandMutation.isPending.value"
>
{{ t('common.next') }}
<template #icon><ArrowRightOutlined /></template>
</a-button>
</div>
</a-form>
</a-modal>
<a-modal
v-model:open="questionModalOpen"
centered
:footer="null"
:width="760"
:closable="false"
:mask-closable="false"
>
<section class="question-picker">
<div class="question-picker__header">
<div>
<p class="eyebrow">{{ createdBrand?.name }}</p>
<h2>{{ t('onboardingBrand.questions.title') }}</h2>
<p>
{{
t('onboardingBrand.questions.subtitle', {
limit: selectableQuestionLimitText,
})
}}
</p>
</div>
<div class="question-picker__count">
<strong>{{ selectedCandidates.length }}</strong>
<span>/ {{ selectableQuestionLimitText }}</span>
</div>
</div>
<a-alert
v-if="questionNotice"
class="question-picker__notice"
type="warning"
show-icon
:message="questionNotice"
/>
<div v-if="candidateRows.length" class="candidate-grid">
<button
v-for="row in candidateRows"
:key="row.id"
type="button"
class="candidate-chip"
:class="{ 'candidate-chip--selected': row.selected }"
@click="toggleCandidate(row)"
>
<CheckCircleOutlined v-if="row.selected" />
<PlusOutlined v-else />
<span>{{ row.text }}</span>
</button>
</div>
<a-empty v-else :description="t('onboardingBrand.questions.empty')" />
<footer class="question-picker__footer">
<a-button size="large" @click="enterWorkspace()">
{{ t('onboardingBrand.questions.skip') }}
</a-button>
<a-button
size="large"
type="primary"
:disabled="!selectedCandidates.length"
:loading="materializeQuestionsMutation.isPending.value"
@click="saveSelectedQuestions"
>
{{ t('onboardingBrand.questions.confirm') }}
</a-button>
</footer>
</section>
</a-modal>
<AiWaitingModal
:open="aiWaitingOpen"
:title="t('onboardingBrand.ai.title')"
:progress="aiProgress"
:stage="aiStages[aiStageIndex]"
/>
</div>
</template>
<style scoped>
.brand-onboarding {
position: relative;
display: flex;
flex: 1;
width: 100%;
min-height: 100vh;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 40px 24px;
color: #0f172a;
background-color: #f8fafc;
background-image:
radial-gradient(circle at 10% 20%, rgba(22, 119, 255, 0.05) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
radial-gradient(circle at 50% 0%, rgba(6, 182, 212, 0.04) 0%, transparent 35%),
radial-gradient(rgba(148, 163, 184, 0.1) 1px, transparent 1px);
background-size:
100% 100%,
100% 100%,
100% 100%,
24px 24px;
}
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.55;
pointer-events: none;
z-index: 0;
animation: float-slow 15s ease-in-out infinite alternate;
}
.glow-orb--1 {
top: -10%;
left: 15%;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(22, 119, 255, 0.15) 0%, rgba(99, 102, 241, 0.05) 100%);
}
.glow-orb--2 {
bottom: -10%;
right: 10%;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, rgba(22, 119, 255, 0.04) 100%);
animation-delay: -5s;
}
@keyframes float-slow {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(30px, -30px) scale(1.05);
}
}
.onboarding-panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(520px, 100%);
padding: 48px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 24px;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.02),
0 4px 12px rgba(0, 0, 0, 0.02),
0 16px 36px rgba(22, 119, 255, 0.04),
0 32px 80px rgba(15, 23, 42, 0.05);
}
.onboarding-panel__intro {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 36px;
}
.brand-mark {
display: flex;
width: 52px;
height: 52px;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(22, 119, 255, 0.08) 0%, rgba(99, 102, 241, 0.08) 100%);
border: 1px solid rgba(22, 119, 255, 0.15);
border-radius: 14px;
color: #1677ff;
font-size: 22px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.04);
}
.eyebrow {
display: inline-block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #1677ff;
margin: 0 0 10px;
background: rgba(22, 119, 255, 0.06);
padding: 4px 12px;
border-radius: 12px;
border: 1px solid rgba(22, 119, 255, 0.1);
}
.onboarding-panel h1 {
margin: 0;
color: #0f172a;
font-size: 26px;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.02em;
}
.intro-copy {
margin: 12px 0 0;
color: #475569;
font-size: 15px;
line-height: 1.6;
}
.brand-entry {
display: flex;
align-items: center;
width: 100%;
}
.brand-entry__loading,
.brand-entry__grid,
.empty-brand-state {
width: 100%;
}
.brand-entry__grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.brand-choice {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
align-items: center;
column-gap: 16px;
width: 100%;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 16px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
text-align: left;
}
.brand-choice:hover {
background: #ffffff;
border-color: rgba(22, 119, 255, 0.4);
box-shadow:
0 10px 25px -5px rgba(22, 119, 255, 0.08),
0 4px 12px -5px rgba(22, 119, 255, 0.05);
transform: translateY(-2px);
}
.brand-choice__icon {
grid-column: 1;
grid-row: 1 / span 2;
display: flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
color: #1677ff;
font-size: 18px;
background: rgba(22, 119, 255, 0.06);
border: 1px solid rgba(22, 119, 255, 0.1);
border-radius: 12px;
transition: all 0.2s ease;
}
.brand-choice:hover .brand-choice__icon {
background: rgba(22, 119, 255, 0.15);
border-color: rgba(22, 119, 255, 0.25);
color: #1677ff;
}
.brand-choice strong {
grid-column: 2;
grid-row: 1;
margin: 0;
color: #0f172a;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
}
.brand-choice span:not(.brand-choice__icon) {
grid-column: 2;
grid-row: 2;
color: #64748b;
font-size: 13px;
line-height: 1.4;
margin-top: 2px;
}
.brand-choice__arrow {
grid-column: 3;
grid-row: 1 / span 2;
color: #94a3b8;
font-size: 14px;
transition: all 0.2s ease;
}
.brand-choice:hover .brand-choice__arrow {
color: #1677ff;
transform: translateX(4px);
}
.brand-choice--create {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border: 1px dashed rgba(22, 119, 255, 0.3);
background: rgba(22, 119, 255, 0.02);
min-height: auto;
padding: 16px;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.brand-choice--create:hover {
background: rgba(22, 119, 255, 0.06);
border-color: #1677ff;
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(22, 119, 255, 0.05);
}
.brand-choice--create .brand-choice__icon {
width: auto;
height: auto;
background: transparent;
color: #1677ff;
font-size: 16px;
}
.brand-choice--create strong {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1677ff;
}
.brand-choice--create span:not(.brand-choice__icon) {
display: none;
}
.empty-brand-state {
display: flex;
justify-content: center;
width: 100%;
}
.create-brand-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
padding: 40px 24px;
background: rgba(255, 255, 255, 0.4);
border: 1px dashed rgba(22, 119, 255, 0.25);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.create-brand-card:hover {
background: rgba(255, 255, 255, 0.9);
border-color: #1677ff;
box-shadow:
0 12px 30px -5px rgba(22, 119, 255, 0.08),
0 4px 12px -5px rgba(22, 119, 255, 0.04);
transform: translateY(-2px);
}
.create-brand-card__icon {
font-size: 24px;
color: #1677ff;
background: rgba(22, 119, 255, 0.06);
border: 1px solid rgba(22, 119, 255, 0.1);
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
transition: all 0.2s ease;
}
.create-brand-card:hover .create-brand-card__icon {
background: rgba(22, 119, 255, 0.15);
border-color: rgba(22, 119, 255, 0.25);
color: #1677ff;
transform: scale(1.05);
}
.create-brand-card strong {
color: #0f172a;
font-size: 16px;
font-weight: 600;
}
.create-brand-card span:not(.create-brand-card__icon) {
color: #64748b;
font-size: 13px;
text-align: center;
max-width: 280px;
line-height: 1.5;
}
.brand-create-form {
padding-top: 12px;
}
.brand-create-form :deep(.ant-form-item-label > label) {
font-weight: 600;
color: #334155;
}
.brand-create-form :deep(.ant-input),
.brand-create-form :deep(.ant-input-affix-wrapper) {
border-radius: 10px;
}
.brand-create-form :deep(.ant-input-affix-wrapper) {
padding: 8px 12px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 28px;
}
.modal-actions :deep(.ant-btn) {
border-radius: 10px;
height: 44px;
padding: 0 24px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
}
.modal-actions :deep(.ant-btn-primary) {
background: #1677ff;
border-color: #1677ff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.15);
}
.modal-actions :deep(.ant-btn-primary:hover) {
background: #4096ff;
border-color: #4096ff;
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.25);
}
.question-picker {
padding: 12px 8px;
}
.question-picker__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding-bottom: 20px;
margin-bottom: 20px;
}
.question-picker__header h2 {
margin: 0;
color: #0f172a;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
}
.question-picker__header p:not(.eyebrow) {
margin: 6px 0 0;
color: #64748b;
font-size: 14px;
line-height: 1.5;
}
.question-picker__header .eyebrow {
display: inline-block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 6px;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.question-picker__count {
min-width: auto;
padding: 6px 14px;
color: #1677ff;
text-align: center;
background: rgba(22, 119, 255, 0.08);
border: 1px solid rgba(22, 119, 255, 0.15);
border-radius: 10px;
display: flex;
align-items: baseline;
gap: 4px;
}
.question-picker__count strong {
font-size: 20px;
line-height: 1;
font-weight: 700;
}
.question-picker__count span {
color: rgba(22, 119, 255, 0.6);
font-size: 13px;
font-weight: 600;
}
.question-picker__notice {
margin-top: 16px;
}
.candidate-grid {
display: flex;
flex-wrap: wrap;
max-height: min(46vh, 420px);
gap: 10px;
margin-top: 16px;
overflow-y: auto;
padding: 4px;
scrollbar-width: thin;
}
.candidate-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: #334155;
text-align: left;
background: rgba(241, 245, 249, 0.8);
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
min-height: 38px;
}
.candidate-chip:hover {
background: #e2e8f0;
border-color: #cbd5e1;
transform: translateY(-1px);
}
.candidate-chip svg {
flex: 0 0 auto;
font-size: 13px;
color: #64748b;
}
.candidate-chip span {
min-width: 0;
overflow-wrap: anywhere;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
}
.candidate-chip--selected {
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
border-color: rgba(22, 119, 255, 0.3);
}
.candidate-chip--selected:hover {
background: rgba(22, 119, 255, 0.12);
border-color: rgba(22, 119, 255, 0.4);
}
.candidate-chip--selected svg {
color: #1677ff;
}
.question-picker__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
border-top: 1px solid rgba(226, 232, 240, 0.6);
padding-top: 20px;
}
.question-picker__footer :deep(.ant-btn) {
border-radius: 10px;
height: 40px;
padding: 0 20px;
font-weight: 600;
}
@media (max-width: 640px) {
.onboarding-panel {
padding: 32px 24px;
}
}
</style>