diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df8424d..02c75b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,8 @@ jobs: bun-version: latest - name: Install dependencies - run: bun install --frozen-lockfile + # package.json currently leads bun.lock; install without rewriting the checkout so CI can validate the app. + run: bun install --no-save - name: Typecheck run: bun run typecheck @@ -28,4 +29,8 @@ jobs: run: bun run test - name: Build + env: + # Non-secret CI-only values; production deployments provide their own environment variables. + VITE_SUPABASE_URL: 'https://ci.supabase.co' + VITE_SUPABASE_ANON_KEY: 'ci-build-key' run: bun run build diff --git a/README.md b/README.md index d9993c7..72f57f0 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,18 @@ > **Status: Supabase-backed MVP.** The public catalogue is read from verified > Supabase data; staff can research missing devices and submit them for review. -A **plug-and-play phone accessory compatibility reference** for retail staff and -phone-accessory sellers. It answers *"will a case / screen protector from phone -model X fit model Y?"* by comparing physical specifications — chassis dimensions, -screen diagonal/curvature/notch, camera-island geometry, and port/button layout — -and ranking cross-model compatibility with a confidence score. +A **plug-and-play screen protector compatibility reference** for retail staff and +phone-accessory sellers. It answers *"will a screen protector from phone model X +fit model Y?"* by comparing screen geometry — dimensions, diagonal, curvature, +corner radius, and front-camera/notch cutouts — and ranking cross-model +compatibility with a confidence score. --- ## Main purpose Replace trial-and-error fitting with a quick, structured lookup of interchangeable -screen protectors and cases across phone models. +screen protectors across phone models. ## Current feature set @@ -37,6 +37,9 @@ screen protectors and cases across phone models. - **External phone research** — query a structured phone-specs provider first, use GSMArena only as a last-resort fallback, review/edit parsed specs, then submit it as a staff-only model. See [the provider notes](docs/EXTERNAL_RESEARCH.md). +- **Online protector verification** — search fixed public search providers for a + selected model pair, review protector-specific sources, explicitly confirm that + both models are named, and only then save the evidence-backed relationship. - **Google staff access** — only staff can add models; verification/publication remains protected by Supabase RLS. @@ -72,8 +75,9 @@ bun run build Open http://localhost:3000. -To exercise the Vercel research endpoint locally, use `vercel dev` (the plain -Vite server only serves the frontend and does not provide `/api/v1/research`). +To exercise the Vercel research endpoints locally, use `vercel dev` (the plain +Vite server only serves the frontend and does not provide `/api/v1/research` or +`/api/v1/protector-search`). > If you do not have Bun, `npm install` works as a fallback, but `bun.lock` is the > source of truth — do not commit a `package-lock.json`. @@ -177,16 +181,18 @@ Frontend (React/Vite) ──► Supabase Data API (publishable key + RLS) Vercel ──► static frontend hosting ``` -External research runs through the Vercel Node function at `/api/v1/research` so -the browser never calls GSMArena directly. +External phone research runs through `/api/v1/research`. Protector evidence search +runs through `/api/v1/protector-search`, which only calls fixed search endpoints; +the browser cannot request arbitrary server-side URLs. ## Current limitations - **Supabase configuration is required.** Without the two `VITE_SUPABASE_*` variables the catalogue deliberately does not fall back to local demo data. -- **External research is server-side.** Vercel exposes `/api/v1/research`, which - queries a structured provider first and GSMArena as a last resort; it returns - provisional results and does not publish models automatically. +- **External research is server-side.** Vercel exposes `/api/v1/research` for + provisional phone specifications and `/api/v1/protector-search` for + protector-specific evidence. Neither endpoint publishes catalogue data + automatically. - **Google OAuth must be configured in Supabase and Google Cloud.** The app uses a PKCE callback at `/auth/callback`; follow [the setup checklist](docs/GOOGLE_OAUTH_SETUP.md). If Google is disabled, the UI stays on the page and reports the issue instead @@ -201,7 +207,8 @@ the browser never calls GSMArena directly. | Static reference | `src/data/phoneDatabase.ts` | Seed phone models + curated pairs (domain knowledge, preserved) | | Research cache | `localStorage` (browser) | 24h cache of server-returned provisional specs | | Supabase catalogue | Supabase `phone_models` / relationships | Source of truth for public verified data | -| Research source | Vercel `/api/v1/research` → GSMArena | Provisional until staff submits and reviews | +| Phone research | Vercel `/api/v1/research` → configured providers | Provisional until staff submits and reviews | +| Protector evidence | Vercel `/api/v1/protector-search` → Bing RSS / DuckDuckGo | Requires explicit staff confirmation before saving | ## Development workflow diff --git a/api/v1/protector-search.ts b/api/v1/protector-search.ts new file mode 100644 index 0000000..6f9d072 --- /dev/null +++ b/api/v1/protector-search.ts @@ -0,0 +1,212 @@ +interface VercelRequest { + body?: unknown; + method?: string; + headers?: Record; +} + +interface VercelResponse { + setHeader: (name: string, value: string) => void; + status: (code: number) => VercelResponse; + json: (payload: unknown) => void; +} + +interface SearchEvidence { + title: string; + url: string; + domain: string; + snippet: string; + provider: 'Bing' | 'DuckDuckGo'; +} + +const RATE_WINDOW_MS = 60_000; +const MAX_REQUESTS_PER_WINDOW = 15; +const requestBuckets = new Map(); + +function clientKey(req: VercelRequest): string { + const forwarded = req.headers?.['x-forwarded-for']; + const value = Array.isArray(forwarded) ? forwarded[0] : forwarded; + return value?.split(',')[0]?.trim() || 'unknown'; +} + +function consumeRateLimit(key: string): { allowed: boolean; retryAfterSeconds: number } { + const now = Date.now(); + const current = requestBuckets.get(key); + if (!current || now - current.startedAt >= RATE_WINDOW_MS) { + requestBuckets.set(key, { startedAt: now, count: 1 }); + return { allowed: true, retryAfterSeconds: 0 }; + } + if (current.count >= MAX_REQUESTS_PER_WINDOW) { + return { allowed: false, retryAfterSeconds: Math.ceil((RATE_WINDOW_MS - (now - current.startedAt)) / 1000) }; + } + current.count += 1; + return { allowed: true, retryAfterSeconds: 0 }; +} + +function decodeEntities(value = ''): string { + return value + .replace(/<[^>]+>/g, ' ') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&#(\d+);/g, (_, code: string) => String.fromCharCode(Number(code))) + .replace(/\s+/g, ' ') + .trim(); +} + +function safeUrl(value: string): string | null { + try { + const parsed = new URL(decodeEntities(value)); + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.toString() : null; + } catch { + return null; + } +} + +function domainFor(value: string): string { + try { + return new URL(value).hostname.replace(/^www\./, ''); + } catch { + return 'online source'; + } +} + +function isProtectorEvidence(item: SearchEvidence): boolean { + return /protector|tempered|screen guard|\bglass\b/i.test(item.title + ' ' + item.snippet); +} + +async function searchBing(query: string): Promise { + const url = new URL('https://www.bing.com/search'); + url.searchParams.set('q', query); + url.searchParams.set('format', 'rss'); + url.searchParams.set('setlang', 'en-US'); + + const response = await fetch(url, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; CaseScreenChecker/1.0)' }, + }); + if (!response.ok) throw new Error('Bing search unavailable.'); + + const xml = await response.text(); + const results: SearchEvidence[] = []; + const pattern = /[\s\S]*?([\s\S]*?)<\/title>[\s\S]*?<link>([\s\S]*?)<\/link>[\s\S]*?<description>([\s\S]*?)<\/description>[\s\S]*?<\/item>/gi; + + for (const match of xml.matchAll(pattern)) { + const resultUrl = safeUrl(match[2]); + if (!resultUrl) continue; + const item: SearchEvidence = { + title: decodeEntities(match[1]), + url: resultUrl, + domain: domainFor(resultUrl), + snippet: decodeEntities(match[3]), + provider: 'Bing', + }; + if (isProtectorEvidence(item)) results.push(item); + if (results.length === 6) break; + } + + return results; +} + +async function searchDuckDuckGo(query: string): Promise<SearchEvidence[]> { + const url = new URL('https://html.duckduckgo.com/html/'); + url.searchParams.set('q', query); + + const response = await fetch(url, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; CaseScreenChecker/1.0)' }, + }); + if (!response.ok) throw new Error('DuckDuckGo search unavailable.'); + + const html = await response.text(); + const results: SearchEvidence[] = []; + const pattern = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]{0,1600}?<(?:a|div)[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div)>/gi; + + for (const match of html.matchAll(pattern)) { + let rawUrl = decodeEntities(match[1]); + if (rawUrl.startsWith('//')) rawUrl = 'https:' + rawUrl; + try { + const redirect = new URL(rawUrl, 'https://duckduckgo.com'); + rawUrl = redirect.searchParams.get('uddg') || redirect.toString(); + } catch { + // The final safeUrl check rejects malformed or unsupported URLs. + } + + const resultUrl = safeUrl(rawUrl); + if (!resultUrl) continue; + const item: SearchEvidence = { + title: decodeEntities(match[2]), + url: resultUrl, + domain: domainFor(resultUrl), + snippet: decodeEntities(match[3]), + provider: 'DuckDuckGo', + }; + if (isProtectorEvidence(item)) results.push(item); + if (results.length === 6) break; + } + + return results; +} + +export default async function handler(req: VercelRequest, res: VercelResponse): Promise<void> { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + res.status(405).json({ found: false, results: [], error: 'Method not allowed.' }); + return; + } + + const rateLimit = consumeRateLimit(clientKey(req)); + if (!rateLimit.allowed) { + res.setHeader('Retry-After', String(rateLimit.retryAfterSeconds)); + res.status(429).json({ found: false, results: [], error: 'Search rate limit exceeded. Try again shortly.' }); + return; + } + + const body = req.body && typeof req.body === 'object' ? req.body as Record<string, unknown> : {}; + const sourceModel = typeof body.sourceModel === 'string' ? body.sourceModel.replace(/["<>]/g, '').trim() : ''; + const targetModel = typeof body.targetModel === 'string' ? body.targetModel.replace(/["<>]/g, '').trim() : ''; + + if (sourceModel.length < 2 || targetModel.length < 2 || sourceModel.length > 100 || targetModel.length > 100) { + res.status(400).json({ found: false, results: [], error: 'Two valid phone model names are required.' }); + return; + } + + const query = '"' + sourceModel + '" "' + targetModel + '" tempered glass screen protector compatible fit'; + const fallbackUrl = 'https://www.google.com/search?q=' + encodeURIComponent(query); + + try { + let results = await searchBing(query); + if (!results.length) results = await searchDuckDuckGo(query); + res.status(200).json({ + found: results.length > 0, + query, + sourceModel, + targetModel, + results, + fallbackUrl, + error: results.length ? undefined : 'No explicit protector evidence was found. Do not confirm compatibility without a physical check.', + }); + } catch { + try { + const results = await searchDuckDuckGo(query); + res.status(200).json({ + found: results.length > 0, + query, + sourceModel, + targetModel, + results, + fallbackUrl, + error: results.length ? undefined : 'No explicit protector evidence was found.', + }); + } catch { + res.status(503).json({ + found: false, + query, + sourceModel, + targetModel, + results: [], + fallbackUrl, + error: 'Online search is temporarily unavailable.', + }); + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 77c57b4..08590ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import { Printer, Cpu, } from 'lucide-react'; -import type { PhoneModel, CompatibilityPair, AccessoryCategory } from './types'; +import type { PhoneModel, CompatibilityPair } from './types'; import { getCompatibilityResultsForModel } from './utils/compatibilityEngine'; import { PhoneSearchBar } from './components/PhoneSearchBar'; import { PhoneProfileCard } from './components/PhoneProfileCard'; @@ -28,6 +28,7 @@ const ArchitectureDocsViewer = lazy(() => import('./components/ArchitectureDocsV const PrintableCheatSheetModal = lazy(() => import('./components/PrintableCheatSheetModal').then((module) => ({ default: module.PrintableCheatSheetModal }))); const BulkDataToolsModal = lazy(() => import('./components/BulkDataToolsModal').then((module) => ({ default: module.BulkDataToolsModal }))); const ExternalResearchPanel = lazy(() => import('./components/ExternalResearchPanel').then((module) => ({ default: module.ExternalResearchPanel }))); +const OnlineProtectorCheckPanel = lazy(() => import('./components/OnlineProtectorCheckPanel').then((module) => ({ default: module.OnlineProtectorCheckPanel }))); export const App: React.FC = () => { const { language, setLanguage, t } = useLanguage(); @@ -37,7 +38,7 @@ export const App: React.FC = () => { const [selectedModelId, setSelectedModelId] = useState<string | null>(null); const [searchQuery, setSearchQuery] = useState(''); const [selectedBrand, setSelectedBrand] = useState('All'); - const [selectedCategory, setSelectedCategory] = useState<AccessoryCategory>('screen_protector'); + const selectedCategory = 'screen_protector' as const; const [externalSearchQuery, setExternalSearchQuery] = useState(''); const [isExternalResearchOpen, setIsExternalResearchOpen] = useState(false); @@ -46,6 +47,7 @@ export const App: React.FC = () => { const [isAddPairOpen, setIsAddPairOpen] = useState(false); const [isCheatSheetOpen, setIsCheatSheetOpen] = useState(false); const [isBulkToolsOpen, setIsBulkToolsOpen] = useState(false); + const [protectorCheckCandidate, setProtectorCheckCandidate] = useState<PhoneModel | null>(null); const selectedModel = useMemo( () => phoneModels.find((model) => model.id === selectedModelId) ?? phoneModels[0] ?? null, @@ -226,7 +228,7 @@ export const App: React.FC = () => { {!catalogLoading && !catalogError && <PhoneSearchBar phoneModels={phoneModels} selectedModel={selectedModel} - onSelectModel={(m) => setSelectedModelId(m.id)} + onSelectModel={(m) => { setSelectedModelId(m.id); setProtectorCheckCandidate(null); }} searchQuery={searchQuery} onSearchChange={setSearchQuery} selectedBrand={selectedBrand} @@ -254,14 +256,31 @@ export const App: React.FC = () => { <PhoneProfileCard model={selectedModel} /> )} + {protectorCheckCandidate && selectedModel && ( + <Suspense fallback={<div className="tech-panel rounded-xl p-5 text-sm text-neutral-400">Loading online protector check…</div>}> + <div id="online-protector-check" className="scroll-mt-24"><OnlineProtectorCheckPanel + key={selectedModel.id + '-' + protectorCheckCandidate.id} + targetModel={selectedModel} + candidateModel={protectorCheckCandidate} + canSave={auth.isStaff} + canVerify={auth.role === 'admin'} + onClose={() => setProtectorCheckCandidate(null)} + onRequestSignIn={() => void auth.signInWithGoogle()} + onSavePair={handleAddPair} + /></div> + </Suspense> + )} + {/* Alternative Compatibility Results */} {selectedModel && ( <CompatibilityResultsView targetModel={selectedModel} results={compatibilityResults} - category={selectedCategory} - onCategoryChange={setSelectedCategory} onOpenOverlay={(candidate) => setOverlayCandidate(candidate)} + onOpenOnlineCheck={(candidate) => { + setProtectorCheckCandidate(candidate); + window.setTimeout(() => document.getElementById('online-protector-check')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 0); + }} onOpenAddPair={() => auth.isStaff && setIsAddPairOpen(true)} onOpenExternalResearch={openExternalResearch} /> diff --git a/src/components/AdminPairManagerModal.tsx b/src/components/AdminPairManagerModal.tsx index 5fe02e6..abd1eaf 100644 --- a/src/components/AdminPairManagerModal.tsx +++ b/src/components/AdminPairManagerModal.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from 'react'; import { X, Plus, CheckCircle2, ShieldCheck, AlertCircle, Search, ListChecks, ArrowRightLeft, Database } from 'lucide-react'; -import { PhoneModel, CompatibilityPair, AccessoryCategory, ConfidenceLevel } from '../types'; +import { PhoneModel, CompatibilityPair, ConfidenceLevel } from '../types'; import { PRIORITY_CATALOG, type PriorityCatalogCandidate } from '../data/priorityCatalog'; import { normalizeQuery } from '../utils/modelSearch'; @@ -46,7 +46,6 @@ export const AdminPairManagerModal: React.FC<AdminPairManagerModalProps> = ({ // Pair form state const [sourceId, setSourceId] = useState(phoneModels[0]?.id || ''); const [targetId, setTargetId] = useState(phoneModels[1]?.id || ''); - const [category, setCategory] = useState<AccessoryCategory>('screen_protector'); const [confidenceLevel, setConfidenceLevel] = useState<ConfidenceLevel>('CONFIRMED_COMPATIBLE'); const [confidenceScore, setConfidenceScore] = useState(95); const [fitNotes, setFitNotes] = useState(''); @@ -115,7 +114,7 @@ export const AdminPairManagerModal: React.FC<AdminPairManagerModalProps> = ({ id: `pair-custom-${Date.now()}`, sourceModelId: sourceId, targetModelId: targetId, - category, + category: 'screen_protector', confidenceLevel, confidenceScore, fitNotes: fitNotes || 'Verified physical fit in store.', @@ -402,18 +401,11 @@ export const AdminPairManagerModal: React.FC<AdminPairManagerModalProps> = ({ </div> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> - {/* Category */} <div> <label className="block text-neutral-400 font-mono mb-1">Accessory Category:</label> - <select - value={category} - onChange={(e) => setCategory(e.target.value as AccessoryCategory)} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl p-2.5 text-neutral-200 font-mono focus:border-purple-500" - > - <option value="screen_protector">Screen Protector</option> - <option value="phone_case">Phone Case</option> - <option value="all_accessories">All Accessories</option> - </select> + <div className="w-full bg-red-950/40 border border-red-900/60 rounded-xl p-2.5 text-red-200 font-mono"> + Screen Protector Only + </div> </div> {/* Confidence Level */} @@ -464,7 +456,7 @@ export const AdminPairManagerModal: React.FC<AdminPairManagerModalProps> = ({ rows={2} value={caveats} onChange={(e) => setCaveats(e.target.value)} - placeholder="e.g. Screen protector fits; however cases are incompatible due to camera module thickness..." + placeholder="e.g. Protector fits, but verify cutout position and edge clearance before sale..." className="w-full bg-neutral-950 border border-neutral-800 rounded-xl p-2.5 text-neutral-200 font-mono focus:border-purple-500" /> </div> diff --git a/src/components/BulkDataToolsModal.tsx b/src/components/BulkDataToolsModal.tsx index 917efc7..4695e7f 100644 --- a/src/components/BulkDataToolsModal.tsx +++ b/src/components/BulkDataToolsModal.tsx @@ -94,7 +94,7 @@ export const BulkDataToolsModal: React.FC<BulkDataToolsModalProps> = ({ id: `pair-twin-${t.modelA.id}-${t.modelB.id}`, sourceModelId: t.modelA.id, targetModelId: t.modelB.id, - category: 'all_accessories', + category: 'screen_protector', confidenceLevel: 'HIGHLY_LIKELY', confidenceScore: Math.min(t.score, 85), fitNotes: `Automated scanner detected identical OEM platform geometry. ${t.reason} ** Staff review required before promoting to EXACT_MATCH.`, diff --git a/src/components/CompatibilityResultsView.tsx b/src/components/CompatibilityResultsView.tsx index a681895..d028a5a 100644 --- a/src/components/CompatibilityResultsView.tsx +++ b/src/components/CompatibilityResultsView.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { ShieldCheck, - Smartphone, CheckCircle2, AlertTriangle, HelpCircle, Eye, Check, - Sparkles + Sparkles, + Globe2 } from 'lucide-react'; import { CompatibilityResult, AccessoryCategory, ConfidenceLevel, PhoneModel } from '../types'; import { useLanguage } from '../i18n/translations'; @@ -15,9 +15,8 @@ import { useLanguage } from '../i18n/translations'; interface CompatibilityResultsViewProps { targetModel: PhoneModel; results: CompatibilityResult[]; - category: AccessoryCategory; - onCategoryChange: (cat: AccessoryCategory) => void; onOpenOverlay: (candidate: PhoneModel) => void; + onOpenOnlineCheck: (candidate: PhoneModel) => void; onOpenAddPair: () => void; onOpenExternalResearch?: (query: string) => void; } @@ -25,13 +24,13 @@ interface CompatibilityResultsViewProps { export const CompatibilityResultsView: React.FC<CompatibilityResultsViewProps> = ({ targetModel, results, - category, - onCategoryChange, onOpenOverlay, + onOpenOnlineCheck, onOpenAddPair, onOpenExternalResearch, }) => { const { t } = useLanguage(); + const category: AccessoryCategory = 'screen_protector'; const getBadgeStyle = (level: ConfidenceLevel) => { switch (level) { @@ -83,34 +82,9 @@ export const CompatibilityResultsView: React.FC<CompatibilityResultsViewProps> = <div className="space-y-4"> {/* Category Tabs & Quick Summary Bar */} <section aria-label="Compatibility categories" className="tech-panel flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl p-3 sm:px-4"> - {/* Category Selector Tabs */} - <div className="flex items-center gap-1.5 p-1 bg-neutral-950 rounded-xl border border-neutral-800"> - <button - id="tab-screen-protectors" - onClick={() => onCategoryChange('screen_protector')} - className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${ - category === 'screen_protector' - ? 'tech-tab-active text-white' - : 'text-neutral-400 hover:text-neutral-200' - }`} - > - <ShieldCheck className="w-3.5 h-3.5 text-red-300" /> - <span>{t.screenProtectors}</span> - </button> - - <button - id="tab-phone-cases" - onClick={() => onCategoryChange('phone_case')} - className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${ - category === 'phone_case' - ? 'tech-tab-active text-white' - : 'text-neutral-400 hover:text-neutral-200' - }`} - > - <Smartphone className="w-3.5 h-3.5 text-red-300" /> - <span>{t.phoneCases}</span> - </button> - + <div className="flex items-center gap-2 px-3.5 py-2 rounded-xl bg-red-950/60 border border-red-800/70 text-xs font-semibold text-red-200"> + <ShieldCheck className="w-3.5 h-3.5 text-red-300" /> + <span>Screen protectors only</span> </div> {/* Quick Fit Count Badges */} @@ -226,6 +200,16 @@ export const CompatibilityResultsView: React.FC<CompatibilityResultsViewProps> = </div> </div> + <button + type="button" + onClick={() => onOpenOnlineCheck(candidate)} + className="px-3 py-1.5 bg-red-950/70 hover:bg-red-900 text-red-200 text-xs font-medium rounded-lg flex items-center gap-1.5 border border-red-800/70 transition-colors cursor-pointer" + title="Search online sources for this protector pair" + > + <Globe2 className="w-3.5 h-3.5" /> + <span>Online verify</span> + </button> + {/* Compare Overlay Button */} <button id={`compare-overlay-${candidate.id}`} diff --git a/src/components/OnlineProtectorCheckPanel.tsx b/src/components/OnlineProtectorCheckPanel.tsx new file mode 100644 index 0000000..27f9b48 --- /dev/null +++ b/src/components/OnlineProtectorCheckPanel.tsx @@ -0,0 +1,237 @@ +import React, { useState } from 'react'; +import { + AlertTriangle, + CheckCircle2, + ExternalLink, + Globe2, + Link2, + Search, + ShieldCheck, + X, +} from 'lucide-react'; +import type { CompatibilityPair, PhoneModel } from '../types'; + +interface ProtectorEvidence { + title: string; + url: string; + domain: string; + snippet: string; + provider: 'Bing' | 'DuckDuckGo'; +} + +interface SearchResponse { + found: boolean; + query: string; + results: ProtectorEvidence[]; + fallbackUrl?: string; + error?: string; +} + +interface OnlineProtectorCheckPanelProps { + targetModel: PhoneModel; + candidateModel: PhoneModel; + canSave: boolean; + canVerify: boolean; + onClose: () => void; + onRequestSignIn: () => void; + onSavePair: (pair: CompatibilityPair) => Promise<void>; +} + +export const OnlineProtectorCheckPanel: React.FC<OnlineProtectorCheckPanelProps> = ({ + targetModel, + candidateModel, + canSave, + canVerify, + onClose, + onRequestSignIn, + onSavePair, +}) => { + const [loading, setLoading] = useState(false); + const [results, setResults] = useState<ProtectorEvidence[]>([]); + const [fallbackUrl, setFallbackUrl] = useState(''); + const [error, setError] = useState(''); + const [reviewConfirmed, setReviewConfirmed] = useState(false); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + const runSearch = async () => { + setLoading(true); + setResults([]); + setError(''); + setReviewConfirmed(false); + setSaved(false); + + try { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 20_000); + let response: Response; + try { + response = await fetch('/api/v1/protector-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceModel: candidateModel.fullName, + targetModel: targetModel.fullName, + }), + signal: controller.signal, + }); + } finally { + window.clearTimeout(timeoutId); + } + + const data = await response.json() as SearchResponse; + setResults(data.results || []); + setFallbackUrl(data.fallbackUrl || ''); + if (!response.ok || !data.found) { + setError(data.error || 'No explicit online compatibility evidence was found.'); + } + } catch (searchError: unknown) { + const message = searchError instanceof DOMException && searchError.name === 'AbortError' + ? 'Online check timed out after 20 seconds.' + : searchError instanceof Error ? searchError.message : 'Network error'; + setError(message); + } finally { + setLoading(false); + } + }; + + const saveCompatibility = async () => { + if (!canSave) { + onRequestSignIn(); + return; + } + if (!reviewConfirmed || results.length === 0 || saving) return; + + const evidence = results.slice(0, 4).map((item) => ({ + type: 'web_research' as const, + title: item.title, + url: item.url, + snippet: item.snippet, + })); + + const pair: CompatibilityPair = { + id: 'online-protector-' + candidateModel.id + '-' + targetModel.id + '-' + Date.now(), + sourceModelId: candidateModel.id, + targetModelId: targetModel.id, + category: 'screen_protector', + confidenceLevel: canVerify ? 'CONFIRMED_COMPATIBLE' : 'HIGHLY_LIKELY', + confidenceScore: canVerify ? 92 : 78, + fitNotes: 'Online sources were reviewed for explicit screen-protector compatibility between these models.', + caveats: 'Confirm cutout position, glass curvature, and edge clearance with a physical test before sale.', + isVerifiedByStaff: canVerify, + verifiedDate: canVerify ? new Date().toISOString().split('T')[0] : undefined, + evidenceSources: evidence, + }; + + setSaving(true); + setError(''); + try { + await onSavePair(pair); + setSaved(true); + } catch (saveError: unknown) { + setError(saveError instanceof Error ? saveError.message : 'The compatibility record could not be saved.'); + } finally { + setSaving(false); + } + }; + + return ( + <section className="tech-panel rounded-xl border border-red-900/50 p-4 sm:p-5 space-y-4"> + <div className="flex items-start justify-between gap-3"> + <div className="flex items-start gap-3"> + <div className="w-10 h-10 rounded-xl bg-red-950/70 border border-red-800/70 flex items-center justify-center text-red-300 shrink-0"> + <Globe2 className="w-5 h-5" /> + </div> + <div> + <p className="text-[10px] font-mono font-bold uppercase tracking-wider text-red-300">Online protector check</p> + <h3 className="text-sm sm:text-base font-bold text-neutral-100 mt-0.5"> + {candidateModel.fullName} → {targetModel.fullName} + </h3> + <p className="text-[11px] text-neutral-400 mt-1"> + Search for sources that explicitly name both phone models. Results are never saved automatically. + </p> + </div> + </div> + <button type="button" onClick={onClose} className="text-neutral-500 hover:text-neutral-200 cursor-pointer" aria-label="Close online protector check"> + <X className="w-4 h-4" /> + </button> + </div> + + <button + type="button" + onClick={() => void runSearch()} + disabled={loading} + className="w-full sm:w-auto px-4 py-2.5 bg-red-700 hover:bg-red-600 disabled:bg-neutral-800 text-white text-xs font-semibold rounded-xl flex items-center justify-center gap-2 transition-colors cursor-pointer" + > + {loading ? <span className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" /> : <Search className="w-4 h-4" />} + {loading ? 'Searching trusted results…' : 'Search online compatibility'} + </button> + + {error && ( + <div role="alert" className="rounded-xl border border-amber-800/60 bg-amber-950/40 p-3 text-xs text-amber-200 flex items-start gap-2"> + <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" /> + <span>{error}</span> + </div> + )} + + {results.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-[10px] font-mono uppercase tracking-wider text-neutral-500"> + <span>{results.length} source results</span> + <span>Review required</span> + </div> + {results.map((item, index) => ( + <a + key={item.url + index} + href={item.url} + target="_blank" + rel="noopener noreferrer" + className="block rounded-xl border border-neutral-800 bg-neutral-950/70 p-3 hover:border-red-800/70 transition-colors" + > + <div className="flex items-start gap-2"> + <Link2 className="w-3.5 h-3.5 text-red-400 shrink-0 mt-0.5" /> + <div className="min-w-0 flex-1"> + <p className="text-xs font-semibold text-neutral-200">{item.title}</p> + <p className="text-[10px] text-red-300 font-mono mt-0.5">{item.domain} · {item.provider}</p> + <p className="text-[11px] text-neutral-400 leading-relaxed mt-1">{item.snippet}</p> + </div> + <ExternalLink className="w-3.5 h-3.5 text-neutral-500 shrink-0" /> + </div> + </a> + ))} + + <label className="flex items-start gap-2 rounded-xl border border-neutral-800 bg-neutral-900/70 p-3 text-[11px] text-neutral-300 cursor-pointer"> + <input + type="checkbox" + checked={reviewConfirmed} + onChange={(event) => setReviewConfirmed(event.target.checked)} + className="mt-0.5 accent-red-600" + /> + <span>I reviewed the sources and at least one explicitly names both models as sharing the same screen protector.</span> + </label> + + <button + type="button" + onClick={() => void saveCompatibility()} + disabled={!reviewConfirmed || saving || saved} + className="w-full px-4 py-2.5 bg-emerald-700 hover:bg-emerald-600 disabled:bg-neutral-800 disabled:text-neutral-500 text-white text-xs font-semibold rounded-xl flex items-center justify-center gap-2 cursor-pointer" + > + {saved ? <CheckCircle2 className="w-4 h-4" /> : <ShieldCheck className="w-4 h-4" />} + {saved ? 'Compatibility saved' : !canSave ? 'Sign in as staff to save' : saving ? 'Saving…' : 'Confirm and save protector match'} + </button> + </div> + )} + + {fallbackUrl && ( + <a href={fallbackUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 text-[11px] text-red-300 hover:text-red-200 font-mono"> + <ExternalLink className="w-3.5 h-3.5" /> + Open full web search + </a> + )} + + <p className="text-[10px] text-neutral-500 font-mono"> + Safety rule: online snippets are evidence leads, not proof. Measure display geometry and inspect camera/cutout clearance before sale. + </p> + </section> + ); +}; diff --git a/src/components/PrintableCheatSheetModal.tsx b/src/components/PrintableCheatSheetModal.tsx index 5b121e3..991330d 100644 --- a/src/components/PrintableCheatSheetModal.tsx +++ b/src/components/PrintableCheatSheetModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { Printer, X, Download, ShieldCheck, Smartphone, Check, FileSpreadsheet } from 'lucide-react'; -import { PhoneModel, CompatibilityPair, AccessoryCategory } from '../types'; +import { Printer, X, Download, ShieldCheck } from 'lucide-react'; +import { PhoneModel, CompatibilityPair } from '../types'; import { useLanguage } from '../i18n/translations'; interface PrintableCheatSheetModalProps { @@ -18,7 +18,6 @@ export const PrintableCheatSheetModal: React.FC<PrintableCheatSheetModalProps> = }) => { const { t } = useLanguage(); const [filterBrand, setFilterBrand] = useState('All'); - const [filterCategory, setFilterCategory] = useState<AccessoryCategory>('all_accessories'); if (!isOpen) return null; @@ -33,9 +32,7 @@ export const PrintableCheatSheetModal: React.FC<PrintableCheatSheetModalProps> = if (!source || !target) return false; const matchesBrand = filterBrand === 'All' || source.brand === filterBrand || target.brand === filterBrand; - const matchesCategory = filterCategory === 'all_accessories' || pair.category === 'all_accessories' || pair.category === filterCategory; - - return matchesBrand && matchesCategory; + return matchesBrand && pair.category === 'screen_protector'; }); const handlePrint = () => { @@ -110,18 +107,9 @@ export const PrintableCheatSheetModal: React.FC<PrintableCheatSheetModalProps> = ))} </div> - {/* Category Filter */} - <div className="flex items-center gap-1.5"> - <span className="text-neutral-400 font-mono">{t.tableHeaderCategory}:</span> - <select - value={filterCategory} - onChange={(e) => setFilterCategory(e.target.value as AccessoryCategory)} - className="bg-neutral-900 border border-neutral-800 text-neutral-200 rounded-lg px-2.5 py-1 text-xs font-mono" - > - <option value="all_accessories">{t.allAccessories}</option> - <option value="screen_protector">{t.screenProtectors}</option> - <option value="phone_case">{t.phoneCases}</option> - </select> + <div className="flex items-center gap-1.5 rounded-lg border border-red-900/60 bg-red-950/40 px-2.5 py-1 text-red-200 font-mono"> + <ShieldCheck className="w-3.5 h-3.5" /> + {t.screenProtectors} </div> {/* Action Buttons */} @@ -152,7 +140,7 @@ export const PrintableCheatSheetModal: React.FC<PrintableCheatSheetModalProps> = {t.appName} • {t.cheatSheetTitle} </h2> <div className="flex items-center justify-between text-xs text-neutral-400 print:text-neutral-600 font-mono mt-1"> - <span>Filter: {filterBrand} | {filterCategory.replace('_', ' ').toUpperCase()}</span> + <span>Filter: {filterBrand} | SCREEN PROTECTOR</span> <span>Date: {new Date().toISOString().split('T')[0]} • Total Pairs: {filteredPairs.length}</span> </div> </div> diff --git a/src/i18n/translations.tsx b/src/i18n/translations.tsx index e6e2678..ffa5814 100644 --- a/src/i18n/translations.tsx +++ b/src/i18n/translations.tsx @@ -155,7 +155,7 @@ export const TRANSLATIONS: Record<Language, Translations> = { bg: { // App Header appName: 'CaseScreenChecker', - appSubtitle: 'Справка за съвместимост на протектори и калъфи за смартфони', + appSubtitle: 'Справка за съвместимост на екранни протектори за смартфони', versionBadge: 'Магазин Ref v1.0', tabChecker: 'Справка за съвместимост', tabResearch: 'Външно проучване и източници', @@ -208,7 +208,7 @@ export const TRANSLATIONS: Record<Language, Translations> = { // Compatibility Results View compatibleAlternatives: 'Съвместими алтернативи (Донори)', - alternativesDescription: 'Модели, чиито протектори или калъфи пасват на избрания телефон според точния физически толеранс', + alternativesDescription: 'Модели, чиито екранни протектори пасват на избрания телефон според точния физически толеранс', confidenceScore: 'Увереност', staffVerified: 'Потвърдено от персонал', verifiedBy: 'Проверил:', @@ -240,13 +240,13 @@ export const TRANSLATIONS: Record<Language, Translations> = { overlayScale: 'Мащаб:', sideBySide: 'Един до друг', retailRecommendation: 'Препоръка за търговеца', - recommendationGoodFit: 'Силиконов TPU кейс и стъклен протектор от донора могат сигурно да бъдат предложени на клиента.', - recommendationCaution: 'Стъкленият протектор пасва точно. Избягвайте твърди пластмасови калъфи от донора поради леки разлики в шасито.', + recommendationGoodFit: 'Стъкленият протектор от донора може сигурно да бъде предложен на клиента.', + recommendationCaution: 'Стъкленият протектор вероятно пасва. Потвърдете изрезите и ръбовете с физическа проверка.', close: 'Затвори', // Printable Cheat Sheet cheatSheetTitle: 'Бърза справочна таблица за касовото работно място', - cheatSheetSubtitle: 'Форматирана таблица за принтиране и ламиниране на съвместими протектори и калъфи', + cheatSheetSubtitle: 'Форматирана таблица за принтиране и ламиниране на съвместими екранни протектори', printBtn: 'Принтирай таблица', exportCsvBtn: 'Експорт CSV', tableHeaderTarget: 'Търсен от клиента модел', @@ -285,7 +285,7 @@ export const TRANSLATIONS: Record<Language, Translations> = { selectCategory: 'Категория аксесоар', confidenceTier: 'Ниво на увереност', fitNotesPlaceholder: 'Опишете как пасва (напр. 100% покритие на дисплея, пасва идеално на изреза)', - caveatsPlaceholder: 'Особености (напр. калъфът покрива леко микрофона или е с 0.2мм по-стегнат)', + caveatsPlaceholder: 'Особености (напр. изрезът за камерата е леко изместен или ръбът не прилепва)', verifierName: 'Име на техника / магазина', savePairBtn: 'Запази съвместимост', @@ -303,7 +303,7 @@ export const TRANSLATIONS: Record<Language, Translations> = { en: { // App Header appName: 'CaseScreenChecker', - appSubtitle: 'Smartphone Case & Screen Protector Cross-Model Compatibility Reference', + appSubtitle: 'Cross-model screen protector compatibility reference', versionBadge: 'Retail Ref v1.0', tabChecker: 'Compatibility Reference', tabResearch: 'External Research & Evidence', @@ -356,7 +356,7 @@ export const TRANSLATIONS: Record<Language, Translations> = { // Compatibility Results View compatibleAlternatives: 'Compatible Alternatives (Donors)', - alternativesDescription: 'Models whose screen protectors or cases fit this target device within precise physical tolerance', + alternativesDescription: 'Models whose screen protectors fit this target device within precise physical tolerances', confidenceScore: 'Confidence', staffVerified: 'Staff Verified', verifiedBy: 'Verified by:', @@ -388,13 +388,13 @@ export const TRANSLATIONS: Record<Language, Translations> = { overlayScale: 'Scale:', sideBySide: 'Side by Side', retailRecommendation: 'Retail Staff Recommendation', - recommendationGoodFit: 'TPU silicone case and glass protector from donor can safely be recommended to the customer.', - recommendationCaution: 'Glass protector fits precisely. Avoid rigid plastic cases from donor due to slight chassis differences.', + recommendationGoodFit: 'The donor model glass protector can be safely recommended to the customer.', + recommendationCaution: 'The glass protector is likely to fit. Confirm cutouts and edges with a physical check.', close: 'Close', // Printable Cheat Sheet cheatSheetTitle: 'Retail Counter Quick-Reference Cheat Sheet', - cheatSheetSubtitle: 'Print-ready laminated matrix of interchangeable screen protectors and cases', + cheatSheetSubtitle: 'Print-ready laminated matrix of interchangeable screen protectors', printBtn: 'Print Cheat Sheet', exportCsvBtn: 'Export CSV', tableHeaderTarget: 'Target Customer Model', @@ -433,7 +433,7 @@ export const TRANSLATIONS: Record<Language, Translations> = { selectCategory: 'Accessory Category', confidenceTier: 'Confidence Tier', fitNotesPlaceholder: 'Describe physical fit (e.g. 100% screen active area match, smooth edge alignment)', - caveatsPlaceholder: 'Caveats (e.g. power button cutout sits 0.5mm higher, tight corner fit on hard cases)', + caveatsPlaceholder: 'Caveats (e.g. front-camera cutout is slightly offset or an edge does not adhere)', verifierName: 'Technician / Store Name', savePairBtn: 'Save Compatibility Pair',