← back to Handbag Authentication

api/mobile-api.js

366 lines

/**
 * Mobile API Endpoints
 * Optimized for mobile devices with compressed responses
 */

const fs = require('fs');
const path = require('path');
const csv = require('csv-parser');

class MobileAPI {
    constructor() {
        this.dataPath = path.join(__dirname, '..', 'handbag_data', 'processed');
        this.cache = {
            retailer: null,
            luxury: null,
            museum: null,
            lastUpdate: null
        };
        this.cacheTimeout = 5 * 60 * 1000; // 5 minutes
    }

    /**
     * Load and cache data
     */
    async loadData(type = 'all') {
        const now = Date.now();

        // Check cache
        if (this.cache.lastUpdate && (now - this.cache.lastUpdate) < this.cacheTimeout) {
            return this.getCachedData(type);
        }

        // Load fresh data
        await Promise.all([
            this.loadRetailerData(),
            this.loadLuxuryData(),
            this.loadMuseumData()
        ]);

        this.cache.lastUpdate = now;
        return this.getCachedData(type);
    }

    /**
     * Load retailer handbags
     */
    async loadRetailerData() {
        return new Promise((resolve, reject) => {
            const results = [];
            const filePath = path.join(this.dataPath, 'retailer_handbags.csv');

            if (!fs.existsSync(filePath)) {
                this.cache.retailer = [];
                return resolve([]);
            }

            fs.createReadStream(filePath)
                .pipe(csv())
                .on('data', (row) => {
                    results.push({
                        id: `retailer_${results.length + 1}`,
                        source: 'retailer',
                        brand: row.brand || 'Unknown',
                        name: row.product_name || 'Unknown',
                        price: parseFloat(row.price_numeric) || 0,
                        priceOriginal: row.price_original || '',
                        type: row.type || 'handbag',
                        retailer: row.retailer || '',
                        imageUrl: row.image_url || '',
                        pageUrl: row.page_url || '',
                        has360: false // Retailer data doesn't have 360
                    });
                })
                .on('end', () => {
                    this.cache.retailer = results;
                    resolve(results);
                })
                .on('error', reject);
        });
    }

    /**
     * Load luxury handbags
     */
    async loadLuxuryData() {
        return new Promise((resolve, reject) => {
            const results = [];
            const filePath = path.join(this.dataPath, 'luxury_handbags.csv');

            if (!fs.existsSync(filePath)) {
                this.cache.luxury = [];
                return resolve([]);
            }

            fs.createReadStream(filePath)
                .pipe(csv())
                .on('data', (row) => {
                    results.push({
                        id: `luxury_${results.length + 1}`,
                        source: 'luxury',
                        brand: row.brand || 'Unknown',
                        name: row.bag_style || 'Unknown',
                        price: parseFloat(row.price) || 0,
                        bagStyle: row.bag_style || '',
                        skinType: row.skin_type || '',
                        hardwareMetal: row.hardware_metal || '',
                        majorColor: row.major_color || '',
                        innerMaterial: row.inner_material || '',
                        type: 'luxury',
                        has360: false // Mark luxury items as potentially having 360
                    });
                })
                .on('end', () => {
                    this.cache.luxury = results;
                    resolve(results);
                })
                .on('error', reject);
        });
    }

    /**
     * Load museum data
     */
    async loadMuseumData() {
        try {
            const filePath = path.join(this.dataPath, 'museum_handbags_normalized.json');

            if (!fs.existsSync(filePath)) {
                this.cache.museum = [];
                return [];
            }

            const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));

            this.cache.museum = data.map((item, index) => ({
                id: `museum_${index + 1}`,
                source: 'museum',
                brand: item.artist_maker || 'Historical',
                name: item.title || 'Untitled',
                price: null,
                dated: item.dated || '',
                culture: item.culture || '',
                classification: item.classification || '',
                imageUrl: item.image_url || '',
                museumUrl: item.url || '',
                type: 'historical',
                has360: false
            }));

