Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
35 changes: 21 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 specificationschassis 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

Expand All @@ -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.

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
212 changes: 212 additions & 0 deletions api/v1/protector-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
interface VercelRequest {
body?: unknown;
method?: string;
headers?: Record<string, string | string[] | undefined>;
}

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<string, { startedAt: number; count: number }>();

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(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/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<SearchEvidence[]> {
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 = /<item>[\s\S]*?<title>([\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.',
});
}
}
}
29 changes: 24 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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);

Expand All @@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
/>
Expand Down
Loading