A modern, mobile-first shopping app built with React, TypeScript, and Supabase. Jot down what you need in seconds — even offline — and, when you want to save more, tap Where to buy to see which stores make your list cheapest, delivery included.
Formerly referred to as "PriceTracker" — the product is now branded SpendLess.
The app has two sections: Lists (the default) and Compare.
- Add items the way you'd say them:
bread,2 milk,atta 10 kg— no brand or size needed - Suggestions while typing (your own history first) and one-tap "often bought" chips
- Items sorted by aisle automatically (English and romanized Urdu names) — or turn off Group by aisle in list options (⋯) to keep them in the order you added them (each item still shows its aisle)
- Tick items as you shop, swipe to tick/delete, undo anything, see your progress
- Several lists (Groceries, Pharmacy, …) with optional quantity, unit and notes
- Friendly one-at-a-time tips that teach the app as you use it
- Lists are stored on the device first (IndexedDB) and sync to Supabase when online
- Changes made offline are queued and sent automatically; the app opens offline too
- Installable PWA; the app shell and fonts are cached by a service worker
- New releases arrive by themselves: an open app shows "A new version is ready · Update", and the next launch always runs the latest build
- On any list, Where to buy finds the stores that make the whole list cheapest — delivery fees, free-delivery thresholds and minimum orders included
- Four choices: Cheapest, One stop, Delivered (online stores only) and Fewer stops, with what you save compared with the best single store
- Write "bread" and we pick a sensible product (and say which); tap an item to choose the brand and size — just this time, or as your usual
- Use this plan splits the same list into one section per store; Shop here shows just that store's part
- Works offline from the prices saved on the device
- While you shop: tick an item and SpendLess asks "Was it Rs 210?" — tap Yes, or Different to add what you paid; when a store's part is done, the rest come up in one go. Answers given offline are sent later. Off in Profile → Shopping features
- Shared prices for your city where they're live (Karachi first); anywhere else, Compare works with the stores and prices you add yourself
- Karachi's shared prices are refreshed daily from five online stores (Diamond,
Imtiaz, Chase Up, Spar, Bin Hashim; Hydri is paused), read politely by SpendLessBot
(see
/botand docs/data-sources.md) - Karachi also has 26 shared in-store branches (Imtiaz, Spar, Diamond Super Market) from the chains' own store lists; their prices come from shoppers. Where to buy compares the online stores and your own, plus the branches you add to My stores
- Share a shop: suggest a shop you added (a local kiryana, a pharmacy, a chain's branch) as a shared one. When enough people who shop there suggest it, it becomes a shared shop overnight and your own copy moves into it, prices included (never with your name)
- Prices (search, browse by aisle, your usuals), Stores (choose the ones you shop at, branches grouped by chain, add your own) and Contribute (add a price, add a product, add a receipt)
- Add a receipt: a whole shop's prices in one go from order screenshots, a PDF invoice, a photo of a till receipt or pasted text. It's read on the device (a one-time ≈6 MB text reader for pictures and scanned PDFs, ≈2 MB for a PDF's own text; both self-hosted and kept for offline use) — the receipt is never uploaded, only the prices you confirm are saved. Medicines are saved as your own private products
- Share to SpendLess (the installed app on Android): share a screenshot, a PDF or an order's text from another app; SpendLess asks "Read this receipt?"
- Prices at shared stores are shared without your name; a price far from the usual one is kept for you only until someone else sees the same; your own stores and products stay private unless you share a shop
- Every price shows how old it is (Today, 2 wks, Old · Aug)
- Your contributions (Compare → Contribute): the prices you've added, where each stands (shared, only you for now, private), and Remove for a mistake
- Wrong price? on a product page: add the right price, say it isn't sold any more, or just say it's wrong — once two people say so, Where to buy leaves it out until a newer price comes in. Prices from people whose prices often disagree with others' quietly count for less
- Use as much as you like: Profile → Shopping features turns Where to buy and receipt import off, and lists look exactly as before
- In-app help (Profile → Help), a short walkthrough the first time you open Where to buy, and one-at-a-time tips
- Warm "paper" light theme with a magenta accent and custom design tokens
- Mobile-first, fully responsive layout with an adaptive app shell
- Custom typography (Bricolage Grotesque, Hanken Grotesk, Space Mono)
- Smooth animations and micro-interactions
- Web app manifest with maskable icons for Android/Chrome
- Apple touch icon and full favicon set for all devices
- Installable to the home screen with a branded splash screen
- Install the app button on the sign-in screen and in Profile (so it can be reinstalled after removal), a "Get the app" card in the desktop sidebar, a small "Get app" pill in the mobile Lists header, plus a dismissible banner. Chrome/Edge/Android open the browser's install dialog in one tap; iPhone/iPad, Mac Safari and other browsers get step-by-step instructions
- User authentication with Supabase Auth
- Row-level security (RLS) for all user data
- Privacy-first approach
- Support for 50+ global currencies
- Automatic currency detection based on locale
- Consistent price formatting across the app
- Optional Google Analytics integration (configurable via environment variables)
Visit the live application: https://spendless.ibexoft.com
- Frontend: React 18, TypeScript
- Routing: React Router (marketing & legal pages)
- Styling: Tailwind CSS with custom design tokens (OKLCH color system)
- Backend: Supabase (PostgreSQL, Auth, Real-time)
- Icons: Lucide React
- Build Tool: Vite
- Analytics: Google Analytics (gtag, optional)
- Deployment: GitHub Actions → Netlify (app) + Supabase (database migrations)
Before you begin, ensure you have the following installed:
- Node.js (version 18 or higher)
- npm or yarn package manager
- Git
git clone https://github.com/mjawaids/price-tracker.git
cd price-trackernpm installCreate a .env file in the root directory (see .env.example) and add your credentials:
# Supabase Configuration
VITE_SUPABASE_URL=your_supabase_project_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
# Google Analytics Configuration (optional)
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
VITE_GA_ENABLE_IN_DEV=falseYou can find the Supabase values in your project dashboard under Settings > API.
SpendLess keeps all of its tables in its own Postgres schema, spendless, so it
can share a Supabase project with other apps without mixing data. Tables:
- spendless.lists / spendless.list_items: quick lists and their items (offline-synced)
- spendless.regions: cities; shared prices are live in Karachi first
- spendless.catalog_stores / spendless.catalog_products: the Compare catalogue — public (shared) rows plus each user's private ones
- spendless.price_reports / spendless.current_prices: append-only price
observations and the price derived from them (weighted median; held prices count once
someone else agrees;
disputedwhen two people say it's wrong) - spendless.reporter_trust: how much each person's prices count (nightly job only)
- spendless.my_contributions(): a user's own price counts for Your contributions
- spendless.user_stores / item_preferences / plans: a user's stores, usual products and applied plans
- spendless.products / spendless.stores / spendless.shopping_lists: the original per-user catalogue and Compare cart (legacy). Their rows were copied into the catalogue as private rows; the old cart is turned into a list once ("From Compare cart"). A later post-deploy migration drops them.
How prices are decided and protected from spam: docs/compare-data.md.
User profile data (name, avatar) is stored in Supabase Auth user_metadata,
so no separate profiles table is required by the app.
Setup:
- Apply the migrations with the project's runner (it tracks them in
spendless.schema_migrations):They create theSUPABASE_DB_URL="postgresql://…" scripts/db-migrate.sh --from-scratch supabase/migrations SUPABASE_DB_URL="postgresql://…" scripts/db-migrate.sh supabase/post-deploy
spendlessschema, grants and RLS policies for every table. (Don't usesupabase db push: the project is shared with other apps — see docs/deployment.md.) - In the Supabase dashboard, open Project Settings → Integrations → Data API
and add
spendlessto Exposed schemas (production does this automatically). The app's client is pinned to this schema (src/lib/supabaseClient.ts), so the API must expose it.
Upgrading an existing install (tables previously in
public):20261003000000_spendless_schema.sqlmovesproducts,storesandshopping_listsintospendlesswith their data and policies, and leaves temporary views inpublicso the old build keeps working. The deploy pipeline handles the order: migrations → exposespendless→ deploy the new build →supabase/post-deploy/20261003000200_drop_public_compat_views.sql.
Users can register and sign in with email/password or Google. A Google login automatically links to an existing account with the same address via Supabase's built-in identity linking (only when the email is verified), so users don't end up with duplicate accounts.
The frontend uses only the public anon key — all data access is gated by
Row-Level Security, so the anon key cannot read or modify anyone's data on its
own. Never put the service_role key in the frontend.
Google OAuth requires provider setup in the Google Cloud and Supabase dashboards (these are not stored in this repo):
- Google Cloud Console → APIs & Services → Credentials → create an OAuth
client ID (type: Web application). Configure the consent screen, then set
the Authorized redirect URI to your Supabase callback:
https://<PROJECT_REF>.supabase.co/auth/v1/callback. Copy the Client ID and Client Secret. - Supabase → Authentication → Providers → Google: enable it and paste the Client ID + Secret.
- Supabase → Authentication → URL Configuration:
- Site URL: your production URL (e.g.
https://spendless.ibexoft.com) - Redirect URLs: add only your own domains (e.g.
https://spendless.ibexoft.com/**andhttp://localhost:5173/**).
- Site URL: your production URL (e.g.
- Google redirect URI is restricted to the Supabase callback only — this stops anyone from reusing your Google credentials in a different app.
- Supabase Redirect URLs list contains only your domains (no broad wildcards) — prevents OAuth open-redirect / session-token theft.
- Confirm email is ON (Authentication → Providers → Email) — required for safe automatic account linking; blocks account pre-hijacking.
- RLS is enabled on every table (it is, by default in the migrations).
-
spendlessis listed under Project Settings → Integrations → Data API → Exposed schemas. - CAPTCHA + rate limits enabled (Authentication → Attack Protection) to deter abuse of the public anon key.
- The
service_rolekey is never referenced in frontend code or.envfiles prefixed withVITE_.
npm run devThe application will be available at http://localhost:5173
npm run buildThis creates an optimized production build in the dist directory.
Favicons and PWA icons are generated from the SVG sources in public/
(favicon.svg and icon-maskable.svg). After editing a source SVG, run:
npm run generate:iconsThis produces the .ico, PNG favicons, apple-touch-icon, and maskable PWA
icons used by index.html and public/site.webmanifest.
Production deploys are fully automated with GitHub Actions
(.github/workflows/ci-cd.yml). Every push to main:
- runs the checks (lint, colour contrast, migration guard, build),
- applies new database migrations to Supabase (
supabase/migrations/), - makes sure the
spendlessschema is exposed in the Data API, - builds and deploys
dist/to Netlify (spendless.ibexoft.com), - smoke-tests the live site,
- runs post-deploy migrations (
supabase/post-deploy/), - tags the release and publishes GitHub Release notes.
Pull requests run the checks only. Releases use CalVer YYYY.M.N (e.g.
2026.10.0), shown in the app under Profile.
A second workflow, .github/workflows/price-import.yml, imports store prices once a
day (and can be run by hand as a dry run); shared-branches.yml makes suggested shops
shared each night; reporter-trust.yml works out how much each person's prices count
each night; and catalog-jobs.yml runs manual catalogue jobs (add a city's
in-store branches, promote a store to public, close a shared shop).
One-time setup (tokens, GitHub production environment, leaving Bolt), rollback and
how to add new services: docs/deployment.md.
.github/workflows/ci-cd.yml # CI checks + production deploy pipeline
.github/workflows/price-import.yml # Daily store price import (docs/data-sources.md)
.github/workflows/shared-branches.yml # Nightly: suggested shops become shared (docs/compare-data.md)
.github/workflows/reporter-trust.yml # Nightly: how much each person's prices count (docs/compare-data.md)
.github/workflows/catalog-jobs.yml # Manual catalogue jobs (add in-store branches, promote a store to public, close a shared shop)
public/ # Static assets (favicons, PWA icons, manifest + share target, _redirects, _headers, share-target-sw.js)
scripts/
├── generate-icons.mjs # Generates favicon/PWA icons from SVG sources
├── check-contrast.mjs # WCAG contrast check for the colour tokens
├── check-migrations.mjs # Guard: migrations may only touch the spendless schema (+ spendless-* storage policies)
├── db-migrate.sh # Applies migrations (tracked in spendless.schema_migrations)
├── supabase-expose-schema.sh # Adds spendless to the Data API's exposed schemas
├── seed/promote-store.ts # Makes a private store + its products public (manual "Catalog jobs" workflow)
├── seed/add-branches.ts # Adds/updates a city's public in-store branches from seed/branches/<city>.json ("Catalog jobs")
├── seed/promote-suggestions.ts # Nightly: suggested shops become shared, people's shops move in ("Shared shops")
├── seed/close-branch.ts # Undo a shared shop: close it, give people their own shop back ("Catalog jobs")
├── seed/reporter-trust.ts # Nightly: each person's price weight from how often they agree with others ("Reporter trust")
├── seed/merge-products.ts # Duplicate products: nightly safe merges after the import; review list, approved merges, undo ("Catalog jobs")
└── import/ # Daily store price import: run.ts, sources.ts, adapters/, polite http + robots.txt, write.sql
supabase/migrations/ # Pre-deploy (additive) migrations — schema: spendless
supabase/post-deploy/ # Post-deploy (cleanup) migrations
src/
├── App.tsx # App root (auth gate + shell)
├── main.tsx # Entry point + React Router routes
├── index.css # Global styles, design tokens & Tailwind
├── components/
│ ├── shell/Shell.tsx # Adaptive app shell (sidebar/nav + screens)
│ ├── shell/HelpSheet.tsx # In-app help topics (src/lib/help.ts)
│ ├── screens/ # Feature screens
│ │ ├── ListsScreen.tsx # Quick lists (default section)
│ │ ├── listParts.tsx # List rows, add bar, suggestions, banners
│ │ ├── listSheets.tsx # Item details + list switcher sheets
│ │ ├── listHelpers.ts # Aisle and store grouping/colours + haptic tap
│ │ ├── listCompare.tsx # Where to buy chip, plan banner, store section headers
│ │ ├── AuthScreen.tsx # Sign in / sign up
│ │ ├── PlanScreen.tsx # Where to buy for a list
│ │ ├── PricesScreen.tsx # Compare home (prices)
│ │ ├── SearchScreen.tsx # Product search
│ │ ├── DetailScreen.tsx # Product page (prices per store, add to list)
│ │ ├── StoresScreen.tsx # Your city and stores
│ │ ├── ContributeScreen.tsx # Add a price / a product / a receipt
│ │ ├── ReceiptScreen.tsx # Add a receipt (+ receiptParts, receiptSheets, receiptHelpers)
│ │ ├── ManageScreens.tsx # Your own products
│ │ ├── compareSheets.tsx # Item choice, city, My stores, store form, add a price
│ │ ├── storePicker.tsx # Shared store picker: Online / In a shop, search, branches by chain
│ │ ├── suggestSheet.tsx # Share this shop (suggest it as a shared one), its row, "Your shop is now shared"
│ │ ├── productSheet.tsx # Add/edit your product
│ │ └── ProfileScreen.tsx # Profile, shopping features, help
│ ├── onboarding/ # Where to buy walkthrough + "What's new" sheet
│ ├── ui/ # Reusable UI primitives (Icon, Sheet, Toast, CoachMark, Toggle, …)
│ ├── PageHeader.tsx # Header for marketing/legal pages
│ └── PageFooter.tsx # Footer with developer credits
├── pages/ # Standalone routed pages
│ ├── Pricing.tsx
│ ├── Privacy.tsx
│ ├── Refund.tsx
│ ├── Terms.tsx
│ ├── Bot.tsx # /bot: what SpendLessBot is and how to opt out
│ └── lazy.ts # Lazy (code-split) exports of the pages above
├── contexts/ # React contexts
│ ├── AuthContext.tsx # Authentication state (+ offline identity)
│ ├── ListsContext.tsx # Lists state, quick add, offline sync
│ ├── CompareContext.tsx # Compare catalogue, prices, plans (cached offline)
│ ├── AppContext.tsx # Navigation, sections & app sheets
│ ├── OnboardingContext.tsx # Walkthrough + contextual tips
│ ├── ThemeContext.tsx # Theme management
│ ├── SettingsContext.tsx # User settings (currency, city, shopping features, list grouping)
│ └── AnalyticsContext.tsx # Analytics wiring
├── hooks/ # Custom React hooks
│ ├── useBreakpoint.ts # Responsive breakpoints
│ ├── useHint.ts # One-at-a-time contextual tips
│ └── useFmt.ts # Formatting helpers
├── lib/ # Library configuration & data
│ ├── supabase.ts # Supabase client re-export
│ ├── supabaseClient.ts # Supabase client (pinned to the `spendless` schema)
│ ├── offline/ # IndexedDB store + sync engine for Lists
│ ├── compare/ # Compare: item types, name parser, unit prices, matching, optimizer, API, offline cache
│ ├── receipt/ # Receipts: on-device reader, PDFs, parser, dates, store guess, matching, review, memory, share inbox
│ ├── help.ts # Help topic copy
│ ├── groceryDictionary.ts # Item → aisle dictionary
│ ├── hints.ts # Tip copy
│ ├── version.ts # Release version (CalVer, set by CI)
│ └── categories.ts # Product categories
├── utils/ # Utility functions
│ ├── currency.ts # Currency formatting
│ ├── quickAdd.ts # Parses "2 milk", "atta 10 kg", …
│ └── analytics.ts # Analytics helpers
└── types/
└── index.ts # TypeScript type definitions
SpendLess ships a single warm light theme. Colors are defined as OKLCH design
tokens (CSS variables) and surfaced to Tailwind via tailwind.config.js:
src/index.css— design tokens, global styles and Tailwind layerstailwind.config.js— Tailwind theme mapping (colors, fonts, radii)
Edit public/favicon.svg (and public/icon-maskable.svg) and run
npm run generate:icons to regenerate the full icon set.
Add or modify supported currencies in src/utils/currency.ts. The app
automatically detects user locale and sets an appropriate default currency.
Modify the schema by adding migration files in supabase/migrations/. Follow
the existing naming convention, create every object in the spendless schema
(schema-qualified, e.g. spendless.lists), add grants and RLS policies, and never
create SpendLess objects in public.
There is no automated test suite; run the linter and build, then follow the manual checklist in TESTING_GUIDE.md:
npm run lint
npm run build
node scripts/check-contrast.mjs # after changing colour tokens- Fork the repository
- Create a feature branch:
git checkout -b feature/amazing-feature - Commit your changes:
git commit -m 'Add amazing feature' - Push to the branch:
git push origin feature/amazing-feature - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
- Made with ❤️ by Jawaid
- Powered by 🚀 Ibexoft
- Icons by Lucide
- UI styled with Tailwind CSS
- Backend powered by Supabase
For support, use Contact support in the app (Profile → Help, or the sign-in screen), visit ibexoft.com/contact, or create an issue in the repository.
Made with passion for smart shoppers everywhere 🛒✨