Spaces:
Sleeping
Sleeping
| const express = require('express'); | |
| const cors = require('cors'); | |
| const axios = require('axios'); | |
| const cheerio = require('cheerio'); | |
| const path = require('path'); | |
| const fs = require('fs').promises; | |
| const cron = require('node-cron'); | |
| const { v4: uuidv4 } = require('uuid'); | |
| const AirtableService = require('./airtable-service'); | |
| require('dotenv').config(); | |
| const app = express(); | |
| const PORT = process.env.PORT || 7860; | |
| app.use(cors()); | |
| app.use(express.json()); | |
| // Serve static files from React build in production | |
| if (process.env.NODE_ENV === 'production') { | |
| app.use(express.static(path.join(__dirname, '../client/build'))); | |
| } | |
| // Initialize Airtable service | |
| const airtableService = new AirtableService(); | |
| // Fallback in-memory storage for examples if Airtable not configured | |
| let transcreationExamples = []; | |
| // Load cached examples from file on startup | |
| const loadCachedExamples = async () => { | |
| try { | |
| const data = await fs.readFile(path.join(__dirname, 'cached-examples.json'), 'utf8'); | |
| transcreationExamples = JSON.parse(data); | |
| console.log(`📚 Loaded ${transcreationExamples.length} cached examples`); | |
| } catch (error) { | |
| console.log('📝 No cached examples found, starting fresh'); | |
| transcreationExamples = []; | |
| } | |
| }; | |
| // Save examples to file | |
| const saveCachedExamples = async () => { | |
| try { | |
| // Sort examples by dateAdded to maintain consistent order | |
| const sortedExamples = [...transcreationExamples].sort((a, b) => | |
| new Date(a.dateAdded) - new Date(b.dateAdded) | |
| ); | |
| // Ensure all examples have consistent structure | |
| const cleanedExamples = sortedExamples.map(example => ({ | |
| ...example, | |
| // Ensure optional fields are properly handled | |
| status: example.status ?? 'pending', | |
| contributor: example.contributor === undefined ? null : example.contributor, | |
| type: example.type ?? 'slogan', | |
| description: example.description ?? '', | |
| lastModified: example.lastModified ?? example.dateAdded | |
| })); | |
| await fs.writeFile( | |
| path.join(__dirname, 'cached-examples.json'), | |
| JSON.stringify(cleanedExamples, null, 2) | |
| ); | |
| console.log(`💾 Saved ${cleanedExamples.length} examples to cache`); | |
| } catch (error) { | |
| console.error('❌ Failed to save examples:', error); | |
| } | |
| }; | |
| // Search for transcreation examples online | |
| const searchTranscreationExamples = async (category = '', maxResults = 5) => { | |
| console.log(`🔍 Searching for transcreation examples...`); | |
| try { | |
| // Simulate online search with curated examples | |
| // In a real implementation, this would scrape marketing sites, case studies, etc. | |
| const simulatedResults = await simulateOnlineSearch(category, maxResults); | |
| // Add to our cache | |
| for (const example of simulatedResults) { | |
| // Check if already exists | |
| const exists = transcreationExamples.find(ex => | |
| ex.english.toLowerCase() === example.english.toLowerCase() && | |
| ex.brand.toLowerCase() === example.brand.toLowerCase() | |
| ); | |
| if (!exists) { | |
| example.id = Date.now().toString() + Math.random().toString(36).substr(2, 9); | |
| example.dateAdded = new Date().toISOString(); | |
| example.source = 'Online Search'; | |
| transcreationExamples.push(example); | |
| console.log(`✅ Added new example: ${example.brand} - ${example.english}`); | |
| } | |
| } | |
| // Save to cache | |
| await saveCachedExamples(); | |
| return simulatedResults; | |
| } catch (error) { | |
| console.error('❌ Search failed:', error); | |
| return []; | |
| } | |
| }; | |
| // Simulate online search with realistic examples | |
| const simulateOnlineSearch = async (category = '', maxResults = 5) => { | |
| // All 38 local examples exactly as they are | |
| const allExamples = [ | |
| { | |
| "english": "Open Happiness", | |
| "mainland": "畅爽开怀", | |
| "taiwan": "暢爽開懷", | |
| "brand": "Coca-Cola", | |
| "category": "Food & Beverage", | |
| "description": "Coca-Cola's uplifting slogan adaptation celebrating carefree refreshment and open-hearted joy.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland version focuses on personal refreshing experience, Taiwan version emphasizes social sharing aspect valued in Taiwanese culture.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Because You're Worth It", | |
| "mainland": "你值得拥有", | |
| "taiwan": "因為你值得", | |
| "brand": "L'Oréal", | |
| "category": "Beauty & Cosmetics", | |
| "description": "L'Oréal's empowerment message adapted for different concepts of self-worth and beauty standards.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland focuses on possession/ownership, Taiwan emphasizes inherent worthiness.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Have a Break, Have a Kit Kat", | |
| "mainland": "轻松一刻,奇巧时刻", | |
| "taiwan": "休息一下,吃個Kit Kat", | |
| "brand": "Kit Kat", | |
| "category": "Food & Beverage", | |
| "description": "Kit Kat's break concept maintaining rhythm while using different approaches to product naming.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland uses localized brand name, Taiwan keeps original brand name with local language structure.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Think Different", | |
| "mainland": "非同凡想", | |
| "taiwan": "不同凡想", | |
| "brand": "Apple", | |
| "category": "Technology", | |
| "description": "Apple's famous campaign with slight variations - mainland uses more contemporary language, Taiwan preserves traditional elements.", | |
| "type": "slogan", | |
| "culturalNote": "Both versions maintain the creative rebellion message but adapt to local linguistic preferences.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Connecting People", | |
| "mainland": "科技以人为本", | |
| "taiwan": "科技始終來自於人性", | |
| "brand": "Nokia", | |
| "category": "Technology", | |
| "description": "Nokia's brand promise adapted to different cultural values about technology's role in society.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland emphasizes human-centered technology philosophy, Taiwan emphasizes personal connection.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Finger Lickin' Good", | |
| "mainland": "吮指回味,自在滋味", | |
| "taiwan": "吮指美味", | |
| "brand": "KFC", | |
| "category": "Food & Beverage", | |
| "description": "KFC's sensory appeal slogan with regional preferences for describing taste intensity.", | |
| "type": "slogan", | |
| "culturalNote": "Both maintain the playful imagery but use different intensifiers for taste description.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "I'm Lovin' It", | |
| "mainland": "我就喜欢", | |
| "taiwan": "我就喜歡", | |
| "brand": "McDonald's", | |
| "category": "Food & Beverage", | |
| "description": "McDonald's global jingle adapted for regional expressions of enjoyment and enthusiasm.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland uses softer expression of liking, Taiwan uses more enthusiastic colloquial expression.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Life's Good", | |
| "mainland": "让生活更美好", | |
| "taiwan": "讓生活更美好", | |
| "brand": "LG", | |
| "category": "Technology", | |
| "description": "LG's positive lifestyle message adapted to different cultural perspectives on good life.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland suggests improvement, Taiwan emphasizes living life to the fullest.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Do What You Can't", | |
| "mainland": "战胜不可能", | |
| "taiwan": "挑戰你所不能", | |
| "brand": "Samsung", | |
| "category": "Technology", | |
| "description": "Samsung's defiant call to go beyond limits, translated to rally users in each market to challenge the impossible.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland emphasizes personal utility, Taiwan emphasizes communal sharing of benefits.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Play Has No Limits", | |
| "mainland": "玩无极限", | |
| "taiwan": "玩樂無設限", | |
| "brand": "PlayStation", | |
| "category": "Entertainment", | |
| "description": "PlayStation's boundless gaming message adapted regionally.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland emphasizes boundlessness, Taiwan emphasizes infinity.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Melts in Your Mouth, Not in Your Hands", | |
| "mainland": "只溶在口,不溶在手", | |
| "taiwan": "只溶你口,不溶你手", | |
| "brand": "M&M's", | |
| "category": "Food & Beverage", | |
| "description": "M&M's unique selling proposition translated with slight regional variations.", | |
| "type": "slogan", | |
| "culturalNote": "Both versions maintain the dual benefit message with minor character variations.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Bank for a Changing World", | |
| "mainland": "携手变,通世界", | |
| "taiwan": "攜手變,通世界", | |
| "brand": "BNP Paribas", | |
| "category": "Financial Services", | |
| "description": "BNP's change-focused message adapted regionally.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland emphasizes guidance, Taiwan emphasizes providing choices.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": false | |
| }, | |
| { | |
| "english": "The Best or Nothing", | |
| "mainland": "惟有最好", | |
| "taiwan": "惟有最好", | |
| "brand": "Mercedes-Benz", | |
| "category": "Automotive", | |
| "description": "Mercedes' commitment to excellence expressed through different cultural values.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland emphasizes meeting expectations, Taiwan emphasizes uncompromising standards.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Rise", | |
| "mainland": "打出名堂", | |
| "taiwan": "打出名堂", | |
| "brand": "Nike", | |
| "category": "Sports", | |
| "description": "Nike's aspirational basketball mantra urging players to make a name for themselves and elevate their game.", | |
| "type": "slogan", | |
| "culturalNote": "Both versions maintain the concept of achievement through play.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Red Bull Gives You Wings", | |
| "mainland": "你的能量超乎你想象", | |
| "taiwan": "Red Bull 給你一對翅膀", | |
| "brand": "Red Bull", | |
| "category": "Food & Beverage", | |
| "description": "Red Bull's energetic message adapted for different markets.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland emphasizes the action of flying, Taiwan emphasizes the gift of wings.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Save Money. Live Better.", | |
| "mainland": "省钱,省心,好生活", | |
| "taiwan": "省錢,生活變更好", | |
| "brand": "Walmart", | |
| "category": "Retail", | |
| "description": "Another Walmart campaign adapted to different value perceptions.", | |
| "type": "slogan", | |
| "culturalNote": "Both maintain saving concept but with different emphasis on lifestyle benefits.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "The World's Local Bank", | |
| "mainland": "环球金融,地方智慧", | |
| "taiwan": "環球金融,地方智慧", | |
| "brand": "HSBC", | |
| "category": "Financial Services", | |
| "description": "HSBC's global-local positioning adapted for different markets.", | |
| "type": "slogan", | |
| "culturalNote": "Different approaches to expressing the global-local balance.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": false | |
| }, | |
| { | |
| "english": "Impossible is Nothing", | |
| "mainland": "没有不可能", | |
| "taiwan": "没有不可能", | |
| "brand": "Adidas", | |
| "category": "Sports", | |
| "description": "Adidas' motivational message adapted to local expressions of possibility.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland uses direct negation of impossibility, Taiwan emphasizes unlimited ability.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Mercedes-Benz", | |
| "mainland": "奔驰", | |
| "taiwan": "賓士", | |
| "brand": "Mercedes-Benz", | |
| "category": "Automotive", | |
| "description": "Luxury car manufacturer name adapted differently in each region.", | |
| "type": "slogan", | |
| "culturalNote": "Mainland emphasizes speed (\"galloping\"), Taiwan uses phonetic translation of \"Benz\".", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Revlon", | |
| "mainland": "露华浓", | |
| "taiwan": "露華濃", | |
| "brand": "Revlon", | |
| "category": "Beauty & Cosmetics", | |
| "description": "Cosmetics brand name adapted using poetic beauty references.", | |
| "type": "slogan", | |
| "culturalNote": "Both use poetic phrase meaning \"concentrated dew\", evoking natural beauty and luxury.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "BMW", | |
| "mainland": "宝马", | |
| "taiwan": "寶馬", | |
| "brand": "BMW", | |
| "category": "Automotive", | |
| "description": "German automaker name adapted using traditional lucky symbolism.", | |
| "type": "slogan", | |
| "culturalNote": "Both use \"precious horse\", a culturally auspicious symbol, differing only in traditional vs simplified characters.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "MasterCard", | |
| "mainland": "万事达卡", | |
| "taiwan": "萬事達卡", | |
| "brand": "MasterCard", | |
| "category": "Financial Services", | |
| "description": "Global payment brand name adaptation emphasizing universal capability.", | |
| "type": "slogan", | |
| "culturalNote": "Both versions use characters meaning \"everything achievable\", with traditional vs simplified characters being the main difference.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": false | |
| }, | |
| { | |
| "english": "Bing", | |
| "mainland": "必应", | |
| "taiwan": "Bing", | |
| "brand": "Microsoft", | |
| "category": "Technology", | |
| "description": "Microsoft's search engine name adapted to reflect responsiveness.", | |
| "type": "slogan", | |
| "culturalNote": "Both versions use characters meaning \"must respond\", maintaining the concept of getting answers.", | |
| "status": "verified", | |
| "contributor": null | |
| }, | |
| { | |
| "english": "IKEA", | |
| "mainland": "宜家", | |
| "taiwan": "宜家", | |
| "brand": "IKEA", | |
| "category": "Retail", | |
| "type": "slogan", | |
| "description": "A hybrid transcreation evoking comfort and domestic harmony, with semantic reference to the idiom \"宜室宜家\", reinforcing IKEA's image as a builder of ideal homes.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Taste The Feeling", | |
| "mainland": "这感觉够爽", | |
| "taiwan": "這感覺夠爽", | |
| "brand": "Coca-Cola", | |
| "category": "Food & Beverage", | |
| "type": "slogan", | |
| "description": "Coca-Cola's effervescent slogan adaptation amplifying the drink's crisp, refreshing thrill.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Coca-Cola", | |
| "mainland": "可口可乐", | |
| "taiwan": "可口可樂", | |
| "brand": "Coca-Cola", | |
| "category": "Food & Beverage", | |
| "type": "slogan", | |
| "description": "Coca-Cola's joyful phonetic rendering emphasizes taste and delight.", | |
| "status": "verified", | |
| "contributor": null | |
| }, | |
| { | |
| "english": "Pepsi-Cola", | |
| "mainland": "百事可乐", | |
| "taiwan": "百事可樂", | |
| "brand": "Pepsi", | |
| "category": "Food & Beverage", | |
| "type": "slogan", | |
| "description": "Pepsi's optimistic phonetic adaptation highlights universal happiness.", | |
| "status": "verified", | |
| "contributor": null | |
| }, | |
| { | |
| "english": "Procter & Gamble", | |
| "mainland": "宝洁", | |
| "taiwan": "寶僑", | |
| "brand": "Procter & Gamble", | |
| "category": "Retail", | |
| "type": "slogan", | |
| "description": "P&G's name localized differently—Mainland emphasizes cleanliness; HK/TW favors phonetic approach.", | |
| "status": "verified", | |
| "contributor": "" | |
| }, | |
| { | |
| "english": "Land Rover", | |
| "mainland": "路虎", | |
| "taiwan": "荒原路華", | |
| "brand": "Land Rover", | |
| "category": "Automotive", | |
| "type": "slogan", | |
| "description": "Land Rover's adventurous spirit expressed differently, with TW preserving the Rover identity.", | |
| "status": "verified", | |
| "contributor": null | |
| }, | |
| { | |
| "english": "Volkswagen", | |
| "mainland": "大众", | |
| "taiwan": "福斯", | |
| "brand": "Volkswagen", | |
| "category": "Automotive", | |
| "type": "slogan", | |
| "description": "Volkswagen's original \"people's car\" concept localized with an auspicious phonetic variant in TW.", | |
| "status": "verified", | |
| "contributor": null | |
| }, | |
| { | |
| "english": "Lexus", | |
| "mainland": "雷克萨斯", | |
| "taiwan": "凌志", | |
| "brand": "Lexus", | |
| "category": "Automotive", | |
| "type": "slogan", | |
| "description": "Lexus' luxury status adapted conceptually in TW, using aspiration imagery rather than direct phonetics.", | |
| "status": "verified", | |
| "contributor": null | |
| }, | |
| { | |
| "english": "Airbnb", | |
| "mainland": "爱彼迎", | |
| "taiwan": "愛彼迎", | |
| "brand": "Airbnb", | |
| "category": "Technology", | |
| "type": "slogan", | |
| "description": "Airbnb's hospitality-focused identity creatively adapted via concept-driven naming in the translation.", | |
| "status": "verified", | |
| "contributor": null | |
| }, | |
| { | |
| "english": "WeChat", | |
| "mainland": "微信", | |
| "brand": "微信", | |
| "category": "Technology", | |
| "type": "slogan", | |
| "description": "WeChat's social communication concept succinctly captured beyond literal translation.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": true | |
| }, | |
| { | |
| "english": "Geely", | |
| "mainland": "吉利", | |
| "brand": "吉利", | |
| "category": "Automotive", | |
| "type": "slogan", | |
| "description": "Geely's auspicious Chinese name transcreated phonetically, successfully preserving cultural positivity and international appeal.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": true | |
| }, | |
| { | |
| "english": "Build Your Dream", | |
| "mainland": "比亚迪", | |
| "brand": "比亚迪", | |
| "category": "Automotive", | |
| "type": "slogan", | |
| "description": "BYD's original acronym reinterpreted into an aspirational message emphasizing ambition and innovation across global markets.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": true | |
| }, | |
| { | |
| "english": "Octopus Card", | |
| "mainland": "八達通", | |
| "brand": "八達通", | |
| "category": "Financial Services", | |
| "type": "slogan", | |
| "description": "Hong Kong's transit pass named for reaching in all directions.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": true | |
| }, | |
| { | |
| "english": "Meet Fresh", | |
| "mainland": "鲜芋仙", | |
| "brand": "鲜芋仙", | |
| "category": "Food & Beverage", | |
| "type": "slogan", | |
| "description": "Taiwanese dessert chain's poetic name combining fresh taro with fairy-like imagery.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": true | |
| }, | |
| { | |
| "english": "TikTok", | |
| "mainland": "抖音", | |
| "brand": "抖音", | |
| "category": "Technology", | |
| "type": "slogan", | |
| "description": "ByteDance's platform name capturing musical rhythm and sound vibrations.", | |
| "status": "verified", | |
| "contributor": "", | |
| "isChineseToEnglish": true | |
| } | |
| ]; | |
| // Filter by category if specified | |
| let results = allExamples; | |
| if (category) { | |
| results = allExamples.filter(ex => | |
| ex.category.toLowerCase().includes(category.toLowerCase()) | |
| ); | |
| } | |
| // Simulate network delay | |
| await new Promise(resolve => setTimeout(resolve, 1500 + Math.random() * 2000)); | |
| // Return random subset or all if maxResults is high | |
| const shuffled = results.sort(() => Math.random() - 0.5); | |
| return shuffled.slice(0, Math.min(maxResults, shuffled.length)); | |
| }; | |
| // Initialize cache on startup and load all examples if none exist | |
| const initializeServer = async () => { | |
| console.log('🚀 Starting server initialization...'); | |
| console.log('🔧 Airtable service configured:', airtableService.isConfigured, '(updated)'); | |
| if (airtableService.isConfigured) { | |
| console.log('🔗 Airtable configured, checking for existing data...'); | |
| try { | |
| // Check if Airtable has examples | |
| console.log('📡 Attempting to connect to Airtable...'); | |
| const airtableExamples = await airtableService.getAllExamples(); | |
| if (airtableExamples.length === 0) { | |
| console.log('📦 Airtable is empty, importing 38 examples...'); | |
| // Load examples from simulateOnlineSearch and import to Airtable | |
| const allExamples = await simulateOnlineSearch('', 38); | |
| console.log(`📦 Generated ${allExamples.length} examples, importing to Airtable...`); | |
| // Log first few examples to verify data | |
| console.log('📋 Sample examples:'); | |
| allExamples.slice(0, 3).forEach((ex, i) => { | |
| console.log(` ${i+1}. ${ex.brand} - ${ex.english} (${ex.mainland} / ${ex.taiwan})`); | |
| }); | |
| await airtableService.bulkImport(allExamples); | |
| console.log('✅ Successfully imported all examples to Airtable'); | |
| } else if (airtableExamples.length < 38) { | |
| console.log(`⚠️ Found only ${airtableExamples.length}/38 examples in Airtable, reimporting all...`); | |
| // Clear and reimport all examples | |
| const allExamples = await simulateOnlineSearch('', 38); | |
| console.log(`📦 Generated ${allExamples.length} examples, reimporting to Airtable...`); | |
| await airtableService.bulkImport(allExamples); | |
| console.log('✅ Successfully reimported all examples to Airtable'); | |
| } else { | |
| console.log(`📚 Found ${airtableExamples.length} examples in Airtable`); | |
| } | |
| } catch (error) { | |
| console.error('❌ Failed to initialize Airtable:', error); | |
| console.log('🔄 Falling back to file-based storage...'); | |
| // Fall back to file-based storage | |
| await loadCachedExamples(); | |
| if (transcreationExamples.length === 0) { | |
| console.log('🔍 No cached examples found, loading all 38 examples...'); | |
| await searchTranscreationExamples('', 38); | |
| } | |
| } | |
| } else { | |
| console.log('📁 Using file-based storage...'); | |
| await loadCachedExamples(); | |
| // If no examples are cached, automatically load all 38 examples | |
| if (transcreationExamples.length === 0) { | |
| console.log('🔍 No cached examples found, loading all 38 examples...'); | |
| await searchTranscreationExamples('', 38); | |
| } | |
| } | |
| }; | |
| initializeServer(); | |
| // API Routes | |
| // Get all examples | |
| app.get('/api/examples', async (req, res) => { | |
| try { | |
| const { category, type, random } = req.query; | |
| let examples; | |
| if (airtableService.isConfigured) { | |
| // Use Airtable | |
| const filters = {}; | |
| if (category) filters.category = category; | |
| if (type) filters.type = type; | |
| examples = await airtableService.getExamples(filters); | |
| } else { | |
| // Fallback to in-memory storage | |
| examples = [...transcreationExamples]; | |
| // Filter by category | |
| if (category) { | |
| examples = examples.filter(ex => | |
| ex.category.toLowerCase().includes(category.toLowerCase()) | |
| ); | |
| } | |
| // Filter by type | |
| if (type) { | |
| examples = examples.filter(ex => ex.type === type); | |
| } | |
| } | |
| // Randomize if requested | |
| if (random === 'true') { | |
| examples = examples.sort(() => Math.random() - 0.5); | |
| } | |
| res.json({ | |
| success: true, | |
| data: examples, | |
| total: examples.length | |
| }); | |
| } catch (error) { | |
| console.error('❌ Failed to get examples:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Failed to retrieve examples' | |
| }); | |
| } | |
| }); | |
| // Get random example (or search for new one if cache is low) | |
| app.get('/api/examples/random', async (req, res) => { | |
| try { | |
| let examples; | |
| if (airtableService.isConfigured) { | |
| // Use Airtable | |
| examples = await airtableService.getAllExamples(); | |
| } else { | |
| // Fallback to in-memory storage | |
| // If we have few examples, try to find more | |
| if (transcreationExamples.length < 5) { | |
| console.log('🔍 Cache low, searching for new examples...'); | |
| await searchTranscreationExamples('', 10); | |
| } | |
| examples = transcreationExamples; | |
| } | |
| if (examples.length === 0) { | |
| return res.json({ | |
| success: true, | |
| data: null, | |
| message: 'No examples available yet. Try searching to discover new ones!' | |
| }); | |
| } | |
| const randomIndex = Math.floor(Math.random() * examples.length); | |
| const example = examples[randomIndex]; | |
| res.json({ | |
| success: true, | |
| data: example | |
| }); | |
| } catch (error) { | |
| console.error('❌ Failed to get random example:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Failed to get random example' | |
| }); | |
| } | |
| }); | |
| // Search for new examples online | |
| app.post('/api/examples/search-online', async (req, res) => { | |
| try { | |
| const { category } = req.body; | |
| console.log(`🔍 Online search requested for category: ${category || 'all'}`); | |
| const newExamples = await searchTranscreationExamples(category, 5); | |
| res.json({ | |
| success: true, | |
| data: newExamples, | |
| message: `Found ${newExamples.length} new example${newExamples.length !== 1 ? 's' : ''}`, | |
| totalCached: transcreationExamples.length | |
| }); | |
| } catch (error) { | |
| console.error('Search error:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Online search failed' | |
| }); | |
| } | |
| }); | |
| // Get example by ID | |
| app.get('/api/examples/:id', async (req, res) => { | |
| try { | |
| let example; | |
| if (airtableService.isConfigured) { | |
| // Use Airtable - get all examples and find by ID | |
| const examples = await airtableService.getAllExamples(); | |
| example = examples.find(ex => ex.id === req.params.id); | |
| } else { | |
| // Fallback to in-memory storage | |
| example = transcreationExamples.find(ex => ex.id === req.params.id); | |
| } | |
| if (!example) { | |
| return res.status(404).json({ | |
| success: false, | |
| error: 'Example not found' | |
| }); | |
| } | |
| res.json({ | |
| success: true, | |
| data: example | |
| }); | |
| } catch (error) { | |
| console.error('❌ Failed to get example by ID:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Failed to retrieve example' | |
| }); | |
| } | |
| }); | |
| // Get categories | |
| app.get('/api/categories', async (req, res) => { | |
| try { | |
| let categories; | |
| if (airtableService.isConfigured) { | |
| const examples = await airtableService.getAllExamples(); | |
| categories = [...new Set(examples.map(ex => ex.category))]; | |
| } else { | |
| categories = [...new Set(transcreationExamples.map(ex => ex.category))]; | |
| } | |
| res.json({ | |
| success: true, | |
| data: categories | |
| }); | |
| } catch (error) { | |
| console.error('❌ Failed to get categories:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Failed to retrieve categories' | |
| }); | |
| } | |
| }); | |
| // Get types | |
| app.get('/api/types', async (req, res) => { | |
| try { | |
| let types; | |
| if (airtableService.isConfigured) { | |
| const examples = await airtableService.getAllExamples(); | |
| types = [...new Set(examples.map(ex => ex.type))]; | |
| } else { | |
| types = [...new Set(transcreationExamples.map(ex => ex.type))]; | |
| } | |
| res.json({ | |
| success: true, | |
| data: types | |
| }); | |
| } catch (error) { | |
| console.error('❌ Failed to get types:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Failed to retrieve types' | |
| }); | |
| } | |
| }); | |
| // Search examples | |
| app.get('/api/search', async (req, res) => { | |
| try { | |
| const { q } = req.query; | |
| if (!q) { | |
| return res.json({ | |
| success: true, | |
| data: [], | |
| total: 0 | |
| }); | |
| } | |
| let results; | |
| if (airtableService.isConfigured) { | |
| results = await airtableService.searchExamples(q); | |
| } else { | |
| const searchTerm = q.toLowerCase(); | |
| results = transcreationExamples.filter(ex => | |
| ex.english.toLowerCase().includes(searchTerm) || | |
| ex.mainland.toLowerCase().includes(searchTerm) || | |
| ex.taiwan?.toLowerCase().includes(searchTerm) || | |
| ex.brand.toLowerCase().includes(searchTerm) || | |
| ex.category.toLowerCase().includes(searchTerm) || | |
| ex.description?.toLowerCase().includes(searchTerm) | |
| ); | |
| } | |
| res.json({ | |
| success: true, | |
| data: results, | |
| total: results.length | |
| }); | |
| } catch (error) { | |
| console.error('❌ Failed to search examples:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Failed to search examples' | |
| }); | |
| } | |
| }); | |
| // Get database stats | |
| app.get('/api/stats', async (req, res) => { | |
| try { | |
| let stats; | |
| if (airtableService.isConfigured) { | |
| stats = await airtableService.getStats(); | |
| } else { | |
| stats = { | |
| totalExamples: transcreationExamples.length, | |
| categories: [...new Set(transcreationExamples.map(ex => ex.category))].length, | |
| types: [...new Set(transcreationExamples.map(ex => ex.type))].length, | |
| lastUpdated: transcreationExamples.length > 0 ? | |
| Math.max(...transcreationExamples.map(ex => new Date(ex.dateAdded || Date.now()).getTime())) : null | |
| }; | |
| } | |
| res.json({ | |
| success: true, | |
| data: stats | |
| }); | |
| } catch (error) { | |
| console.error('❌ Failed to get stats:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Failed to retrieve statistics' | |
| }); | |
| } | |
| }); | |
| // Health check | |
| app.get('/api/health', (req, res) => { | |
| res.json({ | |
| success: true, | |
| message: 'Ad Reflections API is running', | |
| timestamp: new Date().toISOString(), | |
| cachedExamples: transcreationExamples.length | |
| }); | |
| }); | |
| // Manual Edit API Endpoints | |
| // Add new example | |
| app.post('/api/examples/add', async (req, res) => { | |
| try { | |
| console.log('📝 Received new example request:', JSON.stringify(req.body, null, 2)); | |
| // Determine if this is a Chinese to English entry (no taiwan field) | |
| const isChineseToEnglish = req.body.hasOwnProperty('isChineseToEnglish') && req.body.isChineseToEnglish; | |
| // Validate required fields based on direction | |
| const requiredFields = isChineseToEnglish | |
| ? ['english', 'mainland', 'brand', 'category', 'type'] | |
| : ['english', 'mainland', 'taiwan', 'brand', 'category', 'type']; | |
| const missingFields = requiredFields.filter(field => !req.body[field]); | |
| if (missingFields.length > 0) { | |
| console.error('❌ Missing required fields:', { | |
| missingFields, | |
| receivedFields: Object.keys(req.body), | |
| receivedValues: req.body | |
| }); | |
| return res.status(400).json({ | |
| success: false, | |
| message: `Missing required fields: ${missingFields.join(', ')}`, | |
| details: { | |
| missingFields, | |
| receivedFields: Object.keys(req.body) | |
| } | |
| }); | |
| } | |
| // Create new example with cleaned data | |
| const newExample = { | |
| ...req.body, | |
| // Clean strings and handle optional fields | |
| english: req.body.english.trim(), | |
| mainland: req.body.mainland.trim(), | |
| taiwan: isChineseToEnglish ? undefined : req.body.taiwan?.trim(), | |
| brand: req.body.brand.trim(), | |
| category: req.body.category.trim(), | |
| type: req.body.type || 'slogan', | |
| description: req.body.description?.trim() ?? '', | |
| status: req.body.status || 'pending', | |
| contributor: req.body.contributor === undefined ? null : req.body.contributor.trim(), | |
| id: Date.now().toString() + Math.random().toString(36).substring(2), | |
| dateAdded: new Date().toISOString() | |
| }; | |
| console.log('✨ Created new example:', JSON.stringify(newExample, null, 2)); | |
| let savedExample; | |
| if (airtableService.isConfigured) { | |
| // Use Airtable | |
| savedExample = await airtableService.addExample(newExample); | |
| } else { | |
| // Fallback to in-memory storage | |
| transcreationExamples.push(newExample); | |
| await saveCachedExamples(); | |
| savedExample = newExample; | |
| } | |
| res.json({ | |
| success: true, | |
| message: 'Example added successfully', | |
| example: savedExample | |
| }); | |
| } catch (error) { | |
| console.error('❌ Error adding example:', error); | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Error adding example', | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Update existing example | |
| app.put('/api/examples/:id', async (req, res) => { | |
| try { | |
| const { id } = req.params; | |
| console.log(`📝 UPDATE REQUEST for ID: ${id}`); | |
| console.log(`📝 REQUEST BODY:`, JSON.stringify(req.body, null, 2)); | |
| let existingExample; | |
| if (airtableService.isConfigured) { | |
| // Use Airtable - get all examples and find by ID | |
| const examples = await airtableService.getAllExamples(); | |
| existingExample = examples.find(ex => ex.id === id); | |
| } else { | |
| // Fallback to in-memory storage | |
| const index = transcreationExamples.findIndex(ex => ex.id === id); | |
| existingExample = index !== -1 ? transcreationExamples[index] : null; | |
| } | |
| if (!existingExample) { | |
| console.log(`❌ Example with ID ${id} not found`); | |
| return res.status(404).json({ | |
| success: false, | |
| message: 'Example not found' | |
| }); | |
| } | |
| console.log(`📝 FOUND EXAMPLE:`, JSON.stringify(existingExample, null, 2)); | |
| // Determine if this is a Chinese to English entry (no taiwan field) | |
| const isChineseToEnglish = !req.body.hasOwnProperty('taiwan'); | |
| // Validate required fields based on direction | |
| const requiredFields = isChineseToEnglish | |
| ? ['english', 'mainland', 'brand', 'category', 'type'] | |
| : ['english', 'mainland', 'taiwan', 'brand', 'category', 'type']; | |
| const missingFields = requiredFields.filter(field => !req.body[field]); | |
| if (missingFields.length > 0) { | |
| console.error('❌ Missing required fields:', { | |
| missingFields, | |
| receivedFields: Object.keys(req.body), | |
| receivedValues: req.body | |
| }); | |
| return res.status(400).json({ | |
| success: false, | |
| message: `Missing required fields: ${missingFields.join(', ')}`, | |
| details: { | |
| missingFields, | |
| receivedFields: Object.keys(req.body) | |
| } | |
| }); | |
| } | |
| // Preserve original dateAdded and merge updates | |
| const updatedData = { | |
| ...existingExample, // Start with existing data | |
| ...req.body, // Merge in updates | |
| id, // Ensure ID doesn't change | |
| dateAdded: existingExample.dateAdded, // Preserve original date | |
| lastModified: new Date().toISOString(), // Add last modified timestamp | |
| // Handle optional fields - if not provided in request, keep existing value | |
| status: req.body.status ?? existingExample.status ?? 'pending', | |
| contributor: req.body.contributor === undefined ? existingExample.contributor : req.body.contributor, | |
| type: req.body.type ?? existingExample.type ?? 'slogan', | |
| description: req.body.description ?? existingExample.description ?? '' | |
| }; | |
| let updatedExample; | |
| if (airtableService.isConfigured) { | |
| // Use Airtable | |
| updatedExample = await airtableService.updateExample(id, updatedData); | |
| } else { | |
| // Fallback to in-memory storage | |
| const index = transcreationExamples.findIndex(ex => ex.id === id); | |
| transcreationExamples[index] = updatedData; | |
| await saveCachedExamples(); | |
| updatedExample = updatedData; | |
| } | |
| console.log(`📝 UPDATED EXAMPLE:`, JSON.stringify(updatedExample, null, 2)); | |
| console.log(`✅ UPDATE COMPLETED for ID: ${id}`); | |
| res.json({ | |
| success: true, | |
| message: 'Example updated successfully', | |
| data: updatedExample | |
| }); | |
| } catch (error) { | |
| console.error('❌ Error updating example:', error); | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to update example', | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Delete example | |
| app.delete('/api/examples/:id', async (req, res) => { | |
| try { | |
| const { id } = req.params; | |
| if (airtableService.isConfigured) { | |
| // Use Airtable | |
| await airtableService.deleteExample(id); | |
| } else { | |
| // Fallback to in-memory storage | |
| const index = transcreationExamples.findIndex(ex => ex.id === id); | |
| if (index === -1) { | |
| return res.status(404).json({ | |
| success: false, | |
| error: 'Example not found' | |
| }); | |
| } | |
| transcreationExamples.splice(index, 1); | |
| await saveCachedExamples(); | |
| } | |
| res.json({ | |
| success: true, | |
| message: 'Example deleted successfully' | |
| }); | |
| } catch (error) { | |
| console.error('❌ Failed to delete example:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Failed to delete example' | |
| }); | |
| } | |
| }); | |
| // Serve static files from React build (for production) | |
| if (process.env.NODE_ENV === 'production') { | |
| app.use(express.static(path.join(__dirname, '../client/build'))); | |
| app.get('*', (req, res) => { | |
| res.sendFile(path.join(__dirname, '../client/build', 'index.html')); | |
| }); | |
| } | |
| app.listen(PORT, () => { | |
| console.log(`🚀 Ad Reflections API running on port ${PORT}`); | |
| console.log(`📊 Cached examples: ${transcreationExamples.length}`); | |
| }); | |
| module.exports = app; |