HuntWise - Hunting Map Platform
HuntWise is a full-featured web mapping platform that I developed for hunters to plan and manage their hunting areas. The application provides an interactive map interface where users can create hunt areas by selecting property parcels or manually drawing boundaries, manage custom pins with categories and shapes, visualize property lines, and access premium features through Stripe subscriptions. Built with modern Next.js architecture, it features a clean UI with responsive design, bilingual interface (English/Spanish), real-time map interactions, and a sophisticated modal system. The app is production-ready with comprehensive error handling, authentication, and subscription management.
Key Features
Interactive map with Mapbox GL JS
Hunt area creation, edition, and deletion
Parcel selection with real-time acreage calculation
Pin management system with categories, custom shapes, and visibility controls
Drawing tools for creating shapes, paths, and boundaries on the map
Property lines visualization with toggleable layers
3D map view with terrain elevation
Wind direction visualization and markers
Pin groups (hunt areas) with custom names and geometries
Subscription management with Stripe (free trial, coupons, promotion codes)
Secure authentication with session management
Bilingual interface (English/Spanish) with next-intl
Modal system with registry pattern for dynamic modal rendering
Real-time map interactions with click handlers and event management
Map layer management with visibility toggles and base layer selection
Pin location updates with drag-and-drop functionality
Hunt area detection for pins with automatic association
Responsive design with mobile-first approach
Toast notification system for user feedback
Comprehensive form validation with Zod schemas
Challenges & Solutions
Complex Map State Management
Managing multiple map modes (parcel selection, drawing, pin placement, 3D view) while maintaining consistent state and preventing conflicts between different interaction modes.
Solution:
Implemented Zustand stores for each feature domain (auth, map, drawing, parcel selection, pins, pin groups) with clear separation of concerns and minimal cross-store dependencies
Parcel Selection with Mapbox Styling
Implementing parcel selection that requires switching map styles dynamically, querying rendered features, applying conditional styling with Mapbox expressions, and handling thousands of parcels efficiently.
Solution:
Created a hook-based architecture where each map interaction mode has its own hook (useMapDraw, useParcelSelection, useAddPin, etc.) that manages its lifecycle independently
Real-time Map Interactions
Coordinating multiple map interaction hooks (drawing, pin placement, parcel selection, pin movement) without conflicts, ensuring proper cleanup, and maintaining performance with frequent map updates.
Solution:
Built a parcel selection system that switches map styles on demand, uses Mapbox expressions for efficient conditional styling of thousands of parcels, and maintains selection state in a dedicated store
Stripe Subscription Integration
Implementing complex subscription flows with free trials, coupon validation, payment method management, and handling subscription lifecycle events while maintaining data consistency.
Solution:
Designed a modal registry system that allows dynamic modal rendering based on store state, enabling clean separation between modal triggers and modal components
Geometry Processing and Validation
Processing GeoJSON geometries from multiple sources (parcels, drawings, pin shapes), validating coordinates, calculating acreage, and ensuring data integrity when saving to backend.
Solution:
Implemented TanStack React Query for data fetching with proper caching, invalidation strategies, and optimistic updates for pins and pin groups
Code Examples
Parcel Selection Store with Zustand
State management for parcel selection mode with geometry tracking and acreage calculation
import { create } from 'zustand';
interface ParcelData {
id: string;
geometry: GeoJSON.Geometry;
acres: number;
properties?: Record<string, any>;
}
interface ParcelSelectionState {
isParcelMode: boolean;
selectedParcels: Map<string, ParcelData>;
totalAcres: number;
propertyName: string;
originalStyleUrl: string | null;
startParcelMode: (originalStyleUrl: string) => void;
endParcelMode: () => void;
toggleParcel: (parcel: ParcelData) => void;
setPropertyName: (name: string) => void;
getSelectedGeometries: () => GeoJSON.Geometry[];
getSelectedParcelIds: () => string[];
}
export const useParcelSelectionStore = create<ParcelSelectionState>((set, get) => ({
isParcelMode: false,
selectedParcels: new Map(),
totalAcres: 0,
propertyName: '',
originalStyleUrl: null,
startParcelMode: (originalStyleUrl: string) => {
set({
isParcelMode: true,
originalStyleUrl,
selectedParcels: new Map(),
totalAcres: 0,
propertyName: '',
});
},
endParcelMode: () => {
set({
isParcelMode: false,
selectedParcels: new Map(),
totalAcres: 0,
propertyName: '',
originalStyleUrl: null,
});
},
toggleParcel: (parcel: ParcelData) => {
const { selectedParcels, totalAcres } = get();
const newSelected = new Map(selectedParcels);
let newTotalAcres = totalAcres;
if (newSelected.has(parcel.id)) {
newSelected.delete(parcel.id);
newTotalAcres -= parcel.acres;
} else {
newSelected.set(parcel.id, parcel);
newTotalAcres += parcel.acres;
}
set({ selectedParcels: newSelected, totalAcres: newTotalAcres });
},
setPropertyName: (name: string) => set({ propertyName: name }),
getSelectedGeometries: () => {
return Array.from(get().selectedParcels.values()).map((p) => p.geometry);
},
getSelectedParcelIds: () => {
return Array.from(get().selectedParcels.keys());
},
}));Map Hook with Parcel Selection
Custom hook managing parcel selection interactions with Mapbox style switching and visual highlighting
import { useEffect, useCallback } from 'react';
import { useParcelSelectionStore } from '@/store/parcel-selection.store';
import { PARCEL_STYLE_URL, PARCEL_LAYERS } from '@/lib/constants/parcel-selection';
import { createParcelOpacityExpression } from '@/lib/utils/parcel-utils';
export function useParcelSelection({ mapInstance }: { mapInstance: any }) {
const { isParcelMode, selectedParcels, originalStyleUrl, startParcelMode, endParcelMode } = useParcelSelectionStore();
useEffect(() => {
if (!mapInstance || !isParcelMode) return;
const currentStyle = mapInstance.getStyle().name || mapInstance.getStyle().metadata?.['mapbox:autocomposite'];
const styleUrl = mapInstance.getStyle().sources ? undefined : mapInstance.getStyle().sprite;
if (!originalStyleUrl && currentStyle) {
startParcelMode(currentStyle);
}
// Switch to parcel style
mapInstance.setStyle(PARCEL_STYLE_URL);
return () => {
if (originalStyleUrl && mapInstance) {
mapInstance.setStyle(originalStyleUrl);
}
};
}, [mapInstance, isParcelMode, originalStyleUrl, startParcelMode]);
useEffect(() => {
if (!mapInstance || !isParcelMode) return;
const handleMapClick = (e: any) => {
const bbox = [
[e.point.x - 5, e.point.y - 5],
[e.point.x + 5, e.point.y + 5],
];
const features = mapInstance.queryRenderedFeatures(bbox, {
layers: PARCEL_LAYERS,
});
if (features.length > 0 && features[0].properties) {
const parcel = features[0];
const parcelData = {
id: parcel.properties.parcel_id,
geometry: parcel.geometry,
acres: parseFloat(parcel.properties.acres || '0'),
properties: parcel.properties,
};
useParcelSelectionStore.getState().toggleParcel(parcelData);
}
};
mapInstance.on('click', handleMapClick);
return () => {
mapInstance.off('click', handleMapClick);
};
}, [mapInstance, isParcelMode]);
// Apply visual styling to selected parcels
useEffect(() => {
if (!mapInstance || !isParcelMode) return;
const parcelIds = Array.from(selectedParcels.keys());
const opacityExpression = createParcelOpacityExpression(parcelIds);
PARCEL_LAYERS.forEach((layerId) => {
if (mapInstance.getLayer(layerId)) {
mapInstance.setPaintProperty(layerId, 'fill-color', '#F26E0D');
mapInstance.setPaintProperty(layerId, 'fill-opacity', opacityExpression);
}
});
}, [mapInstance, isParcelMode, selectedParcels]);
}Stripe Subscription Creation
Server action for creating Stripe subscriptions with trial periods, coupons, and payment methods
import { createSubscription } from '@/lib/services/stripe/stripe-service';
import { subscriptionSchema } from '@/lib/schemas/stripe';
export async function createSubscriptionAction(
customerId: string,
priceId: string,
paymentMethodId: string,
coupon?: string,
promotionCode?: string,
trialEnd?: number | 'now',
metadata?: Record<string, string>,
) {
try {
const validatedData = subscriptionSchema.parse({
customer_id: customerId,
price_id: priceId,
payment_method_id: paymentMethodId,
coupon,
promotion_code: promotionCode,
trial_end: trialEnd,
metadata,
});
const subscription = await createSubscription(
validatedData.customer_id,
validatedData.price_id,
validatedData.payment_method_id,
validatedData.coupon,
validatedData.promotion_code,
validatedData.trial_end,
validatedData.metadata,
);
return {
success: true,
subscription: subscription.subscription,
};
} catch (error) {
console.error('Error in createSubscriptionAction:', error);
throw error;
}
}Pin Group Creation with Parcels
Utility function to create pin group payload from selected parcels with geometry processing
import { calculateTotalAcres } from '@/lib/utils/parcel-utils';
import { useParcelSelectionStore } from '@/store/parcel-selection.store';
import type { CreatePinGroupPayload } from '@/lib/types/pin-group.types';
export function createPinGroupFromParcels(
propertyName: string,
): CreatePinGroupPayload | null {
const { getSelectedGeometries, getSelectedParcelIds, totalAcres } = useParcelSelectionStore.getState();
const geometries = getSelectedGeometries();
const parcelIds = getSelectedParcelIds();
if (geometries.length === 0 || parcelIds.length === 0) {
return null;
}
// Calculate bounds from all geometries
const allCoordinates = geometries.flatMap((geom) => {
if (geom.type === 'Polygon') {
return geom.coordinates[0];
}
if (geom.type === 'MultiPolygon') {
return geom.coordinates.flatMap((poly) => poly[0]);
}
return [];
});
const lngs = allCoordinates.map((coord) => coord[0]);
const lats = allCoordinates.map((coord) => coord[1]);
const bounds = [
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)],
];
return {
name: propertyName,
shapes: JSON.stringify(geometries),
parcel_ids: JSON.stringify(parcelIds),
bounds: JSON.stringify(bounds),
shape: geometries[0], // First geometry as primary shape
};
}What I Learned
Deepened understanding of Next.js 15 App Router with server components, server actions, and route groups for protected/public routes
Learned advanced Mapbox GL JS patterns including style switching, layer management, conditional styling with expressions, and feature querying
Gained expertise in complex state management with Zustand, including persistence, middleware, and store composition patterns
Improved skills in TypeScript for type-safe development with proper interfaces, type inference from Zod schemas, and generic types
Learned to implement subscription-based SaaS features with Stripe, including setup intents, payment methods, and subscription lifecycle management
Gained experience with TanStack React Query for efficient data fetching, caching, and synchronization in a complex application
Improved understanding of GeoJSON and geometry processing using Turf.js for spatial calculations and validations
Learned to build scalable modal systems with registry patterns that support dynamic rendering and clean state management
Gained experience with internationalization using next-intl for server and client components with proper locale management
Improved understanding of form handling with React Hook Form and Zod validation, including complex nested schemas
Learned to optimize map performance with proper event handler memoization, efficient re-renders, and layer management strategies
Interested in This Project?
Let's discuss how I built this and how I can help you with similar projects.
Get in Touch