            return this.cache.museum;
        } catch (error) {
            console.error('Error loading museum data:', error);
            this.cache.museum = [];
            return [];
        }
    }

    /**
     * Get cached data
     */
    getCachedData(type) {
        switch (type) {
            case 'retailer':
                return this.cache.retailer || [];
            case 'luxury':
                return this.cache.luxury || [];
            case 'museum':
                return this.cache.museum || [];
            case 'all':
            default:
                return [
                    ...(this.cache.retailer || []),
                    ...(this.cache.luxury || []),
                    ...(this.cache.museum || [])
                ];
        }
    }

    /**
     * Get paginated handbags
     */
    async getHandbags(options = {}) {
        const {
            page = 1,
            limit = 20,
            category = 'all',
            brand = null,
            minPrice = null,
            maxPrice = null,
            search = null,
            sortBy = 'recent'
        } = options;

        // Load data
        let data = await this.loadData(category);

        // Filter by brand
        if (brand) {
            data = data.filter(item =>
                item.brand.toLowerCase().includes(brand.toLowerCase())
            );
        }

        // Filter by price range
        if (minPrice !== null) {
            data = data.filter(item => item.price >= minPrice);
        }
        if (maxPrice !== null) {
            data = data.filter(item => item.price <= maxPrice);
        }

        // Search filter
        if (search) {
            const searchLower = search.toLowerCase();
            data = data.filter(item =>
                item.brand.toLowerCase().includes(searchLower) ||
                item.name.toLowerCase().includes(searchLower) ||
                (item.type && item.type.toLowerCase().includes(searchLower))
            );
        }

        // Sort
        switch (sortBy) {
            case 'price_asc':
                data.sort((a, b) => (a.price || 0) - (b.price || 0));
                break;
            case 'price_desc':
                data.sort((a, b) => (b.price || 0) - (a.price || 0));
                break;
            case 'brand':
                data.sort((a, b) => a.brand.localeCompare(b.brand));
                break;
            default:
                // Recent first (default order)
                break;
        }

        // Paginate
        const start = (page - 1) * limit;
        const end = start + limit;
        const paginated = data.slice(start, end);

        return {
            data: paginated,
            pagination: {
                page,
                limit,
                total: data.length,
                totalPages: Math.ceil(data.length / limit),
                hasMore: end < data.length
            }
        };
    }

    /**
     * Get statistics
     */
    async getStats() {
        const data = await this.loadData('all');

        const brands = new Set(data.map(item => item.brand));
        const prices = data.filter(item => item.price > 0).map(item => item.price);

        const stats = {
            totalItems: data.length,
            retailerItems: (this.cache.retailer || []).length,
            luxuryItems: (this.cache.luxury || []).length,
            museumItems: (this.cache.museum || []).length,
            totalBrands: brands.size,
            priceRange: {
                min: prices.length > 0 ? Math.min(...prices) : 0,
                max: prices.length > 0 ? Math.max(...prices) : 0,
                avg: prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : 0
            },
            topBrands: this.getTopBrands(data, 10),
            categoryBreakdown: {
                retailer: (this.cache.retailer || []).length,
                luxury: (this.cache.luxury || []).length,
                historical: (this.cache.museum || []).length
            }
        };

        return stats;
    }

    /**
     * Get top brands
     */
    getTopBrands(data, limit = 10) {
        const brandCounts = {};

        data.forEach(item => {
            const brand = item.brand || 'Unknown';
            brandCounts[brand] = (brandCounts[brand] || 0) + 1;
        });

        return Object.entries(brandCounts)
            .sort((a, b) => b[1] - a[1])
            .slice(0, limit)
            .map(([brand, count]) => ({ brand, count }));
    }

    /**
     * Get product by ID
     */
    async getProductById(id) {
        const data = await this.loadData('all');
        return data.find(item => item.id === id) || null;
    }

    /**
     * Get brands list
     */
    async getBrands() {
        const data = await this.loadData('all');
        const brands = new Set(data.map(item => item.brand));

        return Array.from(brands).sort().map(brand => {
            const count = data.filter(item => item.brand === brand).length;
            const avgPrice = data
                .filter(item => item.brand === brand && item.price > 0)
                .reduce((sum, item, _, arr) =>
                    arr.length > 0 ? sum + item.price / arr.length : 0
                , 0);

            return {
                name: brand,
                count,
                avgPrice: Math.round(avgPrice)
            };
        });
    }

    /**
     * Search suggestions
     */
    async getSearchSuggestions(query, limit = 10) {
        if (!query || query.length < 2) {
            return [];
        }

        const data = await this.loadData('all');
        const queryLower = query.toLowerCase();

        const suggestions = new Set();

        // Add brand matches
        data.forEach(item => {
            if (item.brand.toLowerCase().includes(queryLower)) {
                suggestions.add(item.brand);
            }
        });

        // Add type matches
        data.forEach(item => {
            if (item.type && item.type.toLowerCase().includes(queryLower)) {
                suggestions.add(item.type);
            }
        });

        return Array.from(suggestions).slice(0, limit);
    }
}

module.exports = MobileAPI;