feat: Add promo preference survey on main page

- Add promo preference fields to User model (promo_preferred_maker,
  promo_preferred_model, promo_email_enabled)
- Create API endpoints for getting/updating promo preferences
- Create PromoPreference component with maker/model selection
- Show login prompt for non-logged-in users when interacting
- Add promo notification service to send emails when matching vehicles
  are added to promotion
- Add multi-language translations (en, mn, ru, ko)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2026-01-12 23:37:31 +09:00
parent 2378392f95
commit 2720689515
10 changed files with 618 additions and 11 deletions

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import FilmStripSlider from '@/components/FilmStripSlider';
import PromoPreference from '@/components/PromoPreference';
import { HeroBanner, HeroBannerSettings } from '@/types';
import { heroBannersApi } from '@/lib/api';
import { useTranslation } from '@/lib/i18n';
@@ -45,13 +46,23 @@ export default function Home() {
{/* Film Strip Slider */}
<FilmStripSlider banners={banners} settings={bannerSettings} />
<div className="container mx-auto px-4 py-4 sm:py-8 text-center">
<Link
href="/vehicle-request"
className="inline-block bg-yellow-500 text-white font-semibold px-6 py-2.5 sm:px-8 sm:py-3 rounded-lg hover:bg-yellow-600 transition shadow-lg text-sm sm:text-base"
>
{t.requestVehicle}
</Link>
<div className="container mx-auto px-4 py-4 sm:py-8">
<div className="flex flex-col lg:flex-row items-center justify-center gap-4 lg:gap-8">
{/* Request Vehicle Button */}
<Link
href="/vehicle-request"
className="inline-block bg-yellow-500 text-white font-semibold px-6 py-2.5 sm:px-8 sm:py-3 rounded-lg hover:bg-yellow-600 transition shadow-lg text-sm sm:text-base"
>
{t.requestVehicle}
</Link>
{/* Divider */}
<div className="hidden lg:block w-px h-24 bg-white/30"></div>
<div className="lg:hidden w-32 h-px bg-white/30"></div>
{/* Promo Preference */}
<PromoPreference />
</div>
</div>
</section>

View File

@@ -0,0 +1,213 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { carsApi, authApi } from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { useTranslation } from '@/lib/i18n';
import { CarMaker, CarModel } from '@/types';
export default function PromoPreference() {
const { t } = useTranslation();
const { user, token } = useAuthStore();
const isLoggedIn = !!token && !!user;
const [makers, setMakers] = useState<CarMaker[]>([]);
const [models, setModels] = useState<CarModel[]>([]);
const [selectedMaker, setSelectedMaker] = useState<string>('');
const [selectedModel, setSelectedModel] = useState<string>('');
const [emailEnabled, setEmailEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
// Load makers on mount
useEffect(() => {
const loadMakers = async () => {
try {
const data = await carsApi.getMakers();
setMakers(data);
} catch (error) {
console.error('Failed to load makers:', error);
}
};
loadMakers();
}, []);
// Load user preferences if logged in
useEffect(() => {
const loadPreferences = async () => {
if (!isLoggedIn) return;
try {
const pref = await authApi.getPromoPreference();
if (pref.promo_preferred_maker) {
setSelectedMaker(pref.promo_preferred_maker);
// Load models for selected maker
const maker = makers.find(m => m.name === pref.promo_preferred_maker);
if (maker) {
const modelsData = await carsApi.getModels(maker.id);
setModels(modelsData);
}
}
if (pref.promo_preferred_model) {
setSelectedModel(pref.promo_preferred_model);
}
setEmailEnabled(pref.promo_email_enabled);
} catch (error) {
console.error('Failed to load preferences:', error);
}
};
if (makers.length > 0) {
loadPreferences();
}
}, [isLoggedIn, makers]);
// Load models when maker changes
const handleMakerChange = async (makerName: string) => {
if (!isLoggedIn) {
setShowLoginPrompt(true);
return;
}
setShowLoginPrompt(false);
setSelectedMaker(makerName);
setSelectedModel('');
setSaved(false);
if (makerName) {
const maker = makers.find(m => m.name === makerName);
if (maker) {
try {
const data = await carsApi.getModels(maker.id);
setModels(data);
} catch (error) {
console.error('Failed to load models:', error);
}
}
} else {
setModels([]);
}
};
const handleModelChange = (modelName: string) => {
if (!isLoggedIn) {
setShowLoginPrompt(true);
return;
}
setShowLoginPrompt(false);
setSelectedModel(modelName);
setSaved(false);
};
const handleEmailToggle = () => {
if (!isLoggedIn) {
setShowLoginPrompt(true);
return;
}
setShowLoginPrompt(false);
setEmailEnabled(!emailEnabled);
setSaved(false);
};
const handleSave = async () => {
if (!isLoggedIn) {
setShowLoginPrompt(true);
return;
}
setLoading(true);
try {
await authApi.updatePromoPreference({
promo_preferred_maker: selectedMaker || undefined,
promo_preferred_model: selectedModel || undefined,
promo_email_enabled: emailEnabled,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (error) {
console.error('Failed to save preferences:', error);
} finally {
setLoading(false);
}
};
return (
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4 sm:p-5 max-w-md mx-auto">
<h3 className="text-base sm:text-lg font-semibold text-white mb-3 text-center">
{t.promoPreferenceTitle || 'Next Promotion Interest'}
</h3>
<p className="text-xs sm:text-sm text-primary-100 mb-4 text-center">
{t.promoPreferenceDesc || 'Tell us which vehicle you want in the next promotion!'}
</p>
{/* Maker - Model Combo */}
<div className="space-y-3">
<div className="flex flex-col sm:flex-row gap-2">
<select
value={selectedMaker}
onChange={(e) => handleMakerChange(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg text-sm text-gray-800 bg-white border-0 focus:ring-2 focus:ring-yellow-500"
>
<option value="">{t.selectMaker || 'Select Maker'}</option>
{makers.map((maker) => (
<option key={maker.id} value={maker.name}>
{maker.name}
</option>
))}
</select>
<select
value={selectedModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={!selectedMaker}
className="flex-1 px-3 py-2 rounded-lg text-sm text-gray-800 bg-white border-0 focus:ring-2 focus:ring-yellow-500 disabled:bg-gray-100 disabled:text-gray-400"
>
<option value="">{t.selectModel || 'Select Model'}</option>
{models.map((model) => (
<option key={model.id} value={model.name}>
{model.name}
</option>
))}
</select>
</div>
{/* Email Alert Checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={emailEnabled}
onChange={handleEmailToggle}
className="w-4 h-4 text-yellow-500 bg-white rounded border-0 focus:ring-yellow-500"
/>
<span className="text-xs sm:text-sm text-white">
{t.promoEmailAlert || 'Notify me by email when this vehicle is on promotion'}
</span>
</label>
{/* Login Prompt */}
{showLoginPrompt && (
<div className="p-3 bg-yellow-500/20 rounded-lg text-center">
<p className="text-sm text-white mb-2">
{t.promoLoginRequired || 'Please login to save your preference'}
</p>
<Link
href="/login"
className="inline-block text-sm font-medium text-yellow-400 hover:text-yellow-300 underline"
>
{t.login || 'Login'} / {t.signUp || 'Sign Up'}
</Link>
</div>
)}
{/* Save Button */}
{isLoggedIn && (selectedMaker || emailEnabled) && (
<button
onClick={handleSave}
disabled={loading}
className="w-full py-2 bg-yellow-500 text-white font-medium rounded-lg hover:bg-yellow-600 transition disabled:opacity-50 text-sm"
>
{loading ? (t.saving || 'Saving...') : saved ? (t.saved || 'Saved!') : (t.savePreference || 'Save Preference')}
</button>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import { Car, CarListResponse, CarMaker, CarModel, User, HeroBanner, HeroBannerSettings, CarView } from '@/types';
import { Car, CarListResponse, CarMaker, CarModel, User, HeroBanner, HeroBannerSettings, CarView, PromoPreference } from '@/types';
// When NEXT_PUBLIC_API_URL is empty, use relative path (for HTTPS proxy setup)
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -133,6 +133,18 @@ export const authApi = {
const { data } = await api.post('/auth/withdraw/cancel');
return data;
},
// 프로모션 선호 차종 조회
getPromoPreference: async (): Promise<PromoPreference> => {
const { data } = await api.get('/auth/me/promo-preference');
return data;
},
// 프로모션 선호 차종 설정
updatePromoPreference: async (preference: PromoPreference): Promise<PromoPreference> => {
const { data } = await api.put('/auth/me/promo-preference', preference);
return data;
},
};
// Inquiries API

View File

@@ -121,6 +121,17 @@ export interface Translations {
contactForPrice: string;
engine: string;
// Promo Preference
promoPreferenceTitle: string;
promoPreferenceDesc: string;
selectMaker: string;
selectModel: string;
promoEmailAlert: string;
promoLoginRequired: string;
savePreference: string;
saving: string;
saved: string;
// Vehicle Request & CC System
requestVehicle: string;
aiDealerSearching: string;
@@ -592,6 +603,17 @@ const translations: Record<Language, Translations> = {
contactForPrice: 'Contact for price',
engine: 'Engine',
// Promo Preference
promoPreferenceTitle: 'Next Promotion Interest',
promoPreferenceDesc: 'Tell us which vehicle you want in the next promotion!',
selectMaker: 'Select Maker',
selectModel: 'Select Model',
promoEmailAlert: 'Notify me by email when this vehicle is on promotion',
promoLoginRequired: 'Please login to save your preference',
savePreference: 'Save Preference',
saving: 'Saving...',
saved: 'Saved!',
// Vehicle Request & CC System
requestVehicle: 'Request Vehicle',
aiDealerSearching: 'Korean AI Dealer is searching for vehicles...',
@@ -1061,6 +1083,17 @@ const translations: Record<Language, Translations> = {
contactForPrice: 'Үнийг лавлана уу',
engine: 'Хөдөлгүүр',
// Promo Preference
promoPreferenceTitle: 'Дараагийн урамшуулалд сонирхолтой',
promoPreferenceDesc: 'Дараагийн урамшуулалд ямар машин хүсч байгаагаа хэлнэ үү!',
selectMaker: 'Үйлдвэрлэгч сонгох',
selectModel: 'Загвар сонгох',
promoEmailAlert: 'Энэ машин урамшуулалд гарахад имэйлээр мэдэгдэнэ үү',
promoLoginRequired: 'Сонголтоо хадгалахын тулд нэвтэрнэ үү',
savePreference: 'Сонголт хадгалах',
saving: 'Хадгалж байна...',
saved: 'Хадгалагдлаа!',
// Vehicle Request & CC System
requestVehicle: 'Машин захиалах',
aiDealerSearching: 'Солонгосын AI дилер машин хайж байна...',
@@ -1530,6 +1563,17 @@ const translations: Record<Language, Translations> = {
contactForPrice: 'Цена по запросу',
engine: 'Двигатель',
// Promo Preference
promoPreferenceTitle: 'Интерес к следующей акции',
promoPreferenceDesc: 'Расскажите, какой автомобиль вы хотите в следующей акции!',
selectMaker: 'Выберите марку',
selectModel: 'Выберите модель',
promoEmailAlert: 'Уведомить по email когда этот автомобиль появится в акции',
promoLoginRequired: 'Войдите, чтобы сохранить предпочтения',
savePreference: 'Сохранить предпочтения',
saving: 'Сохранение...',
saved: 'Сохранено!',
// Vehicle Request & CC System
requestVehicle: 'Запросить автомобиль',
aiDealerSearching: 'Корейский AI-дилер ищет автомобили...',
@@ -1999,6 +2043,17 @@ const translations: Record<Language, Translations> = {
contactForPrice: '가격 문의',
engine: '엔진',
// Promo Preference
promoPreferenceTitle: '다음 프로모션 관심 차종',
promoPreferenceDesc: '다음 프로모션에 원하는 차량을 알려주세요!',
selectMaker: '제조사 선택',
selectModel: '모델 선택',
promoEmailAlert: '이 차량이 프로모션에 등록되면 이메일로 알림받기',
promoLoginRequired: '선호도를 저장하려면 로그인하세요',
savePreference: '선호도 저장',
saving: '저장 중...',
saved: '저장됨!',
// Vehicle Request & CC System
requestVehicle: '차량요청하기',
aiDealerSearching: '한국AI딜러가 차량을 찾고있습니다...',

View File

@@ -106,9 +106,18 @@ export interface User {
referral_code?: string;
email_verified: boolean;
phone_verified: boolean;
promo_preferred_maker?: string;
promo_preferred_model?: string;
promo_email_enabled?: boolean;
created_at: string;
}
export interface PromoPreference {
promo_preferred_maker?: string;
promo_preferred_model?: string;
promo_email_enabled: boolean;
}
export interface CarView {
id: number;
user_id: number;