Back to Projects
web

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.

Next.js 15TypeScriptReact 19Mapbox GL JSZustandTanStack React QueryStripenext-intlReact Hook FormZodTailwind CSSDaisyUI

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

1

Deepened understanding of Next.js 15 App Router with server components, server actions, and route groups for protected/public routes

2

Learned advanced Mapbox GL JS patterns including style switching, layer management, conditional styling with expressions, and feature querying

3

Gained expertise in complex state management with Zustand, including persistence, middleware, and store composition patterns

4

Improved skills in TypeScript for type-safe development with proper interfaces, type inference from Zod schemas, and generic types

5

Learned to implement subscription-based SaaS features with Stripe, including setup intents, payment methods, and subscription lifecycle management

6

Gained experience with TanStack React Query for efficient data fetching, caching, and synchronization in a complex application

7

Improved understanding of GeoJSON and geometry processing using Turf.js for spatial calculations and validations

8

Learned to build scalable modal systems with registry patterns that support dynamic rendering and clean state management

9

Gained experience with internationalization using next-intl for server and client components with proper locale management

10

Improved understanding of form handling with React Hook Form and Zod validation, including complex nested schemas

11

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