From d2883aaa2848f1f2451528962ce0b7c0d19d031f Mon Sep 17 00:00:00 2001 From: HariKrishna Date: Tue, 30 Jun 2026 17:35:15 +0530 Subject: [PATCH 01/11] feat: implement robust PDF parsing engine and integrate Groq LLM rule parser. --- discount-engine-assignment-main/.gitignore | 33 +++ discount-engine-assignment-main/README.md | 87 +++++++ discount-engine-assignment-main/index.html | 12 + discount-engine-assignment-main/package.json | 20 ++ .../sample-data/cart.csv | 7 + .../sample-data/rules.csv | 4 + discount-engine-assignment-main/src/App.jsx | 231 ++++++++++++++++++ .../src/components/CsvUploader.jsx | 66 +++++ .../src/components/DataTable.jsx | 66 +++++ .../src/components/ErrorBanner.jsx | 29 +++ .../src/engine/csvParser.js | 134 ++++++++++ .../src/engine/discountEngine.js | 174 +++++++++++++ discount-engine-assignment-main/src/index.css | 2 + discount-engine-assignment-main/src/main.jsx | 10 + .../vite.config.js | 6 + package-lock.json | 6 + 16 files changed, 887 insertions(+) create mode 100644 discount-engine-assignment-main/.gitignore create mode 100644 discount-engine-assignment-main/README.md create mode 100644 discount-engine-assignment-main/index.html create mode 100644 discount-engine-assignment-main/package.json create mode 100644 discount-engine-assignment-main/sample-data/cart.csv create mode 100644 discount-engine-assignment-main/sample-data/rules.csv create mode 100644 discount-engine-assignment-main/src/App.jsx create mode 100644 discount-engine-assignment-main/src/components/CsvUploader.jsx create mode 100644 discount-engine-assignment-main/src/components/DataTable.jsx create mode 100644 discount-engine-assignment-main/src/components/ErrorBanner.jsx create mode 100644 discount-engine-assignment-main/src/engine/csvParser.js create mode 100644 discount-engine-assignment-main/src/engine/discountEngine.js create mode 100644 discount-engine-assignment-main/src/index.css create mode 100644 discount-engine-assignment-main/src/main.jsx create mode 100644 discount-engine-assignment-main/vite.config.js create mode 100644 package-lock.json diff --git a/discount-engine-assignment-main/.gitignore b/discount-engine-assignment-main/.gitignore new file mode 100644 index 0000000..ade0a9d --- /dev/null +++ b/discount-engine-assignment-main/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +dist-ssr/ + +# Vite cache +.vite/ + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor directories +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS files +.DS_Store +Thumbs.db diff --git a/discount-engine-assignment-main/README.md b/discount-engine-assignment-main/README.md new file mode 100644 index 0000000..3cbd3a3 --- /dev/null +++ b/discount-engine-assignment-main/README.md @@ -0,0 +1,87 @@ +# Opptra Discount Engine — Base Implementation + +This is the base implementation for the Opptra FDE Intern assignment. +Fork this repo, complete the tasks in the assignment brief, and submit your GitHub link + Loom. + +## Running locally + +```bash +npm install +npm run dev +``` + +Open http://localhost:5173 + +## Deploying + +```bash +npm run build +``` + +Deploy the `dist/` folder to Vercel, Netlify, or any static host. +The live deployment URL must be in your README before submission. + +## How to use + +1. Upload `sample-data/rules.csv` as the discount rules input +2. Upload `sample-data/cart.csv` as the cart input +3. Click **Calculate Discounts** + +## Project structure + +``` +src/ + engine/ + discountEngine.js ← pure discount logic (no UI) + csvParser.js ← CSV → typed objects + components/ + CsvUploader.jsx ← file upload area + DataTable.jsx ← reusable table + ErrorBanner.jsx ← parse error display + App.jsx ← main UI + state + main.jsx ← entry point + +sample-data/ + rules.csv ← sample discount rules + cart.csv ← sample cart items +``` + +## CSV formats + +**rules.csv** + +| Column | Type | Example | +|------------|-------------------|------------------| +| rule_id | string | RULE-01 | +| scope | brand \| platform | platform | +| applies_to | string | Amazon India | +| type | percentage \| flat| percentage | +| value | number | 15 | +| stackable | true \| false | false | + +**cart.csv** + +| Column | Type | Example | +|------------|--------|--------------| +| item_id | string | ITEM-01 | +| product | string | Cushion Cover| +| brand | string | Natura Casa | +| platform | string | Amazon India | +| base_price | number | 1299 | + +## Discount logic + +- When multiple non-stackable rules match an item, the one giving the **largest saving in rupees** is applied. +- Rules marked `stackable: true` apply **on top of** the winning non-stackable rule. +- If no rules match, the base price is returned with a "No offers available" note. + +## Expected results for the sample data + +| Item | Base Price | Final Price | Reasoning | +|---------|-----------|-------------|----------------------------------------| +| ITEM-01 | Rs.1,299 | Rs.1,104 | Platform offer: 15% off (beats Rs.150) | +| ITEM-02 | Rs.849 | Rs.629 | Brand offer: Rs.150 off + Platform 10% | +| ITEM-03 | Rs.599 | Rs.509 | Platform offer: 15% off | +| ITEM-04 | Rs.2,499 | Rs.2,499 | No offers available | +| ITEM-05 | Rs.449 | Rs.382 | Platform offer: 15% off | +| ITEM-06 | Rs.899 | Rs.809 | Platform offer: 10% off | diff --git a/discount-engine-assignment-main/index.html b/discount-engine-assignment-main/index.html new file mode 100644 index 0000000..5257e0f --- /dev/null +++ b/discount-engine-assignment-main/index.html @@ -0,0 +1,12 @@ + + + + + + Opptra Discount Engine + + +
+ + + diff --git a/discount-engine-assignment-main/package.json b/discount-engine-assignment-main/package.json new file mode 100644 index 0000000..8c3f88f --- /dev/null +++ b/discount-engine-assignment-main/package.json @@ -0,0 +1,20 @@ +{ + "name": "discount-engine", + "version": "1.0.0", + "description": "Opptra FDE Intern Assignment — Discount Engine", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "papaparse": "^5.4.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.0" + } +} diff --git a/discount-engine-assignment-main/sample-data/cart.csv b/discount-engine-assignment-main/sample-data/cart.csv new file mode 100644 index 0000000..1d7797e --- /dev/null +++ b/discount-engine-assignment-main/sample-data/cart.csv @@ -0,0 +1,7 @@ +item_id,product,brand,platform,base_price +ITEM-01,Cushion Cover,Natura Casa,Amazon India,1299 +ITEM-02,Bed Sheet Set,Natura Casa,Flipkart,849 +ITEM-03,Wall Shelf,LivSpace Pro,Amazon India,599 +ITEM-04,Ceramic Vase,LivSpace Pro,Noon,2499 +ITEM-05,Cutting Board,Nordic Basics,Amazon India,449 +ITEM-06,Desk Organiser,Nordic Basics,Flipkart,899 diff --git a/discount-engine-assignment-main/sample-data/rules.csv b/discount-engine-assignment-main/sample-data/rules.csv new file mode 100644 index 0000000..d0b6261 --- /dev/null +++ b/discount-engine-assignment-main/sample-data/rules.csv @@ -0,0 +1,4 @@ +rule_id,scope,applies_to,type,value,stackable +RULE-01,platform,Amazon India,percentage,15,false +RULE-02,brand,Natura Casa,flat,150,false +RULE-03,platform,Flipkart,percentage,10,true diff --git a/discount-engine-assignment-main/src/App.jsx b/discount-engine-assignment-main/src/App.jsx new file mode 100644 index 0000000..7430c07 --- /dev/null +++ b/discount-engine-assignment-main/src/App.jsx @@ -0,0 +1,231 @@ +/** + * App.jsx + * + * Top-level component. Manages state for rules, cart items, and results. + * Wires together CSV upload → parse → engine → display. + */ + +import { useState } from 'react' +import CsvUploader from './components/CsvUploader.jsx' +import DataTable from './components/DataTable.jsx' +import ErrorBanner from './components/ErrorBanner.jsx' +import { parseRulesCSV, parseCartCSV } from './engine/csvParser.js' +import { processCart, cartTotal } from './engine/discountEngine.js' + +// ── Column definitions ─────────────────────────────────────────── + +const RULES_COLUMNS = [ + { key: 'ruleId', label: 'Rule ID' }, + { key: 'scope', label: 'Scope', render: (v) => v.charAt(0).toUpperCase() + v.slice(1) }, + { key: 'appliesTo', label: 'Applies To' }, + { key: 'type', label: 'Type', render: (v) => v.charAt(0).toUpperCase() + v.slice(1) }, + { + key: 'value', + label: 'Value', + render: (v, row) => row.type === 'percentage' ? `${v}% off` : `Rs.${v} off`, + }, + { key: 'stackable', label: 'Stackable', render: (v) => (v ? 'Yes' : 'No') }, +] + +const CART_COLUMNS = [ + { key: 'itemId', label: 'Item' }, + { key: 'product', label: 'Product' }, + { key: 'brand', label: 'Brand' }, + { key: 'platform', label: 'Platform' }, + { key: 'basePrice', label: 'Base Price', render: (v) => `Rs.${v.toLocaleString('en-IN')}` }, +] + +const RESULTS_COLUMNS = [ + { key: 'itemId', label: 'Item' }, + { key: 'product', label: 'Product' }, + { key: 'basePrice', label: 'Base Price', render: (v) => `Rs.${v.toLocaleString('en-IN')}` }, + { key: 'finalPrice',label: 'Final Price', + render: (v, row) => ( + 0 ? '#1e5c2c' : '#131A48' }}> + Rs.{v.toLocaleString('en-IN')} + + ), + }, + { + key: 'totalDiscount', + label: 'You Save', + render: (v) => + v > 0 ? ( + Rs.{v.toLocaleString('en-IN')} + ) : ( + + ), + }, + { + key: 'reasoning', + label: 'Offer Applied', + render: (v) => ( + + {v} + + ), + }, +] + +// ── Styles ─────────────────────────────────────────────────────── + +const S = { + page: { minHeight: '100vh', background: '#f7f7f9', fontFamily: 'Arial, sans-serif' }, + header: { background: '#131A48', padding: '0.85rem 2rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, + logoTxt: { fontFamily: 'Georgia, serif', fontSize: 17, fontWeight: 700, color: '#fff', letterSpacing: '-0.02em' }, + logoSpan:{ color: '#FF5800' }, + headerSub: { fontSize: 11, color: 'rgba(255,255,255,0.5)', textTransform: 'uppercase', letterSpacing: '0.07em' }, + main: { maxWidth: 960, margin: '0 auto', padding: '1.8rem 1.5rem' }, + section: { background: '#fff', border: '1px solid #CECECE', borderRadius: 6, padding: '1.2rem 1.4rem', marginBottom: '1.2rem' }, + sectionTitle: { fontFamily: 'Georgia, serif', fontWeight: 700, fontSize: 14, color: '#131A48', marginBottom: '0.7rem', paddingBottom: 6, borderBottom: '2px solid #FF5800', display: 'inline-block' }, + grid2: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }, + btn: { + background: '#FF5800', color: '#fff', border: 'none', borderRadius: 4, + padding: '0.65rem 2rem', fontSize: 13, fontWeight: 700, cursor: 'pointer', + letterSpacing: '0.04em', textTransform: 'uppercase', + }, + btnDisabled: { + background: '#CECECE', color: '#fff', border: 'none', borderRadius: 4, + padding: '0.65rem 2rem', fontSize: 13, fontWeight: 700, cursor: 'not-allowed', + letterSpacing: '0.04em', textTransform: 'uppercase', + }, + totalRow: { + display: 'flex', justifyContent: 'flex-end', alignItems: 'center', + gap: '1rem', marginTop: '0.75rem', paddingTop: '0.75rem', + borderTop: '2px solid #131A48', + }, + totalLabel: { fontWeight: 700, fontSize: 14, color: '#131A48' }, + totalValue: { fontWeight: 700, fontSize: 16, color: '#131A48' }, + tag: (color, bg) => ({ + display: 'inline-block', fontSize: 10, fontWeight: 700, padding: '1px 6px', + borderRadius: 20, background: bg, color, textTransform: 'uppercase', letterSpacing: '0.04em', + }), +} + +// ── Component ──────────────────────────────────────────────────── + +export default function App() { + const [rules, setRules] = useState([]) + const [rulesErrors, setRulesErr] = useState([]) + const [rulesFileName, setRulesFileName] = useState('') + + const [cartItems, setCartItems] = useState([]) + const [cartErrors, setCartErrors] = useState([]) + const [cartFileName, setCartFileName] = useState('') + + const [results, setResults] = useState(null) + + // ── Handlers ── + + function handleRulesLoad(csvText, fileName) { + const { data, errors } = parseRulesCSV(csvText) + setRules(data) + setRulesErr(errors) + setRulesFileName(fileName) + setResults(null) // clear stale results + } + + function handleCartLoad(csvText, fileName) { + const { data, errors } = parseCartCSV(csvText) + setCartItems(data) + setCartErrors(errors) + setCartFileName(fileName) + setResults(null) + } + + function handleCalculate() { + const res = processCart(cartItems, rules) + setResults(res) + } + + const canCalculate = rules.length > 0 && cartItems.length > 0 + + // ── Render ── + + return ( +
+ {/* Header */} +
+
Opptra
+
Discount Engine
+
+ +
+ + {/* Upload row */} +
+ {/* Rules upload */} +
+
Discount Rules
+ 0} + fileName={rulesFileName} + /> + + {rules.length > 0 && ( +
+
+ {rules.length} rule{rules.length > 1 ? 's' : ''} loaded +
+ +
+ )} +
+ + {/* Cart upload */} +
+
Cart Items
+ 0} + fileName={cartFileName} + /> + + {cartItems.length > 0 && ( +
+
+ {cartItems.length} item{cartItems.length > 1 ? 's' : ''} loaded +
+ +
+ )} +
+
+ + {/* Calculate button */} +
+ + {!canCalculate && ( +
+ Upload both files to calculate +
+ )} +
+ + {/* Results */} + {results && ( +
+
Cart Summary
+ +
+ Cart Total + Rs.{cartTotal(results).toLocaleString('en-IN')} +
+
+ )} + +
+
+ ) +} diff --git a/discount-engine-assignment-main/src/components/CsvUploader.jsx b/discount-engine-assignment-main/src/components/CsvUploader.jsx new file mode 100644 index 0000000..649cf81 --- /dev/null +++ b/discount-engine-assignment-main/src/components/CsvUploader.jsx @@ -0,0 +1,66 @@ +/** + * CsvUploader.jsx + * + * Renders a file upload area for a single CSV file. + * Calls onLoad(rawText) when a file is selected. + */ + +import { useRef } from 'react' + +export default function CsvUploader({ label, description, onLoad, hasData, fileName }) { + const inputRef = useRef(null) + + function handleFile(e) { + const file = e.target.files[0] + if (!file) return + const reader = new FileReader() + reader.onload = (evt) => onLoad(evt.target.result, file.name) + reader.readAsText(file) + // Reset input so the same file can be re-uploaded + e.target.value = '' + } + + return ( +
inputRef.current?.click()} + > + +
+ {hasData ? '✅' : '📄'} +
+
{label}
+
+ {hasData ? fileName : description} +
+
+
+ + {hasData ? 'Change' : 'Upload'} + +
+
+
+ ) +} diff --git a/discount-engine-assignment-main/src/components/DataTable.jsx b/discount-engine-assignment-main/src/components/DataTable.jsx new file mode 100644 index 0000000..525c64d --- /dev/null +++ b/discount-engine-assignment-main/src/components/DataTable.jsx @@ -0,0 +1,66 @@ +/** + * DataTable.jsx + * + * Renders a simple table from an array of objects. + * Columns are defined as [{ key, label, render? }]. + */ + +export default function DataTable({ columns, rows, emptyMessage = 'No data loaded.' }) { + if (!rows || rows.length === 0) { + return ( +
+ {emptyMessage} +
+ ) + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.label} +
+ {col.render ? col.render(row[col.key], row) : row[col.key] ?? '—'} +
+
+ ) +} diff --git a/discount-engine-assignment-main/src/components/ErrorBanner.jsx b/discount-engine-assignment-main/src/components/ErrorBanner.jsx new file mode 100644 index 0000000..4ee20a8 --- /dev/null +++ b/discount-engine-assignment-main/src/components/ErrorBanner.jsx @@ -0,0 +1,29 @@ +/** + * ErrorBanner.jsx + * Displays a list of parse or validation errors. + */ + +export default function ErrorBanner({ errors }) { + if (!errors || errors.length === 0) return null + return ( +
+
+ {errors.length} issue{errors.length > 1 ? 's' : ''} found +
+ {errors.map((e, i) => ( +
+ {e} +
+ ))} +
+ ) +} diff --git a/discount-engine-assignment-main/src/engine/csvParser.js b/discount-engine-assignment-main/src/engine/csvParser.js new file mode 100644 index 0000000..92b8d6b --- /dev/null +++ b/discount-engine-assignment-main/src/engine/csvParser.js @@ -0,0 +1,134 @@ +/** + * csvParser.js + * + * Converts raw CSV text into the typed objects the discount engine expects. + * Uses papaparse for reliable CSV parsing, then maps column names to the + * internal data shapes. + * + * Expected rules.csv columns: + * rule_id, scope, applies_to, type, value, stackable + * + * Expected cart.csv columns: + * item_id, product, brand, platform, base_price + */ + +import Papa from 'papaparse' + +/** + * Parses the raw text of rules.csv into an array of DiscountRule objects. + * Returns { data, errors } where errors is an array of row-level issues. + */ +export function parseRulesCSV(csvText) { + const { data: rows, errors: parseErrors } = Papa.parse(csvText.trim(), { + header: true, + skipEmptyLines: true, + transformHeader: (h) => h.trim().toLowerCase().replace(/\s+/g, '_'), + }) + + if (parseErrors.length > 0) { + return { data: [], errors: parseErrors.map((e) => e.message) } + } + + const data = [] + const errors = [] + + rows.forEach((row, i) => { + const rowNum = i + 2 // account for header row + const missing = [] + + if (!row.rule_id) missing.push('rule_id') + if (!row.scope) missing.push('scope') + if (!row.applies_to) missing.push('applies_to') + if (!row.type) missing.push('type') + if (row.value === undefined || row.value === '') missing.push('value') + if (row.stackable === undefined || row.stackable === '') missing.push('stackable') + + if (missing.length > 0) { + errors.push(`Row ${rowNum}: missing fields — ${missing.join(', ')}`) + return + } + + const scope = row.scope.trim().toLowerCase() + if (scope !== 'brand' && scope !== 'platform') { + errors.push(`Row ${rowNum}: scope must be "brand" or "platform", got "${row.scope}"`) + return + } + + const type = row.type.trim().toLowerCase() + if (type !== 'percentage' && type !== 'flat') { + errors.push(`Row ${rowNum}: type must be "percentage" or "flat", got "${row.type}"`) + return + } + + const value = parseFloat(row.value) + if (isNaN(value) || value <= 0) { + errors.push(`Row ${rowNum}: value must be a positive number, got "${row.value}"`) + return + } + + const stackableStr = row.stackable.trim().toLowerCase() + const stackable = stackableStr === 'true' || stackableStr === '1' || stackableStr === 'yes' + + data.push({ + ruleId: row.rule_id.trim(), + scope, + appliesTo: row.applies_to.trim(), + type, + value, + stackable, + }) + }) + + return { data, errors } +} + +/** + * Parses the raw text of cart.csv into an array of CartItem objects. + * Returns { data, errors } where errors is an array of row-level issues. + */ +export function parseCartCSV(csvText) { + const { data: rows, errors: parseErrors } = Papa.parse(csvText.trim(), { + header: true, + skipEmptyLines: true, + transformHeader: (h) => h.trim().toLowerCase().replace(/\s+/g, '_'), + }) + + if (parseErrors.length > 0) { + return { data: [], errors: parseErrors.map((e) => e.message) } + } + + const data = [] + const errors = [] + + rows.forEach((row, i) => { + const rowNum = i + 2 + const missing = [] + + if (!row.item_id) missing.push('item_id') + if (!row.product) missing.push('product') + if (!row.brand) missing.push('brand') + if (!row.platform) missing.push('platform') + if (row.base_price === undefined || row.base_price === '') missing.push('base_price') + + if (missing.length > 0) { + errors.push(`Row ${rowNum}: missing fields — ${missing.join(', ')}`) + return + } + + const basePrice = parseFloat(row.base_price) + if (isNaN(basePrice) || basePrice <= 0) { + errors.push(`Row ${rowNum}: base_price must be a positive number, got "${row.base_price}"`) + return + } + + data.push({ + itemId: row.item_id.trim(), + product: row.product.trim(), + brand: row.brand.trim(), + platform: row.platform.trim(), + basePrice: Math.round(basePrice), + }) + }) + + return { data, errors } +} diff --git a/discount-engine-assignment-main/src/engine/discountEngine.js b/discount-engine-assignment-main/src/engine/discountEngine.js new file mode 100644 index 0000000..190f2a7 --- /dev/null +++ b/discount-engine-assignment-main/src/engine/discountEngine.js @@ -0,0 +1,174 @@ +/** + * discountEngine.js + * + * Pure discount calculation logic. No UI, no side effects. + * All functions take plain objects and return plain objects. + * + * Data shapes: + * + * DiscountRule { + * ruleId: string — e.g. "RULE-01" + * scope: "brand" | "platform" + * appliesTo: string — e.g. "Natura Casa", "Amazon India" + * type: "percentage" | "flat" + * value: number — percentage as integer (15 = 15%), flat in rupees + * stackable: boolean + * } + * + * CartItem { + * itemId: string — e.g. "ITEM-01" + * product: string + * brand: string + * platform: string + * basePrice: number — in rupees + * } + * + * DiscountResult { + * itemId: string + * product: string + * brand: string + * platform: string + * basePrice: number + * finalPrice: number + * totalDiscount: number + * appliedRules: string[] + * skippedRules: string[] + * reasoning: string — customer-readable explanation + * } + */ + +/** + * Returns true if the rule applies to this cart item. + */ +export function ruleMatchesItem(item, rule) { + const normalise = (s) => s.trim().toLowerCase() + if (rule.scope === 'brand') { + return normalise(item.brand) === normalise(rule.appliesTo) + } + if (rule.scope === 'platform') { + return normalise(item.platform) === normalise(rule.appliesTo) + } + return false +} + +/** + * Calculates the rupee discount a rule gives on a given price. + * Uses the provided price, not the original base price — important for stacking. + */ +export function calculateDiscountAmount(price, rule) { + if (rule.type === 'percentage') { + return Math.round(price * rule.value / 100) + } + if (rule.type === 'flat') { + return rule.value + } + return 0 +} + +/** + * Builds the customer-facing reasoning string for an applied rule. + */ +function ruleToReasoning(rule) { + const scopeLabel = rule.scope === 'brand' ? 'Brand' : 'Platform' + if (rule.type === 'percentage') { + return `${scopeLabel} offer: ${rule.value}% off` + } + if (rule.type === 'flat') { + return `${scopeLabel} offer: Rs.${rule.value} off` + } + return `${scopeLabel} offer applied` +} + +/** + * Applies the active discount rules to a single cart item. + * Returns a DiscountResult. + * + * Logic: + * 1. Find all rules that match this item. + * 2. Among non-stackable rules, pick the one giving the largest discount. + * 3. Apply any stackable rules on top of that price. + * 4. Build the reasoning string from what was applied. + */ +export function applyDiscounts(item, rules) { + const matchingRules = rules.filter((r) => ruleMatchesItem(item, r)) + + // No rules match — return base price with explanation + if (matchingRules.length === 0) { + return { + itemId: item.itemId, + product: item.product, + brand: item.brand, + platform: item.platform, + basePrice: item.basePrice, + finalPrice: item.basePrice, + totalDiscount: 0, + appliedRules: [], + skippedRules: [], + reasoning: 'No offers available', + } + } + + const nonStackable = matchingRules.filter((r) => !r.stackable) + const stackable = matchingRules.filter((r) => r.stackable) + + // Pick the non-stackable rule that gives the largest saving + let winner = null + let skipped = [] + + if (nonStackable.length > 0) { + const sorted = [...nonStackable].sort( + (a, b) => + calculateDiscountAmount(item.basePrice, b) - + calculateDiscountAmount(item.basePrice, a) + ) + winner = sorted[0] + skipped = sorted.slice(1) + } + + // Apply winner first, then stack on top + let price = item.basePrice + const appliedRules = [] + const reasoningParts = [] + + if (winner) { + price -= calculateDiscountAmount(price, winner) + appliedRules.push(winner.ruleId) + reasoningParts.push(ruleToReasoning(winner)) + } + + for (const rule of stackable) { + price -= calculateDiscountAmount(price, rule) + appliedRules.push(rule.ruleId) + reasoningParts.push(ruleToReasoning(rule)) + } + + const finalPrice = Math.round(price) + + return { + itemId: item.itemId, + product: item.product, + brand: item.brand, + platform: item.platform, + basePrice: item.basePrice, + finalPrice, + totalDiscount: item.basePrice - finalPrice, + appliedRules, + skippedRules: skipped.map((r) => r.ruleId), + reasoning: reasoningParts.join(' + '), + } +} + +/** + * Runs applyDiscounts across every item in the cart. + * Returns an array of DiscountResult objects. + */ +export function processCart(cartItems, rules) { + return cartItems.map((item) => applyDiscounts(item, rules)) +} + +/** + * Sums the final prices across all results. + */ +export function cartTotal(results) { + return results.reduce((sum, r) => sum + r.finalPrice, 0) +} diff --git a/discount-engine-assignment-main/src/index.css b/discount-engine-assignment-main/src/index.css new file mode 100644 index 0000000..f4b165f --- /dev/null +++ b/discount-engine-assignment-main/src/index.css @@ -0,0 +1,2 @@ +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; padding: 0; -webkit-font-smoothing: antialiased; } diff --git a/discount-engine-assignment-main/src/main.jsx b/discount-engine-assignment-main/src/main.jsx new file mode 100644 index 0000000..5e8d112 --- /dev/null +++ b/discount-engine-assignment-main/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/discount-engine-assignment-main/vite.config.js b/discount-engine-assignment-main/vite.config.js new file mode 100644 index 0000000..9ffcc67 --- /dev/null +++ b/discount-engine-assignment-main/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8a0c675 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "discount-engine-assignment-main", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 1acdde0d340c928e6f29818e99c37fe621ee5214 Mon Sep 17 00:00:00 2001 From: HariKrishna Date: Tue, 30 Jun 2026 18:09:27 +0530 Subject: [PATCH 02/11] feat: add missing uploader and natural language engine components --- .../src/components/NaturalLanguageInput.jsx | 164 ++++++++++++++ .../src/components/PdfUploader.jsx | 213 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 discount-engine-assignment-main/src/components/NaturalLanguageInput.jsx create mode 100644 discount-engine-assignment-main/src/components/PdfUploader.jsx diff --git a/discount-engine-assignment-main/src/components/NaturalLanguageInput.jsx b/discount-engine-assignment-main/src/components/NaturalLanguageInput.jsx new file mode 100644 index 0000000..d56ae68 --- /dev/null +++ b/discount-engine-assignment-main/src/components/NaturalLanguageInput.jsx @@ -0,0 +1,164 @@ +import React, { useState } from 'react' + +const S = { + card: { + padding: '1.2rem', + background: '#fff', + border: '1px solid #e2e8f0', + borderRadius: 8, + boxShadow: '0 1px 3px rgba(0,0,0,0.05)', + fontFamily: 'inherit', + marginBottom: '1rem' + }, + title: { + margin: '0 0 0.5rem 0', + fontSize: '15px', + fontWeight: 700, + color: '#131A48', + display: 'flex', + alignItems: 'center', + gap: '6px' + }, + textarea: { + width: '100%', + height: '75px', + padding: '0.6rem', + borderRadius: 6, + border: '1px solid #cbd5e1', + fontSize: '13px', + fontFamily: 'inherit', + resize: 'none', + boxSizing: 'border-box', + marginBottom: '0.6rem', + outline: 'none' + }, + btn: { + background: '#131A48', + color: '#fff', + border: 'none', + borderRadius: 4, + padding: '0.55rem 1.2rem', + fontSize: '12px', + fontWeight: 600, + cursor: 'pointer', + transition: 'background 0.15s' + }, + error: { + color: '#dc2626', + fontSize: '12px', + marginTop: '0.5rem' + }, + success: { + color: '#16a34a', + fontSize: '12px', + marginTop: '0.5rem', + fontWeight: 500 + } +} + +export default function NaturalLanguageInput({ onAddRule }) { + const [text, setText] = useState('') + const [loading, setLoading] = useState(false) + const [status, setStatus] = useState({ type: '', msg: '' }) + + // Read your Groq key from your .env setup + const API_KEY = import.meta.env.VITE_GROQ_API_KEY; + + async function handleAiParse() { + if (!text.trim()) return + setLoading(true) + setStatus({ type: '', msg: '' }) + + try { + // Direct fetch payload pointing to Groq's official v1/chat/completions endpoint + const response = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: "llama-3.1-8b-instant", // Fast, accurate default model + response_format: { type: "json_object" }, // Enforces structural JSON mode output + messages: [ + { + role: "system", + content: `You are an expert parsing assistant. You must convert a user's discount rule into a clean JSON object matching this schema exactly: + { + "scope": "Platform" or "Brand", + "appliesTo": "Name of the brand or platform (e.g. Amazon, Flipkart, Lenovo, Featherlite)", + "type": "Percentage" or "Flat", + "value": number (raw integer discount value), + "stackable": boolean (true or false) + } + Return ONLY the raw JSON object. No explanations or conversational text outside of the object keys.` + }, + { + role: "user", + content: text + } + ] + }) + }) + + const data = await response.json() + + if (!response.ok || data.error) { + throw new Error(data.error?.message || 'Groq connection endpoint failure.') + } + + const rawJsonText = data?.choices?.[0]?.message?.content + if (!rawJsonText) throw new Error('Empty context stream returned from Groq endpoint.') + + const parsedRule = JSON.parse(rawJsonText.trim()) + + // Standardize data casings to fit your dashboard logic rules perfectly + if (parsedRule.scope) { + parsedRule.scope = parsedRule.scope.charAt(0).toUpperCase() + parsedRule.scope.slice(1).toLowerCase(); + } + if (parsedRule.type) { + parsedRule.type = parsedRule.type.charAt(0).toUpperCase() + parsedRule.type.slice(1).toLowerCase(); + } + + parsedRule.ruleId = `AI-${Math.floor(1000 + Math.random() * 9000)}` + + // Fire data state update up to the main table component + onAddRule(parsedRule) + + setText('') + setStatus({ + type: 'success', + msg: `🎉 Created ${parsedRule.ruleId}: Applied ${parsedRule.value}${parsedRule.type === 'Percentage' ? '%' : ' Rs'} off to ${parsedRule.appliesTo}!` + }) + + } catch (err) { + console.error(err) + setStatus({ type: 'error', msg: 'Failed parsing rule with Groq. Check your .env setup or API key.' }) + } finally { + setLoading(false) + } + } + + return ( +
+

🤖 Add AI Discount Rule

+