A privacy-first, offline-first Progressive Web App for storing loyalty/membership cards. Apple Wallet-inspired design with custom spring physics navigation.
Key Design Decisions:
- No React Router - Custom ViewStack navigation with iOS-style push/pop animations
- React Spring - Physics-based animations for native feel
- @use-gesture - Touch gestures (swipe to go back, swipe to delete)
- LocalStorage - All data stays on device, 100% offline
- Tailwind CSS - Utility-first styling with custom design tokens
LocalWalletPWA/
├── src/
│ ├── lib/ # Core utilities
│ │ ├── ViewStack.tsx # Custom navigation system
│ │ └── theme.ts # Design tokens & brand colors
│ │
│ ├── components/ # Reusable UI components
│ │ └── WalletCard.tsx # Card component with gestures
│ │
│ ├── views/ # App screens (pages)
│ │ ├── Dashboard.tsx # Home - card list
│ │ ├── AddCard.tsx # Add new card form
│ │ ├── CardDetail.tsx # View card + barcode
│ │ ├── Scanner.tsx # Camera barcode scanner
│ │ └── Settings.tsx # Export/Import/Delete
│ │
│ ├── services/ # Business logic
│ │ ├── storage.ts # LocalStorage CRUD
│ │ ├── brandService.ts # Logo fetching
│ │ └── barcodeService.ts # Barcode format detection
│ │
│ ├── types/ # TypeScript definitions
│ │ └── card.ts # Card interface
│ │
│ ├── App.tsx # Root component + ViewStack
│ ├── main.tsx # Entry point
│ └── index.css # Global styles
│
├── public/ # Static assets
│ └── icons/ # PWA icons
│
└── vite.config.ts # Vite + PWA config
Custom navigation replacing React Router. Features:
- iOS-style push/pop animations (slide from right)
- Swipe-right to go back gesture
- Spring physics for smooth animations
- View stack management
Usage:
const { push, pop, replace } = useViewStack();
// Navigate to a view
push('detail', { cardId: '123' });
// Go back
pop();
// Replace current view
replace('dashboard');View Types: dashboard | add | detail | settings | scanner
Credit card-style component with:
- Brand gradient backgrounds
- Clearbit logo integration
- Swipe-left to delete gesture
- Press animation feedback
- Favorite indicator
Props:
interface WalletCardProps {
card: Card;
onClick?: () => void;
onSwipeDelete?: () => void;
}| View | Route | Description |
|---|---|---|
| Dashboard | / |
Card list with search, FAB to add |
| AddCard | /add |
Form with live preview, camera scanner |
| CardDetail | /card/:id |
Barcode display, brightness boost, delete |
| Scanner | /scanner |
Full-screen camera barcode scanner |
| Settings | /settings |
Export/Import JSON, delete all data |
- Uses
html5-qrcodelibrary - Supports: CODE128, EAN-13, EAN-8, UPC, CODE39, QR
- Manual entry fallback if camera fails
- Increases screen brightness when viewing barcode
- Uses CSS filter for cross-browser support
- Auto-resets when leaving view
- Export all cards as JSON file
- Import cards from JSON backup
- Useful for device migration
- Swipe right: Go back (enabled on all views except scanner)
- Swipe left on card: Delete card
colors.background // #000000 - Pure black
colors.surface // #1C1C1E - Elevated surface
colors.accent // #007AFF - iOS blue
colors.error // #FF3B30 - iOS red
colors.success // #34C759 - iOS greenspringConfigs.ios // iOS-style spring animation
springConfigs.snappy // Quick interactions
springConfigs.gentle // Large movementsPre-defined gradients for popular stores:
- IKEA, Coop, Leroy Merlin, Target, Starbucks
- Costco, Walmart, Amazon, Nike, Adidas
- Lidl, Aldi, Carrefour, Decathlon, etc.
All data stored in localStorage under key LOCAL_WALLET_CARDS.
interface Card {
id: string;
storeName: string;
cardNumber: string;
logoUrl: string | null;
brandColor: string; // Tailwind gradient classes
barcodeFormat: BarcodeFormat;
isFavorite: boolean;
createdAt: number;
lastUsedAt: number | null;
}| Function | Description |
|---|---|
getAllCards() |
Get all cards (favorites first) |
createCard(input) |
Create new card |
getCardById(id) |
Get single card |
deleteCard(id) |
Delete card |
toggleFavorite(id) |
Toggle favorite |
markAsUsed(id) |
Update lastUsedAt |
exportCards() |
Export as JSON string |
importCards(json) |
Import from JSON |
Edit src/lib/theme.ts:
export const brandColors = {
'newstore': { gradient: 'from-red-500 to-pink-500', primary: '#FF0000' },
// ...
};- Edit
src/types/card.tsto add the format type - Edit
src/services/barcodeService.tsto add detection logic
Edit spring configs in src/lib/theme.ts:
export const springConfigs = {
ios: { tension: 300, friction: 28, mass: 0.8 },
// Increase tension = faster, increase friction = more damping
};# Development
npm run dev
# Production build
npm run build
# Preview production
npm run previewDeploy dist/ folder to Vercel, Netlify, or any static host.
- Offline: Service worker caches all assets
- Installable: Add to home screen on iOS/Android
- Safe areas: Handles iPhone notch and home indicator
- No tracking: Zero analytics, no data sent anywhere