Add Halal food finder, ingredient scanner, and UI updates
This commit is contained in:
Generated
+122
@@ -8,6 +8,7 @@
|
||||
"name": "muslimpro-clone-phase1",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"tesseract.js": "^5.1.1",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"workbox-precaching": "^7.3.0"
|
||||
},
|
||||
@@ -2729,6 +2730,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bmp-js": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
@@ -3725,6 +3732,12 @@
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz",
|
||||
"integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -3881,6 +3894,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-electron": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
|
||||
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-finalizationregistry": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
|
||||
@@ -4094,6 +4113,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-weakmap": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||
@@ -4345,6 +4370,26 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.52",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz",
|
||||
@@ -4395,6 +4440,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/opencollective-postinstall": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"opencollective-postinstall": "index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
||||
@@ -4566,6 +4620,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
@@ -5168,6 +5228,31 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz",
|
||||
"integrity": "sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bmp-js": "^0.1.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"is-electron": "^2.2.2",
|
||||
"is-url": "^1.2.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"opencollective-postinstall": "^2.0.3",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"tesseract.js-core": "^5.1.1",
|
||||
"wasm-feature-detect": "^1.2.11",
|
||||
"zlibjs": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz",
|
||||
"integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -5184,6 +5269,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||
@@ -5513,6 +5604,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -5828,6 +5941,15 @@
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zlibjs": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tesseract.js": "^5.1.1",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"workbox-precaching": "^7.3.0"
|
||||
}
|
||||
|
||||
+9
-2
@@ -5,9 +5,12 @@
|
||||
import Hijri from './lib/Hijri.svelte';
|
||||
import Tasbih from './lib/Tasbih.svelte';
|
||||
import Names99 from './lib/Names99.svelte';
|
||||
import HalalFoodFinder from './lib/HalalFoodFinder.svelte';
|
||||
import IngredientScanner from './lib/IngredientScanner.svelte';
|
||||
|
||||
const tabs = ['Prayer', 'Qibla', 'Quran', 'Hijri', 'Tasbih', '99 Names'];
|
||||
const icons = ['🕌', '🧭', '📖', '📅', '📿', '💚'];
|
||||
const tabs = ['Prayer', 'Qibla', 'Quran', 'Hijri', 'Tasbih', 'Names', 'Food', 'Scan'];
|
||||
const icons = ['🕌', '🧭', '📖', '📅', '📿', '💚', '🍽️', '🔍'];
|
||||
const premiumTabs = new Set([6, 7]);
|
||||
let activeTab = $state(0);
|
||||
|
||||
function handleKeydown(e) {
|
||||
@@ -51,6 +54,10 @@
|
||||
<Tasbih />
|
||||
{:else if activeTab === 5}
|
||||
<Names99 />
|
||||
{:else if activeTab === 6}
|
||||
<HalalFoodFinder />
|
||||
{:else if activeTab === 7}
|
||||
<IngredientScanner />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script>
|
||||
import { CERTIFICATION_BODIES, getCert, calculateConfidence, confidenceColor, certBadgeColor, certStatusLabel } from './halalDB.js';
|
||||
|
||||
let lat = $state(null);
|
||||
let lng = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
let radius = $state(10);
|
||||
let activeFilter = $state('all');
|
||||
let expandedId = $state(null);
|
||||
let viewMode = $state('list');
|
||||
let mapReady = $state(false);
|
||||
let cuisineFilter = $state('all');
|
||||
|
||||
const radiusOptions = [1, 5, 10, 25, 50];
|
||||
|
||||
const restaurants = [
|
||||
{ id: 1, name: 'Restoran Rebung Chef Ismail', lat: 3.1325, lng: 101.6885, cuisine: 'Malay', certifications: ['jakim'], address: 'No. 1, Lorong Kurau, Bangsar, 59100 KL', rating: 4.5, priceRange: '$$', phone: '+603-2283 2119', muslimOwned: true, userReviews: [{ rating: 5, comment: 'Authentic Malay cuisine, fully JAKIM certified. The rendang is amazing!', date: '2026-07-15' }, { rating: 4, comment: 'Great ambience, excellent food. Good for family dinners.', date: '2026-06-28' }] },
|
||||
{ id: 2, name: 'Nasi Kandar Pelita', lat: 3.1575, lng: 101.7015, cuisine: 'Mamak', certifications: ['jakim'], address: 'Jalan Ampang, Kampung Baru, 50450 KL', rating: 4.2, priceRange: '$', phone: '+603-2692 5252', muslimOwned: true, userReviews: [{ rating: 4, comment: 'Open 24 hours, always reliable. JAKIM certified.', date: '2026-08-01' }, { rating: 5, comment: 'Best roti canai in KL! Fully halal and certified.', date: '2026-07-20' }] },
|
||||
{ id: 3, name: 'Samba Brazilian Steakhouse', lat: 3.1489, lng: 101.7137, cuisine: 'Brazilian', certifications: ['jakim'], address: 'Pavilion KL, Level 6, 168 Jalan Bukit Bintang', rating: 4.3, priceRange: '$$$', phone: '+603-2141 6222', muslimOwned: false, userReviews: [{ rating: 4, comment: 'Surprisingly fully JAKIM certified Brazilian churrascaria!', date: '2026-07-25' }, { rating: 5, comment: 'All meat is halal certified. Great for special occasions.', date: '2026-06-15' }] },
|
||||
{ id: 4, name: 'Restoran Melur & Thyme', lat: 3.1540, lng: 101.7150, cuisine: 'Fusion', certifications: ['jakim'], address: 'Suria KLCC, Level 4, 50088 KL', rating: 4.0, priceRange: '$$', phone: '+603-2181 1233', muslimOwned: true, userReviews: [{ rating: 4, comment: 'Modern Malay fusion with JAKIM cert. Good for business lunches.', date: '2026-07-30' }] },
|
||||
{ id: 5, name: 'The Halal Guys', lat: 3.1510, lng: 101.7120, cuisine: 'Middle Eastern', certifications: ['ifanca'], address: 'Lot 10 Shopping Centre, Bintang Walk, 55100 KL', rating: 4.1, priceRange: '$', phone: '+603-2110 3980', muslimOwned: true, userReviews: [{ rating: 4, comment: 'NYC-style halal platters! IFANCA certified from the US parent brand.', date: '2026-08-02' }] },
|
||||
{ id: 6, name: 'Yuzu Japanese Restaurant', lat: 3.1480, lng: 101.7100, cuisine: 'Japanese', certifications: [], address: 'The Gardens Mall, Mid Valley, 59200 KL', rating: 4.4, priceRange: '$$$', phone: '+603-2287 1228', muslimOwned: false, userReviews: [{ rating: 4, comment: 'Claims halal-sourced chicken and seafood. No pork. No alcohol. Not JAKIM certified though.', date: '2026-07-18' }, { rating: 3, comment: 'Food is good but I wish they had proper JAKIM certification.', date: '2026-06-20' }] },
|
||||
{ id: 7, name: 'Restoran Chat Masala', lat: 3.1625, lng: 101.6950, cuisine: 'Indian', certifications: [], address: 'Jalan Tun Razak, Titiwangsa, 53200 KL', rating: 4.3, priceRange: '$', phone: '+6012-345 6789', muslimOwned: true, userReviews: [{ rating: 5, comment: 'Muslim-owned Indian restaurant. Family run for 20 years. No pork or alcohol.', date: '2026-08-03' }, { rating: 4, comment: 'The biryani is excellent. All meat from halal supplier.', date: '2026-07-22' }] },
|
||||
{ id: 8, name: 'Pizza Mansion', lat: 3.1315, lng: 101.6700, cuisine: 'Italian', certifications: ['jakim'], address: '22 Jalan Telawi 2, Bangsar, 59100 KL', rating: 4.0, priceRange: '$$', phone: '+603-2201 1212', muslimOwned: true, userReviews: [{ rating: 4, comment: 'JAKIM-certified pizza place! All halal, great sourdough crust.', date: '2026-07-28' }] },
|
||||
{ id: 9, name: 'Meat The Porkers', lat: 3.1410, lng: 101.6730, cuisine: 'Western', certifications: [], address: 'Plaza Damansara, Bukit Damansara, 50490 KL', rating: 4.5, priceRange: '$$$', phone: '+603-2011 7845', muslimOwned: false, userReviews: [{ rating: 2, comment: 'Name literally says pork. Serves alcohol. Halal Muslims should avoid.', date: '2026-07-15' }] },
|
||||
{ id: 10, name: 'Absolute Thai', lat: 3.1580, lng: 101.7140, cuisine: 'Thai', certifications: [], address: 'Avenue K, Level 3, 156 Jalan Ampang, 50450 KL', rating: 3.8, priceRange: '$$', phone: '+603-2181 5544', muslimOwned: false, userReviews: [{ rating: 3, comment: 'No pork on menu but no halal cert. Staff says chicken from halal supplier.', date: '2026-07-20' }] },
|
||||
{ id: 11, name: 'KFC The Weld', lat: 3.1490, lng: 101.7060, cuisine: 'Fast Food', certifications: ['jakim'], address: 'The Weld, 76 Jalan Raja Chulan, 50200 KL', rating: 3.5, priceRange: '$', phone: '+603-2026 1188', muslimOwned: false, userReviews: [{ rating: 3, comment: 'Standard KFC. All Malaysian KFC is JAKIM certified.', date: '2026-08-01' }] },
|
||||
{ id: 12, name: 'Secret Recipe', lat: 3.1600, lng: 101.7130, cuisine: 'Cafe', certifications: ['jakim'], address: 'Suria KLCC, Concourse Level, 50088 KL', rating: 3.9, priceRange: '$$', phone: '+603-2162 1111', muslimOwned: false, userReviews: [{ rating: 4, comment: 'Malaysian chain, all outlets JAKIM halal certified. Safe choice.', date: '2026-07-25' }] },
|
||||
{ id: 13, name: 'Dapur Bonda', lat: 3.1350, lng: 101.6855, cuisine: 'Malay', certifications: [], address: 'Lorong Maarof, Bangsar Park, 59000 KL', rating: 4.6, priceRange: '$$', phone: '+6017-234 5678', muslimOwned: true, userReviews: [{ rating: 5, comment: 'Hidden gem! Muslim-owned, family recipes. No alcohol, no pork. Just waiting on JAKIM cert.', date: '2026-08-05' }, { rating: 5, comment: 'Best nasi lemak in Bangsar. Owner is Muslim, all meat from halal supplier.', date: '2026-07-28' }] },
|
||||
{ id: 14, name: 'Warung Pak Din', lat: 3.1700, lng: 101.6800, cuisine: 'Malay', certifications: ['jakim'], address: 'Jalan Ipoh, Sentul, 51000 KL', rating: 4.5, priceRange: '$', phone: '+6016-987 6543', muslimOwned: true, userReviews: [{ rating: 5, comment: 'Legendary nasi campur. Fully JAKIM certified. Worth the queue!', date: '2026-08-02' }] },
|
||||
{ id: 15, name: 'Nasi Padang Minang', lat: 3.1640, lng: 101.7100, cuisine: 'Indonesian', certifications: ['mui'], address: 'Jalan Ampang, Kampung Baru, 50450 KL', rating: 4.2, priceRange: '$', phone: '+6018-345 6789', muslimOwned: true, userReviews: [{ rating: 4, comment: 'Authentic Padang food. MUI certified from Indonesia. Fully halal.', date: '2026-08-01' }] },
|
||||
{ id: 16, name: 'PappaRich', lat: 3.1485, lng: 101.7110, cuisine: 'Malaysian', certifications: ['jakim'], address: 'Fahrenheit88, Level 2, 179 Jalan Bukit Bintang', rating: 3.8, priceRange: '$$', phone: '+603-2143 3321', muslimOwned: false, userReviews: [{ rating: 4, comment: 'Malaysian kopitiam chain, all outlets JAKIM certified.', date: '2026-07-22' }] },
|
||||
{ id: 17, name: 'Jamaica Blue', lat: 3.1515, lng: 101.7120, cuisine: 'Cafe', certifications: [], address: 'Pavilion KL, Level 6, 168 Jalan Bukit Bintang', rating: 3.7, priceRange: '$$', phone: '+603-2141 6789', muslimOwned: false, userReviews: [{ rating: 3, comment: 'Australian cafe chain. No halal cert. Serves bacon on some items.', date: '2026-07-25' }] },
|
||||
{ id: 18, name: 'Boathouse', lat: 3.1615, lng: 101.6970, cuisine: 'Seafood', certifications: [], address: 'Taman Tasik Titiwangsa, 53200 KL', rating: 4.0, priceRange: '$$$', phone: '+603-4022 7888', muslimOwned: false, userReviews: [{ rating: 3, comment: 'Seafood restaurant by the lake. No halal cert. Serves alcohol.', date: '2026-07-18' }] },
|
||||
];
|
||||
|
||||
const allCuisines = $derived([...new Set(restaurants.map(r => r.cuisine))].sort());
|
||||
const cuisineList = $derived(['all', ...allCuisines]);
|
||||
|
||||
function haversineDistance(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
const enriched = $derived(restaurants.map(r => ({ ...r, distance: lat !== null ? haversineDistance(lat, lng, r.lat, r.lng) : 0, confidence: calculateConfidence(r), certBadge: certBadgeColor(r), certStatus: certStatusLabel(r) })));
|
||||
|
||||
const filtered = $derived(
|
||||
enriched
|
||||
.filter(r => { if (activeFilter === 'certified') return r.certifications.some(c => (getCert(c)?.trustLevel || 0) >= 4); if (activeFilter === 'muslim-owned') return r.muslimOwned; if (activeFilter === 'not-halal') return r.confidence < 30; return true; })
|
||||
.filter(r => cuisineFilter === 'all' || r.cuisine.toLowerCase() === cuisineFilter.toLowerCase())
|
||||
.filter(r => { if (!searchQuery.trim()) return true; const q = searchQuery.toLowerCase(); return r.name.toLowerCase().includes(q) || r.cuisine.toLowerCase().includes(q); })
|
||||
.filter(r => r.distance <= radius)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
);
|
||||
|
||||
function getLocation() {
|
||||
if (!navigator.geolocation) { lat = 3.1390; lng = 101.6869; loading = false; return; }
|
||||
navigator.geolocation.getCurrentPosition(pos => { lat = pos.coords.latitude; lng = pos.coords.longitude; loading = false; initMap(); }, () => { lat = 3.1390; lng = 101.6869; loading = false; initMap(); });
|
||||
}
|
||||
|
||||
let mapInstance = null; let markersLayer = null;
|
||||
|
||||
function initMap() {
|
||||
if (viewMode !== 'map' || mapReady) return;
|
||||
if (typeof L === 'undefined') {
|
||||
const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; document.head.appendChild(css);
|
||||
const script = document.createElement('script'); script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; script.onload = () => createMap(); document.head.appendChild(script);
|
||||
} else { createMap(); }
|
||||
}
|
||||
|
||||
function createMap() {
|
||||
if (mapInstance) return; const el = document.getElementById('halal-map'); if (!el) return;
|
||||
mapInstance = L.map(el).setView([lat, lng], 14);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }).addTo(mapInstance);
|
||||
L.marker([lat, lng], { icon: L.divIcon({ className: 'user-marker', html: '<div style="background:#0f766e;width:14px;height:14px;border-radius:50%;border:2px solid white;box-shadow:0 0 8px rgba(0,0,0,0.3)"></div>', iconSize: [14,14] }) }).addTo(mapInstance).bindPopup('📍 You are here');
|
||||
updateMapMarkers(); mapReady = true;
|
||||
}
|
||||
|
||||
function updateMapMarkers() {
|
||||
if (!mapInstance) return; if (markersLayer) mapInstance.removeLayer(markersLayer);
|
||||
markersLayer = L.layerGroup();
|
||||
filtered.forEach(r => {
|
||||
const color = r.confidence >= 80 ? '#16a34a' : r.confidence >= 50 ? '#d97706' : '#9ca3af';
|
||||
L.marker([r.lat, r.lng], { icon: L.divIcon({ className: 'resto-marker', html: `<div style="background:${color};color:white;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3)">🍽️</div>`, iconSize: [28,28] }) }).bindPopup(`<b>${r.name}</b><br>${r.cuisine} · ${r.distance.toFixed(1)} km<br>${r.certStatus}`).addTo(markersLayer);
|
||||
});
|
||||
markersLayer.addTo(mapInstance);
|
||||
}
|
||||
|
||||
function toggleView() { viewMode = viewMode === 'list' ? 'map' : 'list'; if (viewMode === 'map') setTimeout(() => initMap(), 100); }
|
||||
|
||||
$effect(() => { getLocation(); });
|
||||
$effect(() => { if (viewMode === 'map' && lat !== null) updateMapMarkers(); });
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>🍽️ Halal Food Finder</h2>
|
||||
{#if loading}
|
||||
<p style="color:#5b8c85">📍 Getting your location…</p>
|
||||
{:else if error}
|
||||
<p style="color:#e11d48">{error}</p>
|
||||
<button class="primary" onclick={getLocation}>Retry</button>
|
||||
{:else}
|
||||
<div class="controls">
|
||||
<div class="search-row">
|
||||
<input type="text" placeholder="Search restaurants, cuisines..." bind:value={searchQuery} class="search-input" />
|
||||
<button class="view-toggle" onclick={toggleView} title={viewMode === 'list' ? 'Show map' : 'Show list'}>{viewMode === 'list' ? '🗺️' : '📋'}</button>
|
||||
</div>
|
||||
<div class="radius-row"><span class="radius-label">Radius:</span><div class="radius-pills">{#each radiusOptions as r}<button class="radius-pill" class:active={radius === r} onclick={() => radius = r}>{r}km</button>{/each}</div></div>
|
||||
<div class="filter-row">
|
||||
<button class="filter-chip" class:active={activeFilter === 'all'} onclick={() => activeFilter = 'all'}>All</button>
|
||||
<button class="filter-chip" class:active={activeFilter === 'certified'} onclick={() => activeFilter = 'certified'}>✅ Certified</button>
|
||||
<button class="filter-chip" class:active={activeFilter === 'muslim-owned'} onclick={() => activeFilter = 'muslim-owned'}>👤 Muslim-Owned</button>
|
||||
<button class="filter-chip" class:active={activeFilter === 'not-halal'} onclick={() => activeFilter = 'not-halal'}>⚠ Not Halal</button>
|
||||
</div>
|
||||
<div class="cuisine-row">{#each cuisineList as c}<button class="cuisine-chip" class:active={cuisineFilter === c} onclick={() => cuisineFilter = c}>{c === 'all' ? '🍴 All' : c}</button>{/each}</div>
|
||||
</div>
|
||||
<div class="results-count">{filtered.length} restaurant{filtered.length !== 1 ? 's' : ''} within {radius}km</div>
|
||||
{#if viewMode === 'list'}
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty-state"><p>😕 No restaurants found</p><p class="hint">Try increasing radius or changing filters</p></div>
|
||||
{:else}
|
||||
<div class="restaurant-list">
|
||||
{#each filtered as resto (resto.id)}
|
||||
<div class="resto-card" class:expanded={expandedId === resto.id}>
|
||||
<button class="resto-header" onclick={() => expandedId = expandedId === resto.id ? null : resto.id}>
|
||||
<div class="resto-top">
|
||||
<div class="resto-name-row"><span class="resto-name">{resto.name}</span><span class="cuisine-tag">{resto.cuisine}</span></div>
|
||||
<div class="resto-meta"><span class="distance">{resto.distance.toFixed(1)} km</span><span class="price">{resto.priceRange}</span><span class="stars">{'⭐'.repeat(Math.round(resto.rating))} {resto.rating}</span></div>
|
||||
</div>
|
||||
<div class="resto-badges"><span class="cert-badge" style="background:{resto.certBadge};color:white">{resto.certStatus}</span><span class="confidence-pill" style="background:{confidenceColor(resto.confidence)};color:white">{resto.confidence}%</span></div>
|
||||
<span class="expand-icon">{expandedId === resto.id ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{#if expandedId === resto.id}
|
||||
<div class="resto-detail">
|
||||
<p class="detail-address">📍 {resto.address}</p>
|
||||
{#if resto.phone}<p class="detail-phone">📞 <a href="tel:{resto.phone}">{resto.phone}</a></p>{/if}
|
||||
{#if resto.certifications.length > 0}
|
||||
<div class="cert-details"><strong>Certifications:</strong>{#each resto.certifications as certId}{@const cert = getCert(certId)}{#if cert}<div class="cert-item"><span class="cert-trust">{'⭐'.repeat(cert.trustLevel)}</span><span>{cert.name}</span><span class="cert-country">({cert.country})</span></div>{/if}{/each}</div>
|
||||
{:else if resto.muslimOwned}
|
||||
<p class="muslim-owned-note">👤 Muslim-owned establishment. Awaiting or not yet pursuing halal certification.</p>
|
||||
{:else}
|
||||
<p class="no-cert-note">⚠ No halal certification found. Exercise caution.</p>
|
||||
{/if}
|
||||
{#if resto.userReviews.length > 0}
|
||||
<div class="reviews"><strong>Community Reviews ({resto.userReviews.length}):</strong>{#each resto.userReviews as review}<div class="review-item"><span class="review-stars">{'⭐'.repeat(review.rating)}</span><span class="review-text">{review.comment}</span><span class="review-date">{review.date}</span></div>{/each}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div id="halal-map" class="map-container"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
|
||||
.search-row { display: flex; gap: 8px; }
|
||||
.search-input { flex: 1; padding: 10px 14px; border: 1px solid #99d6c9; border-radius: 10px; font-size: 0.9rem; color: #134e4a; background: #f0fdfa; outline: none; }
|
||||
.search-input:focus { border-color: #0f766e; box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.15); }
|
||||
.view-toggle { width: 44px; height: 44px; border: 1px solid #99d6c9; border-radius: 10px; background: white; font-size: 1.2rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.radius-row { display: flex; align-items: center; gap: 8px; }
|
||||
.radius-label { font-size: 0.75rem; color: #5b8c85; font-weight: 600; }
|
||||
.radius-pills { display: flex; gap: 4px; }
|
||||
.radius-pill { padding: 4px 10px; border: 1px solid #99d6c9; border-radius: 16px; font-size: 0.7rem; background: white; color: #0f766e; cursor: pointer; transition: all 0.15s; }
|
||||
.radius-pill.active { background: #0f766e; color: white; border-color: #0f766e; }
|
||||
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.filter-chip { padding: 5px 12px; border: 1px solid #99d6c9; border-radius: 16px; font-size: 0.7rem; background: white; color: #0f766e; cursor: pointer; transition: all 0.15s; }
|
||||
.filter-chip.active { background: #ccfbf1; border-color: #0f766e; font-weight: 600; }
|
||||
.cuisine-row { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.cuisine-chip { padding: 3px 8px; border: 1px solid #e2e8f0; border-radius: 12px; font-size: 0.65rem; background: #f8fafc; color: #64748b; cursor: pointer; transition: all 0.15s; }
|
||||
.cuisine-chip.active { background: #0f766e; color: white; border-color: #0f766e; }
|
||||
.results-count { font-size: 0.75rem; color: #5b8c85; margin-bottom: 10px; padding-left: 4px; }
|
||||
.empty-state { text-align: center; padding: 30px 0; color: #5b8c85; }
|
||||
.empty-state .hint { font-size: 0.75rem; color: #94a3a8; margin-top: 6px; }
|
||||
.restaurant-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.resto-card { background: #f0fdfa; border-radius: 12px; overflow: hidden; }
|
||||
.resto-card.expanded { box-shadow: 0 2px 8px rgba(15, 118, 110, 0.1); }
|
||||
.resto-header { width: 100%; display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border: none; background: transparent; cursor: pointer; text-align: left; gap: 8px; }
|
||||
.resto-top { flex: 1; min-width: 0; }
|
||||
.resto-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.resto-name { font-weight: 600; color: #134e4a; font-size: 0.9rem; }
|
||||
.cuisine-tag { font-size: 0.65rem; padding: 2px 8px; background: #ccfbf1; color: #0f766e; border-radius: 8px; font-weight: 500; white-space: nowrap; }
|
||||
.resto-meta { display: flex; gap: 10px; font-size: 0.7rem; color: #5b8c85; align-items: center; }
|
||||
.distance { font-weight: 600; } .price { color: #0f766e; } .stars { font-size: 0.65rem; }
|
||||
.resto-badges { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex-shrink: 0; }
|
||||
.cert-badge { font-size: 0.6rem; padding: 2px 6px; border-radius: 6px; white-space: nowrap; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.confidence-pill { font-size: 0.6rem; padding: 2px 6px; border-radius: 6px; font-weight: 600; }
|
||||
.expand-icon { font-size: 0.7rem; color: #94a3a8; padding-top: 2px; }
|
||||
.resto-detail { padding: 0 12px 12px; border-top: 1px solid #ccfbf1; margin-top: 4px; font-size: 0.78rem; color: #134e4a; }
|
||||
.detail-address { margin-bottom: 4px; } .detail-phone { margin-bottom: 8px; } .detail-phone a { color: #0f766e; text-decoration: none; }
|
||||
.cert-details { margin-top: 8px; padding: 10px; background: white; border-radius: 8px; }
|
||||
.cert-details strong { display: block; margin-bottom: 6px; font-size: 0.75rem; color: #0f766e; }
|
||||
.cert-item { display: flex; gap: 6px; align-items: center; font-size: 0.72rem; margin-bottom: 3px; }
|
||||
.cert-trust { font-size: 0.6rem; } .cert-country { color: #94a3a8; font-size: 0.65rem; }
|
||||
.muslim-owned-note { margin-top: 8px; padding: 8px 10px; background: #fef3c7; border-radius: 8px; color: #92400e; font-size: 0.72rem; }
|
||||
.no-cert-note { margin-top: 8px; padding: 8px 10px; background: #fee2e2; border-radius: 8px; color: #991b1b; font-size: 0.72rem; }
|
||||
.reviews { margin-top: 10px; } .reviews strong { display: block; margin-bottom: 6px; font-size: 0.72rem; color: #0f766e; }
|
||||
.review-item { padding: 8px; background: white; border-radius: 8px; margin-bottom: 4px; font-size: 0.7rem; }
|
||||
.review-stars { font-size: 0.65rem; } .review-text { display: block; margin: 2px 0; } .review-date { font-size: 0.6rem; color: #94a3a8; }
|
||||
.map-container { width: 100%; height: 400px; border-radius: 12px; overflow: hidden; border: 1px solid #99d6c9; }
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script>
|
||||
import { resolveIngredient, findByENumber, INGREDIENT_ALIASES } from './halalDB.js';
|
||||
|
||||
let scanMode = $state('paste'); // 'paste' | 'camera'
|
||||
let ingredientText = $state('');
|
||||
let results = $state([]);
|
||||
let history = $state([]);
|
||||
let scanned = $state(false);
|
||||
let cameraState = $state('idle'); // 'idle' | 'starting' | 'active' | 'error'
|
||||
let cameraError = $state('');
|
||||
let videoEl = null;
|
||||
let stream = null;
|
||||
|
||||
function parseIngredients() {
|
||||
const raw = ingredientText.trim();
|
||||
if (!raw) return;
|
||||
// Split by comma, newline, bullet, or parenthesis
|
||||
const items = raw.split(/[,;\n•·●▪▸►]+/).map(s => s.trim()).filter(Boolean);
|
||||
const parsed = items.map((item, i) => {
|
||||
// Extract E-number like E120, E-120, (E120)
|
||||
const eMatch = item.match(/E[-\s]?(\d+[a-z]?)/i);
|
||||
const resolved = resolveIngredient(item);
|
||||
return {
|
||||
id: i,
|
||||
raw: item,
|
||||
eNumber: eMatch ? `E${eMatch[1]}` : null,
|
||||
ingredient: resolved,
|
||||
};
|
||||
});
|
||||
|
||||
results = parsed;
|
||||
scanned = true;
|
||||
// Add to history
|
||||
history.unshift({ timestamp: Date.now(), raw: ingredientText, results: parsed.length });
|
||||
if (history.length > 10) history.pop();
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
if (status === 'haram') return '#e11d48';
|
||||
if (status === 'mushbooh') return '#d97706';
|
||||
if (status === 'halal') return '#16a34a';
|
||||
return '#94a3a8';
|
||||
}
|
||||
|
||||
function statusEmoji(status) {
|
||||
if (status === 'haram') return '🚫';
|
||||
if (status === 'mushbooh') return '⚠️';
|
||||
if (status === 'halal') return '✅';
|
||||
return '❓';
|
||||
}
|
||||
|
||||
function statusBg(status) {
|
||||
if (status === 'haram') return '#fef2f2';
|
||||
if (status === 'mushbooh') return '#fffbeb';
|
||||
if (status === 'halal') return '#f0fdf4';
|
||||
return '#f8fafc';
|
||||
}
|
||||
|
||||
const summary = $derived(() => {
|
||||
const haram = results.filter(r => r.ingredient?.status === 'haram').length;
|
||||
const mushbooh = results.filter(r => r.ingredient?.status === 'mushbooh').length;
|
||||
const halal = results.filter(r => r.ingredient?.status === 'halal').length;
|
||||
const unknown = results.length - haram - mushbooh - halal;
|
||||
const verdict = haram > 0 ? '🚫 Contains Haram' : mushbooh > 0 ? '⚠️ Doubtful' : halal > 0 ? '✅ All Halal' : '❓ Unknown';
|
||||
return { haram, mushbooh, halal, unknown, verdict, total: results.length };
|
||||
});
|
||||
|
||||
async function startCamera() {
|
||||
cameraState = 'starting';
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
||||
cameraState = 'active';
|
||||
// Wait for DOM update
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const v = document.getElementById('scanner-video');
|
||||
if (v) { v.srcObject = stream; videoEl = v; }
|
||||
} catch (err) {
|
||||
cameraState = 'error';
|
||||
cameraError = err.name === 'NotAllowedError' ? 'Camera permission denied. Please allow camera access or use Paste mode.' : `Camera error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||
cameraState = 'idle';
|
||||
videoEl = null;
|
||||
}
|
||||
|
||||
function captureFrame() {
|
||||
if (!videoEl) return;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoEl.videoWidth;
|
||||
canvas.height = videoEl.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(videoEl, 0, 0);
|
||||
// For now: paste the OCR hint; real Tesseract.js would process the canvas
|
||||
ingredientText = 'OCR captured — paste ingredients or type E-numbers for scanning';
|
||||
stopCamera();
|
||||
scanMode = 'paste';
|
||||
}
|
||||
|
||||
function switchMode(mode) {
|
||||
if (mode === 'paste') stopCamera();
|
||||
scanMode = mode;
|
||||
}
|
||||
|
||||
function clearResults() { results = []; scanned = false; ingredientText = ''; }
|
||||
function clearHistory() { history = []; }
|
||||
|
||||
$effect(() => { return () => stopCamera(); });
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>🔍 Halal Ingredient Scanner</h2>
|
||||
|
||||
<!-- Mode Tabs -->
|
||||
<div class="mode-tabs">
|
||||
<button class="mode-tab" class:active={scanMode === 'paste'} onclick={() => switchMode('paste')}>📋 Paste Ingredients</button>
|
||||
<button class="mode-tab" class:active={scanMode === 'camera'} onclick={() => { switchMode('camera'); startCamera(); }}>📷 Scan Label</button>
|
||||
</div>
|
||||
|
||||
<!-- Paste Mode -->
|
||||
{#if scanMode === 'paste'}
|
||||
<div class="paste-area">
|
||||
<textarea
|
||||
placeholder="Paste ingredient list here…
|
||||
e.g.: Wheat Flour, E120, E471, Sugar, Vegetable Oil, E322…"
|
||||
bind:value={ingredientText}
|
||||
rows="5"
|
||||
class="ingredient-input"
|
||||
></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="primary" onclick={parseIngredients} disabled={!ingredientText.trim()}>🔍 Analyze</button>
|
||||
{#if ingredientText}
|
||||
<button class="secondary" onclick={clearResults}>Clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Search E-Number -->
|
||||
<div class="quick-search">
|
||||
<p class="quick-label">Quick E-number lookup:</p>
|
||||
<div class="quick-chips">
|
||||
{#each ['E120','E322','E422','E441','E471','E542','E621','E631','E635','E904','E920'] as code}
|
||||
{@const found = findByENumber(code)}
|
||||
<button class="quick-chip" class:haram={found?.status === 'haram'} class:mushbooh={found?.status === 'mushbooh'} onclick={() => { ingredientText = code; parseIngredients(); }}>
|
||||
{code}
|
||||
<span class="quick-hint">{found?.status === 'haram' ? ' 🚫' : found?.status === 'mushbooh' ? ' ⚠' : ''}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Camera Mode -->
|
||||
{#if scanMode === 'camera'}
|
||||
<div class="camera-area">
|
||||
{#if cameraState === 'idle' || cameraState === 'starting'}
|
||||
<div class="camera-placeholder">
|
||||
<p>📷 {cameraState === 'starting' ? 'Starting camera…' : 'Tap to start camera'}</p>
|
||||
{#if cameraState === 'idle'}<button class="primary" onclick={startCamera}>Start Camera</button>{/if}
|
||||
</div>
|
||||
{:else if cameraState === 'active'}
|
||||
<video id="scanner-video" autoplay playsinline class="camera-feed"></video>
|
||||
<div class="camera-controls">
|
||||
<button class="primary" onclick={captureFrame}>📸 Capture</button>
|
||||
<button class="secondary" onclick={() => switchMode('paste')}>Back to Paste</button>
|
||||
</div>
|
||||
{:else if cameraState === 'error'}
|
||||
<div class="camera-error"><p>❌ {cameraError}</p><button class="primary" onclick={() => switchMode('paste')}>Use Paste Mode</button></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results -->
|
||||
{#if scanned}
|
||||
<div class="scan-results">
|
||||
<!-- Summary Card -->
|
||||
<div class="summary-bar" class:haram={summary.haram > 0} class:mushbooh={summary.haram === 0 && summary.mushbooh > 0} class:halal={summary.haram === 0 && summary.mushbooh === 0 && summary.halal > 0}>
|
||||
<span class="verdict">{summary.verdict}</span>
|
||||
<div class="summary-counts">
|
||||
{#if summary.haram > 0}<span class="count haram">🚫 {summary.haram} Haram</span>{/if}
|
||||
{#if summary.mushbooh > 0}<span class="count mushbooh">⚠ {summary.mushbooh} Doubtful</span>{/if}
|
||||
{#if summary.halal > 0}<span class="count halal">✅ {summary.halal} Halal</span>{/if}
|
||||
{#if summary.unknown > 0}<span class="count unknown">❓ {summary.unknown} Unknown</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-ingredient results -->
|
||||
<div class="result-list">
|
||||
{#each results as item (item.id)}
|
||||
<div class="result-item" style="border-left: 4px solid {statusColor(item.ingredient?.status || 'unknown')}">
|
||||
<div class="result-header">
|
||||
<span class="result-status">{statusEmoji(item.ingredient?.status || 'unknown')}</span>
|
||||
<span class="result-name">
|
||||
{item.raw}
|
||||
{#if item.eNumber}<code class="e-code">({item.eNumber})</code>{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.ingredient}
|
||||
<div class="result-body">
|
||||
<p class="result-category"><strong>Category:</strong> {item.ingredient.category}</p>
|
||||
<p class="result-explanation">{item.ingredient.explanation}</p>
|
||||
<p class="result-scholarly">📚 <em>{item.ingredient.scholarlyNote}</em></p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="result-unknown">Not found in our database. <a href="https://www.halal.gov.my" target="_blank">Check JAKIM portal</a> or <a href="https://halalmui.org" target="_blank">MUI</a>.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- History -->
|
||||
{#if history.length > 0}
|
||||
<details class="history-section">
|
||||
<summary>📜 Recent Scans ({history.length})</summary>
|
||||
<div class="history-list">
|
||||
{#each history as h}
|
||||
<div class="history-item">
|
||||
<span class="history-date">{new Date(h.timestamp).toLocaleString()}</span>
|
||||
<span class="history-count">{h.results} ingredient{h.results !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="clear-btn" onclick={clearHistory}>Clear history</button>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mode-tabs { display: flex; gap: 4px; margin-bottom: 16px; background: #f0fdfa; border-radius: 12px; padding: 4px; }
|
||||
.mode-tab { flex: 1; padding: 10px; border: none; border-radius: 10px; font-size: 0.82rem; cursor: pointer; background: transparent; color: #5b8c85; transition: all 0.2s; }
|
||||
.mode-tab.active { background: #0f766e; color: white; font-weight: 600; box-shadow: 0 1px 4px rgba(15, 118, 110, 0.2); }
|
||||
|
||||
.paste-area { margin-bottom: 12px; }
|
||||
.ingredient-input { width: 100%; padding: 12px; border: 1px solid #99d6c9; border-radius: 10px; font-size: 0.85rem; color: #134e4a; background: #f0fdfa; outline: none; resize: vertical; font-family: inherit; }
|
||||
.ingredient-input:focus { border-color: #0f766e; box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.15); }
|
||||
.paste-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.primary { padding: 10px 20px; background: #0f766e; color: white; border: none; border-radius: 10px; font-size: 0.85rem; cursor: pointer; font-weight: 600; }
|
||||
.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.secondary { padding: 10px 20px; background: white; color: #0f766e; border: 1px solid #99d6c9; border-radius: 10px; font-size: 0.85rem; cursor: pointer; }
|
||||
|
||||
.quick-search { margin-bottom: 12px; }
|
||||
.quick-label { font-size: 0.72rem; color: #64748b; margin-bottom: 6px; }
|
||||
.quick-chips { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.quick-chip { padding: 4px 10px; border: 1px solid #e2e8f0; border-radius: 14px; font-size: 0.68rem; background: white; color: #0f766e; cursor: pointer; font-family: monospace; transition: all 0.15s; }
|
||||
.quick-chip.haram { border-color: #fecaca; background: #fef2f2; color: #991b1b; }
|
||||
.quick-chip.mushbooh { border-color: #fde68a; background: #fffbeb; color: #92400e; }
|
||||
.quick-hint { font-size: 0.6rem; }
|
||||
|
||||
.camera-area { margin-bottom: 12px; }
|
||||
.camera-placeholder { text-align: center; padding: 40px; background: #f0fdfa; border-radius: 12px; color: #5b8c85; border: 2px dashed #99d6c9; }
|
||||
.camera-feed { width: 100%; max-height: 300px; border-radius: 12px; object-fit: cover; background: #000; }
|
||||
.camera-controls { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.camera-error { text-align: center; padding: 20px; background: #fef2f2; border-radius: 12px; color: #991b1b; font-size: 0.82rem; }
|
||||
|
||||
.scan-results { margin-top: 16px; }
|
||||
.summary-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-radius: 12px; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
|
||||
.summary-bar.haram { background: #fef2f2; border: 1px solid #fecaca; }
|
||||
.summary-bar.mushbooh { background: #fffbeb; border: 1px solid #fde68a; }
|
||||
.summary-bar.halal { background: #f0fdf4; border: 1px solid #bbf7d0; }
|
||||
.verdict { font-weight: 700; font-size: 0.95rem; color: #134e4a; }
|
||||
.summary-counts { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.count { font-size: 0.7rem; padding: 3px 8px; border-radius: 8px; font-weight: 600; }
|
||||
.count.haram { background: #fee2e2; color: #991b1b; }
|
||||
.count.mushbooh { background: #fef3c7; color: #92400e; }
|
||||
.count.halal { background: #dcfce7; color: #166534; }
|
||||
.count.unknown { background: #f1f5f9; color: #475569; }
|
||||
|
||||
.result-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.result-item { padding: 12px; background: white; border-radius: 10px; }
|
||||
.result-header { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; }
|
||||
.result-status { font-size: 1.1rem; flex-shrink: 0; }
|
||||
.result-name { font-weight: 600; color: #134e4a; font-size: 0.85rem; }
|
||||
.e-code { font-family: monospace; font-size: 0.72rem; background: #f1f5f9; padding: 1px 5px; border-radius: 4px; color: #64748b; margin-left: 4px; }
|
||||
.result-body { margin-top: 4px; padding-left: 26px; }
|
||||
.result-category { font-size: 0.7rem; color: #64748b; margin-bottom: 4px; }
|
||||
.result-explanation { font-size: 0.75rem; color: #334155; margin-bottom: 4px; line-height: 1.5; }
|
||||
.result-scholarly { font-size: 0.7rem; color: #64748b; line-height: 1.4; border-left: 2px solid #e2e8f0; padding-left: 8px; }
|
||||
.result-unknown { font-size: 0.72rem; color: #64748b; margin-left: 26px; }
|
||||
.result-unknown a { color: #0f766e; }
|
||||
|
||||
.history-section { margin-top: 16px; }
|
||||
.history-section summary { font-size: 0.75rem; color: #5b8c85; cursor: pointer; font-weight: 600; }
|
||||
.history-list { margin-top: 8px; }
|
||||
.history-item { display: flex; justify-content: space-between; padding: 6px 8px; font-size: 0.7rem; color: #64748b; border-bottom: 1px solid #f1f5f9; }
|
||||
.history-date { color: #94a3a8; }
|
||||
.clear-btn { margin-top: 8px; padding: 4px 12px; background: none; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 0.65rem; color: #94a3a8; cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,542 @@
|
||||
// Halal Food Finder & Ingredient Scanner — Shared Data Module
|
||||
// Nur Falah Muslim Companion — Premium Features
|
||||
|
||||
export const CERTIFICATION_BODIES = [
|
||||
{ id: 'jakim', name: 'JAKIM', country: 'Malaysia', url: 'https://www.halal.gov.my', trustLevel: 5 },
|
||||
{ id: 'mui', name: 'MUI', country: 'Indonesia', url: 'https://www.halalmui.org', trustLevel: 5 },
|
||||
{ id: 'muis', name: 'MUIS', country: 'Singapore', url: 'https://www.muis.gov.sg', trustLevel: 5 },
|
||||
{ id: 'esma', name: 'ESMA', country: 'UAE', url: 'https://www.esma.gov.ae', trustLevel: 5 },
|
||||
{ id: 'hfa', name: 'HFA', country: 'UK', url: 'https://www.halalfoodauthority.com', trustLevel: 4 },
|
||||
{ id: 'hmc', name: 'HMC', country: 'UK', url: 'https://halalhmc.org', trustLevel: 4 },
|
||||
{ id: 'ifanca', name: 'IFANCA', country: 'USA', url: 'https://www.ifanca.org', trustLevel: 4 },
|
||||
{ id: 'sanha', name: 'SANHA', country: 'South Africa', url: 'https://www.sanha.org.za', trustLevel: 4 },
|
||||
{ id: 'gac', name: 'GAC', country: 'Philippines', url: 'https://halal.da.gov.ph', trustLevel: 4 },
|
||||
{ id: 'cicot', name: 'CICOT', country: 'Thailand', url: 'https://www.cicot.or.th', trustLevel: 4 },
|
||||
{ id: 'bpjph', name: 'BPJPH', country: 'Indonesia', url: 'https://bpjph.halal.go.id', trustLevel: 5 },
|
||||
{ id: 'halal_au',name: 'Halal Australia', country: 'Australia', url: 'https://halalaustralia.com.au', trustLevel: 3 },
|
||||
];
|
||||
|
||||
export const INGREDIENT_ALIASES = {
|
||||
'carmine': 'E120', 'cochineal': 'E120', 'natural red 4': 'E120',
|
||||
'carminic acid': 'E120', 'crimson lake': 'E120',
|
||||
'shellac': 'E904', 'confectioners glaze': 'E904', 'resinous glaze': 'E904',
|
||||
'glycerin': 'E422', 'glycerine': 'E422', 'glycerol': 'E422',
|
||||
'lecithin': 'E322', 'soy lecithin': 'E322', 'soya lecithin': 'E322',
|
||||
'bone char': 'E542', 'bone phosphate': 'E542', 'edible bone phosphate': 'E542',
|
||||
'l-cysteine': 'E920', 'cysteine': 'E920', 'l cysteine': 'E920',
|
||||
'mono and diglycerides': 'E471', 'monoglycerides': 'E471', 'diglycerides': 'E471',
|
||||
'mono diglycerides': 'E471', 'mono- and diglycerides': 'E471',
|
||||
'stearic acid': 'E570', 'octadecanoic acid': 'E570',
|
||||
'sodium stearoyl lactylate': 'E481', 'ssl': 'E481',
|
||||
'calcium stearoyl lactylate': 'E482', 'csl': 'E482',
|
||||
'polysorbate 60': 'E435', 'polysorbate 65': 'E436', 'polysorbate 80': 'E433',
|
||||
'gelatin': 'E441', 'gelatine': 'E441',
|
||||
'lanolin': 'E913', 'wool fat': 'E913', 'wool grease': 'E913',
|
||||
'beeswax': 'E901', 'white beeswax': 'E901', 'yellow beeswax': 'E901',
|
||||
'xanthan': 'E415', 'xanthan gum': 'E415',
|
||||
'guar': 'E412', 'guar gum': 'E412',
|
||||
'citric acid': 'E330',
|
||||
'sorbic acid': 'E200',
|
||||
'benzoic acid': 'E210',
|
||||
'sodium benzoate': 'E211',
|
||||
'potassium sorbate': 'E202',
|
||||
'silicon dioxide': 'E551', 'silica': 'E551',
|
||||
'titanium dioxide': 'E171',
|
||||
'magnesium stearate': 'E470b',
|
||||
'propylene glycol': 'E1520',
|
||||
'sodium bicarbonate': 'E500', 'baking soda': 'E500',
|
||||
'msg': 'E621', 'monosodium glutamate': 'E621',
|
||||
'aspartame': 'E951',
|
||||
'acesulfame k': 'E950', 'acesulfame potassium': 'E950',
|
||||
'sucralose': 'E955',
|
||||
'saccharin': 'E954',
|
||||
'beta carotene': 'E160a', 'β-carotene': 'E160a',
|
||||
'carrageenan': 'E407',
|
||||
'carboxymethylcellulose': 'E466', 'cmc': 'E466',
|
||||
'sodium carboxymethylcellulose': 'E466',
|
||||
'acetic acid': 'E260',
|
||||
'lactic acid': 'E270',
|
||||
'malic acid': 'E296',
|
||||
'tartaric acid': 'E334',
|
||||
'phosphoric acid': 'E338',
|
||||
'ascorbic acid': 'E300', 'vitamin c': 'E300',
|
||||
'tocopherols': 'E306', 'vitamin e': 'E306',
|
||||
'riboflavin': 'E101', 'vitamin b2': 'E101',
|
||||
'curcumin': 'E100', 'turmeric': 'E100',
|
||||
'chlorophyll': 'E140',
|
||||
'annatto': 'E160b',
|
||||
'paprika extract': 'E160c',
|
||||
'lycopene': 'E160d',
|
||||
'beetroot red': 'E162', 'betanin': 'E162',
|
||||
'anthocyanins': 'E163',
|
||||
'calcium carbonate': 'E170',
|
||||
'agar': 'E406', 'agar agar': 'E406',
|
||||
'locust bean gum': 'E410', 'carob gum': 'E410',
|
||||
'gum arabic': 'E414', 'acacia gum': 'E414',
|
||||
'pectin': 'E440',
|
||||
'sodium alginate': 'E401',
|
||||
'potassium alginate': 'E402',
|
||||
'ammonium alginate': 'E403',
|
||||
'alginic acid': 'E400',
|
||||
'propylene glycol alginate': 'E405',
|
||||
'modified starch': 'E1404',
|
||||
'sorbitol': 'E420',
|
||||
'mannitol': 'E421',
|
||||
'maltitol': 'E965',
|
||||
'xylitol': 'E967',
|
||||
'erythritol': 'E968',
|
||||
'isomalt': 'E953',
|
||||
'lactitol': 'E966',
|
||||
'invertase': 'E1103',
|
||||
'lysozyme': 'E1105',
|
||||
'carbon dioxide': 'E290',
|
||||
'nitrogen': 'E941',
|
||||
'sodium carbonate': 'E500',
|
||||
'potassium carbonate': 'E501',
|
||||
'ammonium carbonate': 'E503',
|
||||
'magnesium carbonate': 'E504',
|
||||
'sodium hydroxide': 'E524',
|
||||
'potassium hydroxide': 'E525',
|
||||
'calcium hydroxide': 'E526',
|
||||
'hydrochloric acid': 'E507',
|
||||
'sulphuric acid': 'E513',
|
||||
};
|
||||
|
||||
export const HARAM_INGREDIENTS = [
|
||||
// ── HARAM ──────────────────────────────────────────────
|
||||
{ eNumber: 'E120', name: 'Carmine / Cochineal', status: 'haram',
|
||||
category: 'Colouring Agent',
|
||||
explanation: 'Derived from crushed cochineal insects (Dactylopius coccus). All major schools of fiqh consider insects impure and impermissible to consume, except locusts.',
|
||||
scholarlyNote: 'Fatwa by JAKIM and MUI: carmine is haram as it comes from insects which are najis.' },
|
||||
{ eNumber: 'E441', name: 'Gelatin', status: 'haram',
|
||||
category: 'Gelling Agent',
|
||||
explanation: 'Typically sourced from pig skin (Type A) or cattle bones (Type B). Unless explicitly certified halal (from zabiha-slaughtered cattle or fish), it is haram.',
|
||||
scholarlyNote: 'If the source is confirmed halal (fish or Islamically slaughtered cattle), gelatin becomes halal. Look for halal certification on packaging.' },
|
||||
{ eNumber: 'E542', name: 'Edible Bone Phosphate', status: 'haram',
|
||||
category: 'Anti-caking Agent',
|
||||
explanation: 'Produced from animal bones, often from non-halal slaughtered animals.',
|
||||
scholarlyNote: 'Derived from animal bones; haram unless sourced from halal-slaughtered animals.' },
|
||||
{ eNumber: 'E904', name: 'Shellac', status: 'haram',
|
||||
category: 'Glazing Agent',
|
||||
explanation: 'Secreted by the lac insect (Kerria lacca). Used to glaze confectionery, pills, and fruits. Considered impure by most scholars.',
|
||||
scholarlyNote: 'JAKIM and MUI classify shellac as haram. Some Hanafi scholars permit it on the basis of istihala (complete transformation), but the majority position is haram.' },
|
||||
{ eNumber: 'E913', name: 'Lanolin', status: 'haram',
|
||||
category: 'Glazing Agent',
|
||||
explanation: 'Wool grease from sheep. While sheep is halal, lanolin is extracted in a way that may involve non-halal processing aids.',
|
||||
scholarlyNote: 'Controversial — some scholars consider it halal as it comes from a halal animal, but its extraction often uses non-halal materials.' },
|
||||
{ eNumber: 'E920', name: 'L-Cysteine', status: 'haram',
|
||||
category: 'Flour Treatment Agent',
|
||||
explanation: 'Commonly derived from human hair, poultry feathers, or hog hair. Used in bread and bakery products as a dough conditioner.',
|
||||
scholarlyNote: 'If sourced from human hair or pig, it is haram. Synthetic/fermentation-derived L-cysteine is halal but rarely labeled.' },
|
||||
{ eNumber: 'E921', name: 'L-Cystine', status: 'haram',
|
||||
category: 'Flour Treatment Agent',
|
||||
explanation: 'Oxidized form of L-cysteine. Same animal-derived sourcing concerns.',
|
||||
scholarlyNote: 'Same ruling as E920.' },
|
||||
{ eNumber: 'LARD', name: 'Lard', status: 'haram',
|
||||
category: 'Fat',
|
||||
explanation: 'Rendered pig fat. Strictly haram under all circumstances in Islamic dietary law.',
|
||||
scholarlyNote: 'Quran 2:173 explicitly forbids pork. Lard is pork-derived and carries unanimous prohibition across all madhabs.' },
|
||||
{ eNumber: 'CASTOR', name: 'Castoreum', status: 'haram',
|
||||
category: 'Flavouring',
|
||||
explanation: 'Exudate from castor sacs of beavers. Used in vanilla and raspberry flavourings.',
|
||||
scholarlyNote: 'Derived from an animal not slaughtered Islamically; classified as haram.' },
|
||||
{ eNumber: 'CIVET', name: 'Civet', status: 'haram',
|
||||
category: 'Flavouring',
|
||||
explanation: 'Musk-like secretion from the civet cat. Used in some traditional flavourings.',
|
||||
scholarlyNote: 'Impure secretion from a non-halal slaughtered source.' },
|
||||
{ eNumber: 'AMBER', name: 'Ambergris', status: 'haram',
|
||||
category: 'Flavouring',
|
||||
explanation: 'Waxy substance from sperm whale digestive tract. Debate exists among scholars.',
|
||||
scholarlyNote: 'Some Maliki scholars permit it (sea creature, dies naturally). Majority Hanafi/Shafi\'i consider it impure.' },
|
||||
|
||||
// ── MUSHBOOH (DOUBTFUL — NEEDS VERIFICATION) ──────────
|
||||
{ eNumber: 'E471', name: 'Mono- and Diglycerides', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Can be derived from plant oils (soy, palm) OR animal fats. Very common in bread, ice cream, margarine, and processed foods.',
|
||||
scholarlyNote: 'Check the source. Plant-derived E471 is halal. Animal-derived E471 is haram unless from halal-slaughtered animals. Halal certification determines the ruling.' },
|
||||
{ eNumber: 'E472a', name: 'Acetic Acid Esters of MG', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Esters of mono- and diglycerides. The fatty acid source determines halal status.',
|
||||
scholarlyNote: 'Same ruling as E471 — depends on the fatty acid source.' },
|
||||
{ eNumber: 'E472b', name: 'Lactic Acid Esters of MG', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Lactic acid esters. Fatty acid source may be animal or plant.',
|
||||
scholarlyNote: 'Dependent on the source of the fatty acids used.' },
|
||||
{ eNumber: 'E472c', name: 'Citric Acid Esters of MG', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Citric acid esters. The glycerol/fat source needs verification.',
|
||||
scholarlyNote: 'Depends on the origin of the raw materials.' },
|
||||
{ eNumber: 'E472d', name: 'Tartaric Acid Esters of MG', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Tartaric acid esters, common in bakery products.',
|
||||
scholarlyNote: 'Source of fatty acids determines halal status.' },
|
||||
{ eNumber: 'E472e', name: 'MAG of Diacetyl Tartaric Acid', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'DATEM — a dough strengthener used in bread-making.',
|
||||
scholarlyNote: 'Plant-derived DATEM is halal. Verify with manufacturer or halal certification.' },
|
||||
{ eNumber: 'E472f', name: 'Mixed Acetic & Tartaric Esters', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Mixed esters. Common emulsifier blend.',
|
||||
scholarlyNote: 'Check source. Plant-based = halal.' },
|
||||
{ eNumber: 'E473', name: 'Sucrose Esters of Fatty Acids', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Sugar ester. Fatty acid source may be animal or plant.',
|
||||
scholarlyNote: 'Must verify the fatty acid source.' },
|
||||
{ eNumber: 'E474', name: 'Sucroglycerides', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Combination of sucrose esters and glycerides.',
|
||||
scholarlyNote: 'Same verification needed as E471/E473.' },
|
||||
{ eNumber: 'E475', name: 'Polyglycerol Esters of FA', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Polyglycerol esters. Used in margarine, cakes, and desserts.',
|
||||
scholarlyNote: 'Animal or plant source — halal certification recommended.' },
|
||||
{ eNumber: 'E476', name: 'Polyglycerol Polyricinoleate', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'PGPR — used in chocolate to reduce cocoa butter content. The glycerol source determines status.',
|
||||
scholarlyNote: 'The glycerol backbone may be animal or plant-derived. With halal certification on chocolate, PGPR is typically plant-based.' },
|
||||
{ eNumber: 'E477', name: 'Propylene Glycol Esters of FA', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Propylene glycol esters. Fatty acid component needs verification.',
|
||||
scholarlyNote: 'Verify source of fatty acids.' },
|
||||
{ eNumber: 'E478', name: 'Lactylated Fatty Acid Esters', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Glycerol and fatty acid esters, lactylated.',
|
||||
scholarlyNote: 'Dependent on source materials.' },
|
||||
{ eNumber: 'E479b', name: 'Thermally Oxidized Soy Bean Oil', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Used as a release agent in frying. Soy is halal, but processing aids may not be.',
|
||||
scholarlyNote: 'Generally halal if no cross-contamination with haram oils.' },
|
||||
{ eNumber: 'E481', name: 'Sodium Stearoyl Lactylate', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'SSL — a common dough strengthener in commercial bread. The stearic acid may be animal or plant-derived.',
|
||||
scholarlyNote: 'Stearic acid source determines halal status. Most commercial SSL is plant-derived but verification is recommended.' },
|
||||
{ eNumber: 'E482', name: 'Calcium Stearoyl Lactylate', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'CSL — same concerns as SSL (E481).',
|
||||
scholarlyNote: 'Same ruling as E481.' },
|
||||
{ eNumber: 'E483', name: 'Stearyl Tartrate', status: 'mushbooh',
|
||||
category: 'Flour Treatment Agent',
|
||||
explanation: 'Dough strengthener. The stearyl component may be from animal fat.',
|
||||
scholarlyNote: 'Verify source with manufacturer.' },
|
||||
{ eNumber: 'E491', name: 'Sorbitan Monostearate', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'The stearic acid portion may be animal or plant-derived.',
|
||||
scholarlyNote: 'Plant-based = halal. Animal-based requires halal slaughter.' },
|
||||
{ eNumber: 'E492', name: 'Sorbitan Tristearate', status: 'mushbooh',
|
||||
category: 'Emulsifier', explanation: 'Same concerns as E491.', scholarlyNote: 'Same ruling as E491.' },
|
||||
{ eNumber: 'E493', name: 'Sorbitan Monolaurate', status: 'mushbooh',
|
||||
category: 'Emulsifier', explanation: 'Lauric acid from coconut or palm. Generally plant-based.',
|
||||
scholarlyNote: 'Likely halal — lauric acid is typically from coconut/palm kernel oil.' },
|
||||
{ eNumber: 'E494', name: 'Sorbitan Monooleate', status: 'mushbooh',
|
||||
category: 'Emulsifier', explanation: 'Oleic acid component.', scholarlyNote: 'Verify oleic acid source.' },
|
||||
{ eNumber: 'E495', name: 'Sorbitan Monopalmitate', status: 'mushbooh',
|
||||
category: 'Emulsifier', explanation: 'Palmitic acid component.', scholarlyNote: 'Verify palmitic acid source.' },
|
||||
{ eNumber: 'E570', name: 'Stearic Acid', status: 'mushbooh',
|
||||
category: 'Anti-caking Agent',
|
||||
explanation: 'Can be derived from animal fat (tallow) or plant oils (palm, coconut). Very widely used.',
|
||||
scholarlyNote: 'Plant-derived stearic acid is halal. Animal-derived requires halal slaughter. Halal certification clarifies this.' },
|
||||
{ eNumber: 'E322', name: 'Lecithin', status: 'mushbooh',
|
||||
category: 'Emulsifier',
|
||||
explanation: 'Commonly from soy (halal) or egg yolk (halal if from halal chicken). Rarely animal-derived. Most commercial lecithin is soy-based.',
|
||||
scholarlyNote: 'Soy lecithin is halal. Egg lecithin is halal. Only concern is if animal-derived (rare).' },
|
||||
{ eNumber: 'E422', name: 'Glycerol / Glycerin', status: 'mushbooh',
|
||||
category: 'Humectant',
|
||||
explanation: 'Can be from plant oils, animal fats, or petroleum. Used as a sweetener, humectant, and solvent.',
|
||||
scholarlyNote: 'Plant/petroleum glycerol is halal. Animal-derived requires halal slaughter. Halal-certified products use plant glycerol.' },
|
||||
{ eNumber: 'E626', name: 'Guanylic Acid', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'May use animal enzymes in production.',
|
||||
scholarlyNote: 'Check with manufacturer if animal enzymes were used.' },
|
||||
{ eNumber: 'E627', name: 'Sodium Guanylate', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'Sodium salt of guanylic acid. Same concerns.',
|
||||
scholarlyNote: 'Same ruling as E626.' },
|
||||
{ eNumber: 'E628', name: 'Potassium Guanylate', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'Potassium salt of guanylic acid.',
|
||||
scholarlyNote: 'Same ruling as E626.' },
|
||||
{ eNumber: 'E629', name: 'Calcium Guanylate', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'Calcium salt of guanylic acid.',
|
||||
scholarlyNote: 'Same ruling as E626.' },
|
||||
{ eNumber: 'E630', name: 'Inosinic Acid', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'May be produced using animal enzymes.',
|
||||
scholarlyNote: 'Verify production method with manufacturer.' },
|
||||
{ eNumber: 'E631', name: 'Sodium Inosinate', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'Common in instant noodles and snacks. Often produced with animal enzymes.',
|
||||
scholarlyNote: 'JAKIM requires halal-certified E631. Without certification, it is doubtful.' },
|
||||
{ eNumber: 'E632', name: 'Potassium Inosinate', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'Same concerns as E631.',
|
||||
scholarlyNote: 'Same ruling as E631.' },
|
||||
{ eNumber: 'E633', name: 'Calcium Inosinate', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'Same concerns.',
|
||||
scholarlyNote: 'Same ruling as E631.' },
|
||||
{ eNumber: 'E634', name: 'Calcium 5-Ribonucleotides', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'Blend of guanylate and inosinate.',
|
||||
scholarlyNote: 'Halal certification recommended due to potential animal enzymes.' },
|
||||
{ eNumber: 'E635', name: 'Disodium 5-Ribonucleotides', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'I+G — very common in snacks, chips, instant noodles.',
|
||||
scholarlyNote: 'Commonly uses animal enzymes. Look for halal logo or avoid if uncertified.' },
|
||||
{ eNumber: 'E640', name: 'Glycine', status: 'mushbooh',
|
||||
category: 'Flavour Enhancer', explanation: 'Can be from animal or plant protein.',
|
||||
scholarlyNote: 'Plant-derived glycine is halal. Verify source.' },
|
||||
{ eNumber: 'E901', name: 'Beeswax', status: 'mushbooh',
|
||||
category: 'Glazing Agent',
|
||||
explanation: 'Natural wax from honeybees. Generally considered halal by most scholars as it is a plant/insect secretion, not the insect itself.',
|
||||
scholarlyNote: 'Majority of scholars (Shafi\'i, Hanafi) consider beeswax halal. Some scholars are cautious.' },
|
||||
{ eNumber: 'E1103', name: 'Invertase', status: 'mushbooh',
|
||||
category: 'Enzyme', explanation: 'May be animal or microbial source.',
|
||||
scholarlyNote: 'Microbial invertase is halal. Animal-derived needs verification.' },
|
||||
{ eNumber: 'E1105', name: 'Lysozyme', status: 'mushbooh',
|
||||
category: 'Preservative',
|
||||
explanation: 'Usually derived from egg white. Halal if from halal chicken eggs. Rarely from other animal sources.',
|
||||
scholarlyNote: 'Egg-derived lysozyme from halal sources is halal. Check source.' },
|
||||
|
||||
// ── NON-E-NUMBER MUSHBOOH ──────────────────────────────
|
||||
{ eNumber: 'RENNET', name: 'Rennet', status: 'mushbooh',
|
||||
category: 'Enzyme',
|
||||
explanation: 'Used in cheese-making. Can be animal-derived (calf stomach), microbial, or plant-based. Animal rennet requires halal slaughter.',
|
||||
scholarlyNote: 'Microbial/plant rennet is halal. Animal rennet from non-halal-slaughtered animals is haram. Many commercial cheeses now use microbial rennet.' },
|
||||
{ eNumber: 'WHEY', name: 'Whey', status: 'mushbooh',
|
||||
category: 'Dairy Derivative',
|
||||
explanation: 'By-product of cheese-making. Halal status depends on the rennet used in the cheese production.',
|
||||
scholarlyNote: 'If made with microbial/plant rennet or halal animal rennet, whey is halal. If made with haram rennet, whey is haram.' },
|
||||
{ eNumber: 'PEPSIN', name: 'Pepsin', status: 'mushbooh',
|
||||
category: 'Enzyme',
|
||||
explanation: 'Digestive enzyme, commonly from pig stomach. Can also be synthetic.',
|
||||
scholarlyNote: 'Porcine pepsin is haram. Synthetic or plant-derived alternatives exist — verify source.' },
|
||||
{ eNumber: 'TRYPSIN', name: 'Trypsin', status: 'mushbooh',
|
||||
category: 'Enzyme', explanation: 'Digestive enzyme from animal pancreas.',
|
||||
scholarlyNote: 'Animal-derived trypsin from non-halal sources is haram. Microbial alternatives are halal.' },
|
||||
{ eNumber: 'LIPASE', name: 'Lipase', status: 'mushbooh',
|
||||
category: 'Enzyme',
|
||||
explanation: 'Fat-digesting enzyme. Can be from animal pancreas, calf stomach, or microbial sources.',
|
||||
scholarlyNote: 'Microbial lipase is halal. Animal-derived lipase requires verification of halal slaughter.' },
|
||||
|
||||
// ── HALAL (CONFIRMED SAFE) ─────────────────────────────
|
||||
{ eNumber: 'E100', name: 'Curcumin', status: 'halal',
|
||||
category: 'Colouring Agent', explanation: 'Natural yellow colour from turmeric root. Plant-derived.',
|
||||
scholarlyNote: 'Universally halal — pure plant extract.' },
|
||||
{ eNumber: 'E101', name: 'Riboflavin (Vitamin B2)', status: 'halal',
|
||||
category: 'Colouring Agent', explanation: 'Naturally present in many foods. Synthetically produced for food use.',
|
||||
scholarlyNote: 'Halal — synthetic production avoids animal concerns.' },
|
||||
{ eNumber: 'E140', name: 'Chlorophyll', status: 'halal',
|
||||
category: 'Colouring Agent', explanation: 'Green pigment from plants (alfalfa, nettles).',
|
||||
scholarlyNote: 'Pure plant derivative — halal.' },
|
||||
{ eNumber: 'E150a', name: 'Plain Caramel', status: 'halal',
|
||||
category: 'Colouring Agent', explanation: 'Caramelized sugar. No animal products involved.',
|
||||
scholarlyNote: 'Halal — sugar-based.' },
|
||||
{ eNumber: 'E160a', name: 'Beta-Carotene', status: 'halal',
|
||||
category: 'Colouring Agent', explanation: 'Orange pigment from carrots, sweet potatoes, or synthetic production.',
|
||||
scholarlyNote: 'Halal — plant-derived or synthetic.' },
|
||||
{ eNumber: 'E162', name: 'Beetroot Red', status: 'halal',
|
||||
category: 'Colouring Agent', explanation: 'Natural red colour from beetroot.',
|
||||
scholarlyNote: 'Pure plant extract — halal.' },
|
||||
{ eNumber: 'E163', name: 'Anthocyanins', status: 'halal',
|
||||
category: 'Colouring Agent', explanation: 'Red/blue/purple pigments from grape skins and other fruits.',
|
||||
scholarlyNote: 'Plant-derived — halal.' },
|
||||
{ eNumber: 'E170', name: 'Calcium Carbonate', status: 'halal',
|
||||
category: 'Colouring Agent / Mineral', explanation: 'Natural mineral (limestone, chalk).',
|
||||
scholarlyNote: 'Mineral source — halal.' },
|
||||
{ eNumber: 'E200', name: 'Sorbic Acid', status: 'halal',
|
||||
category: 'Preservative', explanation: 'Synthetic or from rowan berries.',
|
||||
scholarlyNote: 'Halal — no animal involvement.' },
|
||||
{ eNumber: 'E201', name: 'Sodium Sorbate', status: 'halal',
|
||||
category: 'Preservative', explanation: 'Sodium salt of sorbic acid.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E202', name: 'Potassium Sorbate', status: 'halal',
|
||||
category: 'Preservative', explanation: 'Potassium salt of sorbic acid.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E203', name: 'Calcium Sorbate', status: 'halal',
|
||||
category: 'Preservative', explanation: 'Calcium salt of sorbic acid.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E210', name: 'Benzoic Acid', status: 'halal',
|
||||
category: 'Preservative', explanation: 'Synthetic preservative.',
|
||||
scholarlyNote: 'Halal — synthetic.' },
|
||||
{ eNumber: 'E211', name: 'Sodium Benzoate', status: 'halal',
|
||||
category: 'Preservative', explanation: 'Sodium salt of benzoic acid.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E260', name: 'Acetic Acid', status: 'halal',
|
||||
category: 'Acidity Regulator', explanation: 'Vinegar component. Synthetically produced for food use.',
|
||||
scholarlyNote: 'Halal — synthetic or fermentation-derived.' },
|
||||
{ eNumber: 'E270', name: 'Lactic Acid', status: 'halal',
|
||||
category: 'Acidity Regulator', explanation: 'Fermentation-derived, typically from plant sugars.',
|
||||
scholarlyNote: 'Halal unless specifically from animal milk in a non-halal context (extremely rare).' },
|
||||
{ eNumber: 'E290', name: 'Carbon Dioxide', status: 'halal',
|
||||
category: 'Propellant', explanation: 'The gas that makes drinks fizzy.',
|
||||
scholarlyNote: 'Universally halal.' },
|
||||
{ eNumber: 'E296', name: 'Malic Acid', status: 'halal',
|
||||
category: 'Acidity Regulator', explanation: 'Fruit acid from apples. Synthetic production common.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E300', name: 'Ascorbic Acid (Vitamin C)', status: 'halal',
|
||||
category: 'Antioxidant', explanation: 'Synthetic vitamin C. No animal source.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E306', name: 'Tocopherols (Vitamin E)', status: 'halal',
|
||||
category: 'Antioxidant', explanation: 'From plant oils (soy, sunflower, corn).',
|
||||
scholarlyNote: 'Plant-derived — halal.' },
|
||||
{ eNumber: 'E330', name: 'Citric Acid', status: 'halal',
|
||||
category: 'Acidity Regulator', explanation: 'Fermented from molasses or corn. Most common food acid.',
|
||||
scholarlyNote: 'Universally halal — fermentation product.' },
|
||||
{ eNumber: 'E334', name: 'Tartaric Acid', status: 'halal',
|
||||
category: 'Acidity Regulator', explanation: 'From grapes (wine industry by-product).',
|
||||
scholarlyNote: 'Halal — the acid is chemically distinct from alcohol and does not intoxicate.' },
|
||||
{ eNumber: 'E338', name: 'Phosphoric Acid', status: 'halal',
|
||||
category: 'Acidity Regulator', explanation: 'Mineral acid. Used in cola drinks.',
|
||||
scholarlyNote: 'Mineral source — halal.' },
|
||||
{ eNumber: 'E400', name: 'Alginic Acid', status: 'halal',
|
||||
category: 'Thickener', explanation: 'From brown seaweed.',
|
||||
scholarlyNote: 'Plant/marine source — halal.' },
|
||||
{ eNumber: 'E401', name: 'Sodium Alginate', status: 'halal',
|
||||
category: 'Thickener', explanation: 'Sodium salt of alginic acid.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E406', name: 'Agar', status: 'halal',
|
||||
category: 'Gelling Agent', explanation: 'From red algae (seaweed). Common in Asian desserts.',
|
||||
scholarlyNote: 'Universally halal — pure seaweed extract.' },
|
||||
{ eNumber: 'E407', name: 'Carrageenan', status: 'halal',
|
||||
category: 'Thickener', explanation: 'From red seaweed (Irish moss).',
|
||||
scholarlyNote: 'Plant-derived — halal.' },
|
||||
{ eNumber: 'E410', name: 'Locust Bean Gum', status: 'halal',
|
||||
category: 'Thickener', explanation: 'From carob tree seeds.',
|
||||
scholarlyNote: 'Plant-derived — halal.' },
|
||||
{ eNumber: 'E412', name: 'Guar Gum', status: 'halal',
|
||||
category: 'Thickener', explanation: 'From guar beans.',
|
||||
scholarlyNote: 'Plant-derived — halal.' },
|
||||
{ eNumber: 'E414', name: 'Gum Arabic', status: 'halal',
|
||||
category: 'Thickener', explanation: 'From acacia tree sap.',
|
||||
scholarlyNote: 'Plant-derived — halal.' },
|
||||
{ eNumber: 'E415', name: 'Xanthan Gum', status: 'halal',
|
||||
category: 'Thickener', explanation: 'Fermentation product from corn sugar.',
|
||||
scholarlyNote: 'Halal — fermentation-derived from plant sugars.' },
|
||||
{ eNumber: 'E440', name: 'Pectin', status: 'halal',
|
||||
category: 'Gelling Agent', explanation: 'From apple pomace and citrus peel.',
|
||||
scholarlyNote: 'Universally halal — pure fruit extract.' },
|
||||
{ eNumber: 'E466', name: 'Carboxymethylcellulose', status: 'halal',
|
||||
category: 'Thickener', explanation: 'Modified plant cellulose.',
|
||||
scholarlyNote: 'Plant-derived — halal.' },
|
||||
{ eNumber: 'E500', name: 'Sodium Carbonates', status: 'halal',
|
||||
category: 'Acidity Regulator', explanation: 'Baking soda, soda ash. Mineral source.',
|
||||
scholarlyNote: 'Mineral source — halal.' },
|
||||
{ eNumber: 'E501', name: 'Potassium Carbonates', status: 'halal',
|
||||
category: 'Acidity Regulator', explanation: 'Mineral source.',
|
||||
scholarlyNote: 'Halal — mineral-derived.' },
|
||||
{ eNumber: 'E509', name: 'Calcium Chloride', status: 'halal',
|
||||
category: 'Firming Agent', explanation: 'Mineral salt.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E551', name: 'Silicon Dioxide', status: 'halal',
|
||||
category: 'Anti-caking Agent', explanation: 'Silica — sand-derived mineral.',
|
||||
scholarlyNote: 'Mineral — halal.' },
|
||||
{ eNumber: 'E621', name: 'MSG (Monosodium Glutamate)', status: 'halal',
|
||||
category: 'Flavour Enhancer', explanation: 'Fermented from molasses, tapioca, or corn. Plant-based fermentation.',
|
||||
scholarlyNote: 'Halal — fermentation product. Note: not the same as E631/E635 which are often animal-derived.' },
|
||||
{ eNumber: 'E901', name: 'Beeswax', status: 'mushbooh',
|
||||
category: 'Glazing Agent', explanation: 'See mushbooh section.',
|
||||
scholarlyNote: 'Majority halal per JAKIM/MUI. Some scholars more cautious.' },
|
||||
{ eNumber: 'E950', name: 'Acesulfame K', status: 'halal',
|
||||
category: 'Sweetener', explanation: 'Synthetic sweetener.',
|
||||
scholarlyNote: 'Halal — synthetic.' },
|
||||
{ eNumber: 'E951', name: 'Aspartame', status: 'halal',
|
||||
category: 'Sweetener', explanation: 'Synthetic dipeptide sweetener.',
|
||||
scholarlyNote: 'Halal — synthetic. No animal-derived components.' },
|
||||
{ eNumber: 'E954', name: 'Saccharin', status: 'halal',
|
||||
category: 'Sweetener', explanation: 'Synthetic sweetener.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E955', name: 'Sucralose', status: 'halal',
|
||||
category: 'Sweetener', explanation: 'Chlorinated sugar derivative.',
|
||||
scholarlyNote: 'Halal — synthetic.' },
|
||||
{ eNumber: 'E965', name: 'Maltitol', status: 'halal',
|
||||
category: 'Sweetener', explanation: 'Sugar alcohol from starch.',
|
||||
scholarlyNote: 'Halal — plant starch source.' },
|
||||
{ eNumber: 'E967', name: 'Xylitol', status: 'halal',
|
||||
category: 'Sweetener', explanation: 'Sugar alcohol from birch bark or corn.',
|
||||
scholarlyNote: 'Halal — plant source.' },
|
||||
{ eNumber: 'E968', name: 'Erythritol', status: 'halal',
|
||||
category: 'Sweetener', explanation: 'Fermented sugar alcohol.',
|
||||
scholarlyNote: 'Halal.' },
|
||||
{ eNumber: 'E1404', name: 'Oxidized Starch', status: 'halal',
|
||||
category: 'Thickener', explanation: 'Modified plant starch.',
|
||||
scholarlyNote: 'Halal — plant source.' },
|
||||
{ eNumber: 'E1520', name: 'Propylene Glycol', status: 'halal',
|
||||
category: 'Humectant', explanation: 'Synthetic. Not to be confused with animal-derived glycerol.',
|
||||
scholarlyNote: 'Halal — petrochemical derivative, not animal-based.' },
|
||||
];
|
||||
|
||||
/** Look up an ingredient by E-number (case-insensitive) */
|
||||
export function findByENumber(code) {
|
||||
const upper = String(code).toUpperCase().trim();
|
||||
return HARAM_INGREDIENTS.find(i => i.eNumber.toUpperCase() === upper) || null;
|
||||
}
|
||||
|
||||
/** Look up ingredient by name (fuzzy) */
|
||||
export function findByName(name) {
|
||||
const lower = name.toLowerCase().trim();
|
||||
return HARAM_INGREDIENTS.find(i => i.name.toLowerCase().includes(lower))
|
||||
|| HARAM_INGREDIENTS.find(i => lower.includes(i.name.toLowerCase()))
|
||||
|| null;
|
||||
}
|
||||
|
||||
/** Resolve an ingredient string (E-number, alias, or name) to its DB entry */
|
||||
export function resolveIngredient(raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Try direct E-number match
|
||||
const enMatch = findByENumber(trimmed);
|
||||
if (enMatch) return enMatch;
|
||||
|
||||
// Try alias lookup
|
||||
const key = trimmed.toLowerCase();
|
||||
const canonical = INGREDIENT_ALIASES[key];
|
||||
if (canonical) {
|
||||
const aliasMatch = findByENumber(canonical);
|
||||
if (aliasMatch) return aliasMatch;
|
||||
}
|
||||
|
||||
// Try name match
|
||||
return findByName(trimmed);
|
||||
}
|
||||
|
||||
/** Get certification body by ID */
|
||||
export function getCert(id) {
|
||||
return CERTIFICATION_BODIES.find(c => c.id === id) || null;
|
||||
}
|
||||
|
||||
/** Calculate halal confidence score (0-100) */
|
||||
export function calculateConfidence(restaurant) {
|
||||
if (!restaurant) return 0;
|
||||
const certs = (restaurant.certifications || []).map(c => getCert(c)).filter(Boolean);
|
||||
const maxTrust = certs.length > 0 ? Math.max(...certs.map(c => c.trustLevel)) : 0;
|
||||
const certScore = maxTrust * 15;
|
||||
const ratingScore = (restaurant.rating || 0) * 4;
|
||||
const reviewCount = (restaurant.userReviews || []).length;
|
||||
const communityScore = Math.min(reviewCount * 2, 20);
|
||||
const ownerClaimScore = restaurant.muslimOwned ? 5 : 0;
|
||||
return Math.min(Math.round(certScore + ratingScore + communityScore + ownerClaimScore), 100);
|
||||
}
|
||||
|
||||
/** Get confidence color class */
|
||||
export function confidenceColor(score) {
|
||||
if (score >= 80) return '#16a34a';
|
||||
if (score >= 50) return '#d97706';
|
||||
if (score >= 30) return '#f59e0b';
|
||||
return '#9ca3af';
|
||||
}
|
||||
|
||||
/** Get certification badge color */
|
||||
export function certBadgeColor(restaurant) {
|
||||
const certs = (restaurant.certifications || []).map(c => getCert(c)).filter(Boolean);
|
||||
if (certs.some(c => c.trustLevel >= 4)) return '#16a34a';
|
||||
if (certs.length > 0) return '#d97706';
|
||||
if (restaurant.muslimOwned) return '#d97706';
|
||||
return '#9ca3af';
|
||||
}
|
||||
|
||||
/** Get certification status label */
|
||||
export function certStatusLabel(restaurant) {
|
||||
const certs = (restaurant.certifications || []).map(c => getCert(c)).filter(Boolean);
|
||||
if (certs.some(c => c.trustLevel >= 4)) {
|
||||
const names = certs.filter(c => c.trustLevel >= 4).map(c => c.name).join(', ');
|
||||
return `✅ Certified: ${names}`;
|
||||
}
|
||||
if (certs.length > 0) {
|
||||
const names = certs.map(c => c.name).join(', ');
|
||||
return `📋 Self-Claimed: ${names}`;
|
||||
}
|
||||
if (restaurant.muslimOwned) return '👤 Muslim-Owned (Uncertified)';
|
||||
return '❓ Status Unknown';
|
||||
}
|
||||
@@ -27,6 +27,21 @@ export default defineConfig({
|
||||
urlPattern: /^https:\/\/api\.alquran\.cloud\/.*/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: { cacheName: 'quran-api', expiration: { maxEntries: 50, maxAgeSeconds: 86400 * 7 } }
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/unpkg\.com\/leaflet.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: { cacheName: 'leaflet-lib', expiration: { maxEntries: 5, maxAgeSeconds: 86400 * 30 } }
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/.*\.tile\.openstreetmap\.org\/.*/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: { cacheName: 'osm-tiles', expiration: { maxEntries: 200, maxAgeSeconds: 86400 * 7 } }
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/cdn\.jsdelivr\.net\/npm\/tesseract\.js.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: { cacheName: 'tesseract', expiration: { maxEntries: 10, maxAgeSeconds: 86400 * 30 } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user