first commit

This commit is contained in:
Dhananjaya99
2026-09-28 11:48:14 +05:30
commit a1b870fa8a
242 changed files with 39483 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
.next
.git
.gitignore
Dockerfile
.dockerignore
README.md
.env
.env.local
.env.*.local
npm-debug.log
.vscode
.idea
*.log
+2
View File
@@ -0,0 +1,2 @@
NEXT_PUBLIC_CORE_API_URL=https://hexafit.hexdive.com
NEXT_PUBLIC_AUTH_API_URL=https://hexafit.hexdive.com
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.production
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+13
View File
@@ -0,0 +1,13 @@
{
"extends": [
"development"
],
"hints": {
"axe/forms": [
"default",
{
"label": "off"
}
]
}
}
+37
View File
@@ -0,0 +1,37 @@
FROM node:20-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build-time env vars (must be present during `next build` for NEXT_PUBLIC_*)
ARG NEXT_PUBLIC_CORE_API_URL
ENV NEXT_PUBLIC_CORE_API_URL=${NEXT_PUBLIC_CORE_API_URL}
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next && chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+404
View File
@@ -0,0 +1,404 @@
# HexaFitness Client Project Reference
## Overview
- App name: `hexa-fitness-client`
- Stack: Next.js 16 App Router, React 19, TypeScript, Tailwind CSS 4
- UI patterns: custom app components plus reusable `src/components/ui/*`
- Notifications: `sonner`
- Charts: `recharts`
- Theme handling: `next-themes`
- Main app area: dashboard for gym management
- Public flows: landing page, request access, sandbox demo, activation, login
## Environment
- Core API base URL: `NEXT_PUBLIC_CORE_API_URL`
- Default fallback in code: `http://localhost:5111`
## Scripts
- `npm run dev` - start development server
- `npm run build` - production build
- `npm run start` - run production server
- `npm run lint` - run ESLint
## App Structure
### Root App Files
- `src/app/layout.tsx` - global layout, fonts, theme provider, tooltip provider, toast provider
- `src/app/globals.css` - global styling
- `src/app/page.tsx` - redirects `/` to `/landing-page`
### Screens / Routes
| Route | File | Purpose |
| --- | --- | --- |
| `/` | `src/app/page.tsx` | Root redirect to landing page |
| `/landing-page` | `src/app/landing-page/page.tsx` | Marketing website |
| `/login` | `src/app/login/page.tsx` | User login form |
| `/request-access` | `src/app/request-access/page.tsx` | Mobile OTP onboarding request flow |
| `/sandbox/[token]` | `src/app/sandbox/[token]/page.tsx` | Demo dashboard / sandbox experience |
| `/activation/[token]` | `src/app/activation/[token]/page.tsx` | Multi-step gym activation flow |
| `/dashboard` | `src/app/dashboard/page.tsx` | Dashboard overview |
| `/dashboard/attendance` | `src/app/dashboard/attendance/page.tsx` | Attendance tracking |
| `/dashboard/members` | `src/app/dashboard/members/page.tsx` | Member listing, details, actions |
| `/dashboard/memberships` | `src/app/dashboard/memberships/page.tsx` | Gym plans / memberships |
| `/dashboard/payments` | `src/app/dashboard/payments/page.tsx` | Payments and invoices |
| `/dashboard/workout-plans` | `src/app/dashboard/workout-plans/page.tsx` | Workout plan management |
| `/dashboard/meal-plans` | `src/app/dashboard/meal-plans/page.tsx` | Meal plan management |
| `/dashboard/staff` | `src/app/dashboard/staff/page.tsx` | Staff management |
| `/dashboard/feed` | `src/app/dashboard/feed/page.tsx` | Feed management |
| `/dashboard/tickets` | `src/app/dashboard/tickets/page.tsx` | Support tickets |
| `/dashboard/payouts` | `src/app/dashboard/payouts/page.tsx` | Wallet, KYC, payout setup |
| `/dashboard/reports` | `src/app/dashboard/reports/page.tsx` | Revenue, attendance, trends |
### Layout and Loading Files
- `src/app/dashboard/layout.tsx` - auth gate, wraps dashboard in `DashboardShell`
- `src/app/dashboard/loading.tsx` - dashboard loading state
- `src/app/dashboard/attendance/loading.tsx`
- `src/app/dashboard/feed/loading.tsx`
- `src/app/dashboard/members/loading.tsx`
- `src/app/dashboard/memberships/loading.tsx`
- `src/app/dashboard/meal-plans/loading.tsx`
- `src/app/dashboard/payments/loading.tsx`
- `src/app/dashboard/payouts/loading.tsx`
- `src/app/dashboard/reports/loading.tsx`
- `src/app/dashboard/staff/loading.tsx`
- `src/app/dashboard/tickets/loading.tsx`
- `src/app/dashboard/workout-plans/loading.tsx`
## Navigation
- Source: `src/lib/nav.ts`
- Main dashboard nav items:
- Dashboard
- Attendance
- Members
- Memberships
- Payments & Invoices
- Workout Plans
- Meal Plans
- Staff
- Feed
- Tickets
- Payouts & Wallet
- Reports
- Mobile primary keys: `dashboard`, `members`, `attendance`, `payments`
## API Layer
### Core HTTP
- `src/lib/api/core-http.ts`
- Responsibilities:
- centralized `apiRequest`
- auth-aware fetch with cookies
- auto refresh on `401`
- redirect to `/login` when session refresh fails
- `ApiError` handling
- shared helpers: `findField`, `refreshSession`
### Auth APIs
- `src/lib/api/auth.ts`
- `login(request)` - login user and extract access/refresh token fields
- `src/lib/api/session.ts`
- `logout()` - clear server cookies and local auth flag
- `refreshSession()` - re-exported refresh helper
### Onboarding / Activation APIs
- `src/lib/api/onboarding.ts`
- `checkMobileNumber(request)` - check if onboarding already exists
- `sendMobileOtp(request)` - send request-access OTP
- `verifyMobileOtp(request)` - verify request-access OTP
- `createOnboardingRequest(requestAccessToken, request)` - submit gym onboarding request
- `getSandboxStatus(sandboxToken)` - fetch demo/sandbox status
- `updateSandboxProgress(sandboxToken, update)` - track sandbox usage/tour progress
- `src/lib/api/activation.ts`
- `sendActivationEmailOtp(token, request)` - send activation email OTP
- `verifyActivationEmailOtp(token, request)` - verify activation email OTP
- `uploadNic(token, nicNumber, frontImage, backImage)` - upload identity documents
- `updateGymProfile(token, request)` - save gym profile info
- `setPaymentMethod(token, request)` - save activation payment method
- `chargeAndActivate(token)` - finalize activation
- `getActivationChecklist(token)` - fetch post-activation checklist
### Dashboard Business APIs
- `src/lib/api/profile.ts`
- `getMyProfile()` - fetch logged-in client profile
- `src/lib/api/stats.ts`
- `getStatSummary()` - dashboard summary stats
- `getMonthlyRevenue()` - revenue chart data
- `getRecentPayments()` - recent payment list
- `getRecentAttendance()` - recent attendance list
- `src/lib/api/attendance.ts`
- `getAllAttendance()` - full attendance records
- `getTodayAttendance()` - today check-ins
- `getAttendanceCounts(fromDate, toDate)` - aggregated attendance counts
- `src/lib/api/customers.ts`
- `getMembers()` - list members with subscriptions
- `getMember(customerId)` - member detail
- `createMember(input)` - add member
- `updateMember(customerId, input)` - edit member
- `deleteMember(customerId)` - remove member
- `src/lib/api/gym-plans.ts`
- `getGymPlans(includeInactive)` - list gym membership plans
- `getGymPlan(id)` - single gym plan
- `createGymPlan(input)` - create gym plan
- `updateGymPlan(id, input)` - update gym plan
- `deleteGymPlan(id, hard)` - delete or soft delete plan
- `src/lib/api/subscriptions.ts`
- `getSubscription(id)` - subscription summary
- `getSubscriptionPayments(id)` - subscription payment history
- `recordSubscriptionPayment(id, input)` - add payment to subscription
- `addSubscriptionMember(id, customerId)` - attach member to subscription
- `removeSubscriptionMember(id, subscriptionMemberId)` - remove member from subscription
- `src/lib/api/invoices.ts`
- `getMemberInvoices()` - list invoices
- `getMemberInvoice(id)` - invoice detail
- `src/lib/api/payments-enablement.ts`
- `getPaymentTracks()` - available payout/payment tracks
- `selectPaymentTrack(request)` - choose a payment track
- `submitPayoutKyc(brCertificate, bankProof)` - upload payout KYC docs
- `getKycStatus()` - read KYC state
- `getWallet()` - wallet summary
- `getPayoutSchedule()` - payout schedule data
- `src/lib/api/staff.ts`
- `getStaff()` - list staff
- `getStaffMember(staffId)` - single staff member
- `createStaff(input)` - create staff member
- `updateStaff(staffId, input)` - update staff
- `deleteStaff(staffId, softDelete)` - delete staff member
- `src/lib/api/feed.ts`
- `getFeed()` - list feed posts
- `createFeedPost(input)` - create post
- `deleteFeedPost(postId)` - delete post
- `likePost(postId)` - like post
- `unlikePost(postId)` - unlike post
- `isPostLiked(postId)` - fetch current user like state
- `src/lib/api/tickets.ts`
- `getTickets()` - list support tickets
- `getTicket(id)` - ticket detail
- `getTicketCategories()` - support categories
- `createTicket(mainReason, ticketCategoryId)` - open ticket
- `sendTicketMessage(id, message)` - add ticket message
- `submitTicketFeedback(id, rating, feedback)` - submit support feedback
- `closeTicket(id)` - close ticket
- `deleteTicket(id)` - delete ticket
### Plan APIs
- `src/lib/api/workout-plans.ts`
- `getWorkoutPlans()`
- `getWorkoutCategories()`
- `createWorkoutCategory(name, description)`
- `createWorkoutPlan(input)`
- `updateWorkoutPlan(id, input)`
- `deleteWorkoutPlan(id)`
- `assignWorkoutPlan(id, customerId, notes)`
- `getWorkoutItems(planId)`
- `createWorkoutItem(planId, title, detail)`
- `src/lib/api/meal-plans.ts`
- `getMealPlans()`
- `getMealCategories()`
- `createMealCategory(name, description)`
- `createMealPlan(input)`
- `updateMealPlan(id, input)`
- `deleteMealPlan(id)`
- `assignMealPlan(id, customerId, notes)`
- `getMealItems(planId)`
- `createMealItem(planId, title, detail)`
## Components
### Shared App Components
- `src/components/theme-provider.tsx` - global theme wrapper
### Marketing Components
- `src/components/marketing/marketing-header.tsx`
- `src/components/marketing/hero-section.tsx`
- `src/components/marketing/features-section.tsx`
- `src/components/marketing/cta-section.tsx`
- `src/components/marketing/marketing-footer.tsx`
### Onboarding Components
- `src/components/onboarding/onboarding-layout.tsx` - shared layout for request access and activation flows
### Dashboard / Hexa Components
- `src/components/hexa/app-sidebar.tsx`
- `src/components/hexa/attendance-chart-card.tsx`
- `src/components/hexa/back-button.tsx`
- `src/components/hexa/checkin-list.tsx`
- `src/components/hexa/client-profile-context.tsx`
- `src/components/hexa/dashboard-shell.tsx`
- `src/components/hexa/data-table.tsx`
- `src/components/hexa/detail-panel.tsx`
- `src/components/hexa/filter-bar.tsx`
- `src/components/hexa/info-banner.tsx`
- `src/components/hexa/mobile-nav.tsx`
- `src/components/hexa/page-header.tsx`
- `src/components/hexa/page-skeleton.tsx`
- `src/components/hexa/pagination-bar.tsx`
- `src/components/hexa/post-card.tsx`
- `src/components/hexa/refresh-button.tsx`
- `src/components/hexa/revenue-chart-card.tsx`
- `src/components/hexa/sandbox-tour.tsx`
- `src/components/hexa/selectable-card.tsx`
- `src/components/hexa/site-header.tsx`
- `src/components/hexa/stat-card.tsx`
- `src/components/hexa/status-badge.tsx`
- `src/components/hexa/theme-toggle.tsx`
### Dashboard View Components
- `src/components/hexa/views/feed-view.tsx`
- `src/components/hexa/views/gym-plans-view.tsx`
- `src/components/hexa/views/payments-enablement-view.tsx`
- `src/components/hexa/views/placeholder-view.tsx`
- `src/components/hexa/views/plan-detail-panel.tsx`
- `src/components/hexa/views/plan-editor-dialog.tsx`
- `src/components/hexa/views/plan-management-view.tsx`
- `src/components/hexa/views/staff-view.tsx`
- `src/components/hexa/views/member-subscription-card.tsx`
### Dialog / Action Components
- `src/components/hexa/views/add-category-dialog.tsx`
- `src/components/hexa/views/add-group-members-dialog.tsx`
- `src/components/hexa/views/add-gym-plan-dialog.tsx`
- `src/components/hexa/views/create-feed-dialog.tsx`
- `src/components/hexa/views/member-form-dialog.tsx`
- `src/components/hexa/views/record-payment-dialog.tsx`
- `src/components/hexa/views/staff-form-dialog.tsx`
### Reusable UI Components
- `src/components/ui/alert-dialog.tsx`
- `src/components/ui/alert.tsx`
- `src/components/ui/avatar.tsx`
- `src/components/ui/badge.tsx`
- `src/components/ui/breadcrumb.tsx`
- `src/components/ui/button.tsx`
- `src/components/ui/calendar.tsx`
- `src/components/ui/card.tsx`
- `src/components/ui/chart.tsx`
- `src/components/ui/checkbox.tsx`
- `src/components/ui/combobox.tsx`
- `src/components/ui/command.tsx`
- `src/components/ui/dialog.tsx`
- `src/components/ui/dropdown-menu.tsx`
- `src/components/ui/empty.tsx`
- `src/components/ui/field.tsx`
- `src/components/ui/input-group.tsx`
- `src/components/ui/input-otp.tsx`
- `src/components/ui/input.tsx`
- `src/components/ui/label.tsx`
- `src/components/ui/pagination.tsx`
- `src/components/ui/popover.tsx`
- `src/components/ui/progress.tsx`
- `src/components/ui/radio-group.tsx`
- `src/components/ui/scroll-area.tsx`
- `src/components/ui/select.tsx`
- `src/components/ui/separator.tsx`
- `src/components/ui/sheet.tsx`
- `src/components/ui/sidebar.tsx`
- `src/components/ui/skeleton.tsx`
- `src/components/ui/sonner.tsx`
- `src/components/ui/spinner.tsx`
- `src/components/ui/switch.tsx`
- `src/components/ui/table.tsx`
- `src/components/ui/tabs.tsx`
- `src/components/ui/textarea.tsx`
- `src/components/ui/tooltip.tsx`
## Hooks and Client Utilities
### Hooks
- `src/hooks/use-mobile.ts` - mobile breakpoint helper
- `src/lib/use-media-query.ts` - media query hook
- `src/lib/use-fetch.ts` - lightweight async data fetching helper
### Utility / Support Files
- `src/lib/auth/token-service.ts` - local auth/session flag helpers
- `src/lib/format.ts` - formatting helpers for currency, dates, etc.
- `src/lib/nav.ts` - dashboard navigation config
- `src/lib/utils.ts` - shared utility helpers
- `src/lib/validation.ts` - validation and masking helpers for onboarding/activation
## Models / Types
- `src/lib/models/api.ts` - base API response types
- `src/lib/models/auth.ts` - auth request/result types
- `src/lib/models/activation.ts` - activation flow types
- `src/lib/models/attendance.ts` - attendance-related types
- `src/lib/models/customer.ts` - member and customer types
- `src/lib/models/feed.ts` - feed post types
- `src/lib/models/gym-plan.ts` - gym membership plan types
- `src/lib/models/invoice.ts` - invoice types
- `src/lib/models/onboarding.ts` - request-access and sandbox types
- `src/lib/models/payments-enablement.ts` - wallet, KYC, payout types
- `src/lib/models/plan.ts` - reusable workout/meal plan types
- `src/lib/models/profile.ts` - client profile types
- `src/lib/models/staff.ts` - staff types
- `src/lib/models/stats.ts` - dashboard stat types
- `src/lib/models/subscription.ts` - subscription and payment types
- `src/lib/models/ticket.ts` - support ticket types
## Public Assets
### Images
- `public/images/auth-bg.png`
- `public/images/gym-bg.png`
- `public/images/landing-hero.png`
- `public/images/logo.png`
- `public/images/hexa_Logo.png`
- `public/images/Hexa_Fitness.png`
- `public/images/hexa-Logo-white.png`
- `public/images/hexa-Logo-black.png`
- `public/images/mobile-bg.png`
### Other Public Files
- `public/file.svg`
- `public/globe.svg`
- `public/next.svg`
- `public/vercel.svg`
- `public/window.svg`
## Current Functional Areas
- Marketing website
- Authentication
- Request access onboarding
- Sandbox demo experience
- Gym activation workflow
- Dashboard overview analytics
- Attendance management
- Member management
- Membership / gym plan management
- Payments and invoices
- Workout plan management
- Meal plan management
- Staff management
- Feed / posts
- Ticketing / support
- Payout and wallet setup
- Reports
## Suggested Use Of This File
- Use this document as the first-stop project map for onboarding
- Update it when new routes, API modules, or components are added
- Keep API method names in sync with `src/lib/api/*`
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"admin/**",
]),
]);
export default eslintConfig;
+12
View File
@@ -0,0 +1,12 @@
import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
turbopack: {
root: path.resolve(__dirname),
},
};
export default nextConfig;
+11212
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "hexa-fitness-client",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@microsoft/signalr": "^8.0.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"driver.js": "^1.6.0",
"html2canvas-pro": "^2.3.0",
"input-otp": "^1.4.2",
"jspdf": "^4.2.1",
"lucide-react": "^1.23.0",
"next": "16.2.10",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"react": "19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.4",
"recharts": "^3.8.0",
"shadcn": "^4.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/qrcode": "^1.5.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="chart-list" />
}
+5
View File
@@ -0,0 +1,5 @@
import { AttendanceView } from "@/components/hexa/views/attendance-view"
export default function AttendancePage() {
return <AttendanceView />
}
+5
View File
@@ -0,0 +1,5 @@
import { BookingsView } from "@/components/hexa/views/bookings-view"
export default function BookingsPage() {
return <BookingsView />
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
@@ -0,0 +1,5 @@
import { CustomerPaymentsView } from "@/components/hexa/views/customer-payments-view"
export default function CustomerPaymentsPage() {
return <CustomerPaymentsView />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-cards" />
}
+5
View File
@@ -0,0 +1,5 @@
import { FeedView } from "@/components/hexa/views/feed-view"
export default function FeedPage() {
return <FeedView />
}
+38
View File
@@ -0,0 +1,38 @@
"use client"
import * as React from "react"
import { usePathname, useRouter } from "next/navigation"
import { isLoggedIn } from "@/lib/auth/token-service"
import { Spinner } from "@/components/ui/spinner"
import { DashboardShell } from "@/components/hexa/dashboard-shell"
import { ClientProfileProvider } from "@/components/hexa/client-profile-context"
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const [checked, setChecked] = React.useState(false)
React.useEffect(() => {
if (!isLoggedIn()) {
router.replace(`/login?next=${encodeURIComponent(pathname || "/dashboard")}`)
return
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time auth-gate check against localStorage, not derived from props/state
setChecked(true)
}, [router, pathname])
if (!checked) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner className="size-6" />
</div>
)
}
return (
<ClientProfileProvider>
<DashboardShell notificationCount={3}>{children}</DashboardShell>
</ClientProfileProvider>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="chart-list" />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { MealPlansView } from "@/components/hexa/views/meal-plans-view"
export default function MealPlansPage() {
return <MealPlansView />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { MembersView } from "@/components/hexa/views/members-view"
export default function MembersPage() {
return <MembersView />
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-cards" />
}
+5
View File
@@ -0,0 +1,5 @@
import { MembershipsView } from "@/components/hexa/views/memberships-view"
export default function MembershipsPage() {
return <MembershipsView />
}
+5
View File
@@ -0,0 +1,5 @@
import { DashboardOverviewView } from "@/components/hexa/views/dashboard-overview-view"
export default function DashboardOverviewPage() {
return <DashboardOverviewView />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="chart-list" />
}
+5
View File
@@ -0,0 +1,5 @@
import { ReportsView } from "@/components/hexa/views/reports-view"
export default function ReportsPage() {
return <ReportsView />
}
+11
View File
@@ -0,0 +1,11 @@
import * as React from "react"
import { SettingsView } from "@/components/hexa/views/settings-view"
export default function SettingsPage() {
return (
<React.Suspense fallback={null}>
<SettingsView />
</React.Suspense>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { StaffView } from "@/components/hexa/views/staff-view"
export default function StaffPage() {
return <StaffView />
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { TicketsView } from "@/components/hexa/views/tickets-view"
export default function TicketsPage() {
return <TicketsView />
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/hexa/page-skeleton"
export default function Loading() {
return <PageSkeleton variant="stats-table" />
}
+5
View File
@@ -0,0 +1,5 @@
import { WorkoutPlansView } from "@/components/hexa/views/workout-plans-view"
export default function WorkoutPlansPage() {
return <WorkoutPlansView />
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+206
View File
@@ -0,0 +1,206 @@
"use client"
import * as React from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeftIcon, Loader2Icon } from "lucide-react"
import { toast } from "sonner"
import hexaLogo from "@/assets/images/hexa-logo.png"
import { ApiError } from "@/lib/api/core-http"
import { forgotPassword, resetPassword, verifyResetOtp } from "@/lib/api/auth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp"
type Step = "identifier" | "otp" | "password"
export default function ForgotPasswordPage() {
const router = useRouter()
const [step, setStep] = React.useState<Step>("identifier")
const [identifier, setIdentifier] = React.useState("")
const [referenceNumber, setReferenceNumber] = React.useState("")
const [otp, setOtp] = React.useState("")
const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
function onError(error: unknown, fallback: string) {
toast.error(error instanceof ApiError ? error.message : fallback)
}
async function handleSendOtp(e: React.FormEvent) {
e.preventDefault()
if (!identifier.trim()) {
toast.error("Enter your email or mobile number.")
return
}
setSubmitting(true)
try {
const { referenceNumber } = await forgotPassword(identifier.trim())
setReferenceNumber(referenceNumber)
setStep("otp")
toast.success("We sent you a verification code.")
} catch (error) {
onError(error, "Couldn't send the code. Try again.")
} finally {
setSubmitting(false)
}
}
async function handleVerifyOtp(e: React.FormEvent) {
e.preventDefault()
if (otp.length < 6) {
toast.error("Enter the 6-digit code.")
return
}
setSubmitting(true)
try {
await verifyResetOtp(referenceNumber, otp)
setStep("password")
} catch (error) {
onError(error, "That code didn't work. Try again.")
} finally {
setSubmitting(false)
}
}
async function handleReset(e: React.FormEvent) {
e.preventDefault()
if (password.length < 8) {
toast.error("Password must be at least 8 characters.")
return
}
if (password !== confirm) {
toast.error("Passwords don't match.")
return
}
setSubmitting(true)
try {
await resetPassword(referenceNumber, password, confirm)
toast.success("Password reset. Please sign in.")
router.push("/login")
} catch (error) {
onError(error, "Couldn't reset your password. Try again.")
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen">
<div className="relative hidden w-1/2 shrink-0 bg-neutral-50 lg:block">
<Image src="/images/auth-bg.png" alt="" fill className="object-contain object-bottom p-12" priority />
<div className="absolute top-8 left-8 flex items-center gap-2">
<Image src={hexaLogo} alt="Vijaya Sport Club" width={260} height={64} className="h-16 w-auto" />
</div>
</div>
<div className="flex w-full flex-col justify-start px-6 py-10 sm:justify-center sm:px-12 sm:py-12 lg:w-1/2 lg:px-16">
<div className="mx-auto w-full max-w-sm">
<Link href="/login" className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeftIcon className="size-4" />
Back to sign in
</Link>
<p className="text-xs font-semibold tracking-wide text-primary uppercase">Reset password</p>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">
{step === "identifier" && "Forgot your password?"}
{step === "otp" && "Enter the code"}
{step === "password" && "Choose a new password"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{step === "identifier" && "Enter your email or mobile and we'll send you a verification code."}
{step === "otp" && `We sent a 6-digit code to ${identifier}.`}
{step === "password" && "Pick a strong password you haven't used before."}
</p>
{step === "identifier" && (
<form onSubmit={handleSendOtp} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="identifier">Email or mobile number</Label>
<Input
id="identifier"
className="h-11"
placeholder="you@example.com"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
autoComplete="username"
/>
</div>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Send code
</Button>
</form>
)}
{step === "otp" && (
<form onSubmit={handleVerifyOtp} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col items-center gap-2">
<InputOTP maxLength={6} value={otp} onChange={setOtp} containerClassName="justify-center">
<InputOTPGroup>
{Array.from({ length: 6 }).map((_, i) => (
<InputOTPSlot key={i} index={i} className="size-10 text-base sm:size-11" />
))}
</InputOTPGroup>
</InputOTP>
</div>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Verify code
</Button>
<button
type="button"
className="text-sm text-muted-foreground hover:text-foreground"
onClick={() => setStep("identifier")}
>
Use a different account
</button>
</form>
)}
{step === "password" && (
<form onSubmit={handleReset} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
className="h-11"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
type="password"
className="h-11"
placeholder="Re-enter your password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
/>
</div>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Reset password
</Button>
</form>
)}
</div>
</div>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.984 0.003 17);
--foreground: oklch(0.2 0.01 17);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.01 17);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.01 17);
--primary: oklch(0.586 0.216 17);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.965 0.004 17);
--secondary-foreground: oklch(0.2 0.01 17);
--muted: oklch(0.965 0.004 17);
--muted-foreground: oklch(0.54 0.01 17);
--accent: oklch(0.955 0.03 17);
--accent-foreground: oklch(0.586 0.216 17);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.006 17);
--input: oklch(0.92 0.006 17);
--ring: oklch(0.586 0.216 17 / 40%);
--chart-1: oklch(0.586 0.216 17);
--chart-2: oklch(0.65 0.15 220);
--chart-3: oklch(0.72 0.15 150);
--chart-4: oklch(0.75 0.15 70);
--chart-5: oklch(0.6 0.18 290);
--radius: 0.75rem;
--sidebar: oklch(1 0 0);
--sidebar-foreground: oklch(0.2 0.01 17);
--sidebar-primary: oklch(0.586 0.216 17);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.955 0.03 17);
--sidebar-accent-foreground: oklch(0.586 0.216 17);
--sidebar-border: oklch(0.94 0.005 17);
--sidebar-ring: oklch(0.586 0.216 17 / 40%);
}
.dark {
--background: oklch(0.17 0.006 17);
--foreground: oklch(0.96 0 0);
--card: oklch(0.215 0.008 17);
--card-foreground: oklch(0.96 0 0);
--popover: oklch(0.215 0.008 17);
--popover-foreground: oklch(0.96 0 0);
--primary: oklch(0.645 0.211 17);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.28 0.008 17);
--secondary-foreground: oklch(0.96 0 0);
--muted: oklch(0.28 0.008 17);
--muted-foreground: oklch(0.68 0.01 17);
--accent: oklch(0.32 0.05 17);
--accent-foreground: oklch(0.9 0.06 17);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.645 0.211 17 / 45%);
--chart-1: oklch(0.645 0.211 17);
--chart-2: oklch(0.65 0.15 220);
--chart-3: oklch(0.72 0.15 150);
--chart-4: oklch(0.75 0.15 70);
--chart-5: oklch(0.6 0.18 290);
--sidebar: oklch(0.19 0.008 17);
--sidebar-foreground: oklch(0.96 0 0);
--sidebar-primary: oklch(0.645 0.211 17);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.28 0.05 17);
--sidebar-accent-foreground: oklch(0.9 0.06 17);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.645 0.211 17 / 45%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import { ModalStackProvider } from "@/components/modal-stack";
const geistSans = Geist({
variable: "--font-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "HexaFitness | Fitness Automation Simplified",
description: "Gym management dashboard for HexaFitness.",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col"
data-new-gr-c-s-check-loaded="14.1309.0"
data-gr-ext-installed=""
>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
<TooltipProvider delay={200}>
<ModalStackProvider>
{children}
<Toaster position="top-right" richColors />
</ModalStackProvider>
</TooltipProvider>
</ThemeProvider>
</body>
</html>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client"
import * as React from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Loader2Icon } from "lucide-react"
import { toast } from "sonner"
import hexaLogo from "@/assets/images/hexa-logo.png"
import { ApiError } from "@/lib/api/core-http"
import { login } from "@/lib/api/auth"
import { resetNavItemsCache } from "@/hooks/use-nav-items"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const next = searchParams.get("next") || "/dashboard"
const [identifier, setIdentifier] = React.useState("")
const [password, setPassword] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [rememberMe, setRememberMe] = React.useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!identifier.trim() || !password) {
toast.error("Enter your email/phone and password.")
return
}
setSubmitting(true)
try {
const result = await login({ identifier: identifier.trim(), password, rememberMe })
if (!result.accessToken) {
toast.error("Login didn't return a valid session. Try again.")
return
}
resetNavItemsCache()
router.push(next)
} catch (error) {
const message = error instanceof ApiError ? error.message : "Couldn't sign in. Try again."
toast.error(message)
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen">
<div className="relative hidden w-1/2 shrink-0 bg-neutral-50 lg:block">
<Image src="/images/auth-bg.png" alt="" fill className="object-contain object-bottom p-12" priority />
</div>
<div className="flex w-full flex-col justify-start px-6 py-10 sm:justify-center sm:px-12 sm:py-12 lg:w-1/2 lg:px-16">
<div className="mx-auto w-full max-w-sm">
<div className="mb-8 flex items-center gap-2 lg:hidden">
<Image src={hexaLogo} alt="Vijaya Sport Club" width={230} height={56} className="h-14 w-auto" />
</div>
<p className="text-xs font-semibold tracking-wide text-primary uppercase">Sign in</p>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Welcome back</h1>
<p className="mt-2 text-sm text-muted-foreground">Enter your details to access your gym.</p>
<form onSubmit={handleSubmit} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="identifier">Email or mobile number</Label>
<Input
id="identifier"
className="h-11"
placeholder="you@example.com"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
autoComplete="username"
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-xs font-medium text-primary hover:underline"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
className="h-11"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox checked={rememberMe} onCheckedChange={(checked) => setRememberMe(checked === true)} />
Remember me for 30 days
</label>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Sign in
</Button>
</form>
</div>
</div>
</div>
)
}
export default function LoginPage() {
return (
<React.Suspense fallback={null}>
<LoginForm />
</React.Suspense>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation"
// No public marketing site for a single-club internal admin portal -- straight to the
// dashboard (which itself redirects to /login if there's no stored token).
export default function RootPage() {
redirect("/dashboard")
}
+172
View File
@@ -0,0 +1,172 @@
"use client"
import * as React from "react"
import { useParams } from "next/navigation"
import { CheckCircle2Icon, InfoIcon, Loader2Icon } from "lucide-react"
import { toast } from "sonner"
import { getInvoiceByPaymentToken, payInvoiceByToken } from "@/lib/api/pay"
import { ApiError } from "@/lib/api/core-http"
import { formatCurrency } from "@/lib/format"
import type { PayByLinkInvoice } from "@/lib/models/pay"
import { OnboardingLayout } from "@/components/onboarding/onboarding-layout"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
type Step = "loading" | "form" | "paying" | "success" | "error"
export default function PayByLinkPage() {
const params = useParams<{ token: string }>()
const token = params.token
const [step, setStep] = React.useState<Step>("loading")
const [invoice, setInvoice] = React.useState<PayByLinkInvoice | null>(null)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
const [cardName, setCardName] = React.useState("")
const [cardNumber, setCardNumber] = React.useState("")
const [cardExpiry, setCardExpiry] = React.useState("")
const [cardCvc, setCardCvc] = React.useState("")
React.useEffect(() => {
let cancelled = false
async function load() {
try {
const result = await getInvoiceByPaymentToken(token)
if (cancelled) return
setInvoice(result)
setStep(result.balance > 0 ? "form" : "success")
} catch (error) {
if (cancelled) return
const message = error instanceof ApiError ? error.message : "This payment link is invalid or has expired."
setErrorMessage(message)
setStep("error")
}
}
load()
return () => {
cancelled = true
}
}, [token])
async function handlePay(e: React.FormEvent) {
e.preventDefault()
if (!cardNumber.trim() || !cardExpiry.trim() || !cardCvc.trim() || !cardName.trim()) {
toast.error("Fill in the card details.")
return
}
setStep("paying")
try {
// No real PayHere/Stripe SDK is wired up yet — this is a mock token standing in
// for what real client-side card tokenization would return.
const cardToken = `mock_${crypto.randomUUID()}`
await payInvoiceByToken(token, cardToken)
setStep("success")
} catch (error) {
const message = error instanceof ApiError ? error.message : "Couldn't process the payment. Try again."
toast.error(message)
setStep("form")
}
}
return (
<OnboardingLayout eyebrow="Payment" currentStep={1} totalSteps={1} hideProgress>
{step === "loading" && (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<Loader2Icon className="size-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading your invoice…</p>
</div>
)}
{step === "error" && (
<>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Link unavailable</h1>
<p className="mt-2 text-sm text-muted-foreground">{errorMessage}</p>
</>
)}
{(step === "form" || step === "paying") && invoice && (
<>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">
{invoice.gymName ?? "Pay your invoice"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{invoice.planName ? `${invoice.planName} — ` : ""}
Outstanding balance: <span className="font-semibold text-foreground">{formatCurrency(invoice.balance)}</span>
</p>
<Alert className="mt-4">
<InfoIcon />
<AlertDescription>
Test mode — no real PayHere/Stripe SDK is wired up yet, so no real charge will occur.
</AlertDescription>
</Alert>
<form onSubmit={handlePay} className="mt-6 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-name">Name on card</Label>
<Input
id="card-name"
placeholder="Full name"
className="h-11"
value={cardName}
onChange={(e) => setCardName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-number">Card number</Label>
<Input
id="card-number"
inputMode="numeric"
placeholder="4111 1111 1111 1111"
className="h-11"
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-expiry">Expiry</Label>
<Input
id="card-expiry"
placeholder="MM/YY"
className="h-11"
value={cardExpiry}
onChange={(e) => setCardExpiry(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-cvc">CVC</Label>
<Input
id="card-cvc"
inputMode="numeric"
placeholder="123"
className="h-11"
value={cardCvc}
onChange={(e) => setCardCvc(e.target.value)}
/>
</div>
</div>
<Button type="submit" size="lg" className="h-11" disabled={step === "paying"}>
{step === "paying" && <Loader2Icon className="animate-spin" />}
Pay {formatCurrency(invoice.balance)}
</Button>
</form>
</>
)}
{step === "success" && (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<CheckCircle2Icon className="size-12 text-emerald-600" />
<h1 className="text-2xl font-bold tracking-tight">Payment received</h1>
<p className="text-sm text-muted-foreground">
Thanks{invoice?.memberName ? `, ${invoice.memberName}` : ""} — your payment has been recorded.
</p>
</div>
)}
</OnboardingLayout>
)
}
+151
View File
@@ -0,0 +1,151 @@
"use client"
import * as React from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { CheckCircle2Icon, Loader2Icon } from "lucide-react"
import { toast } from "sonner"
import hexaLogo from "@/assets/images/hexa-logo.png"
import { ApiError } from "@/lib/api/core-http"
import { resetPasswordWithToken } from "@/lib/api/auth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
function ResetPasswordForm() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get("token") ?? ""
const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [done, setDone] = React.useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (password.length < 8) {
toast.error("Password must be at least 8 characters.")
return
}
if (password !== confirm) {
toast.error("Passwords don't match.")
return
}
setSubmitting(true)
try {
await resetPasswordWithToken(token, password, confirm)
setDone(true)
toast.success("Password set. Please sign in.")
} catch (error) {
const message =
error instanceof ApiError
? error.message
: "Couldn't reset your password. Try again."
toast.error(message)
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen">
<div className="relative hidden w-1/2 shrink-0 bg-neutral-50 lg:block">
<Image src="/images/auth-bg.png" alt="" fill className="object-contain object-bottom p-12" priority />
<div className="absolute top-8 left-8 flex items-center gap-2">
<Image src={hexaLogo} alt="Vijaya Sport Club" width={260} height={64} className="h-16 w-auto" />
</div>
</div>
<div className="flex w-full flex-col justify-start px-6 py-10 sm:justify-center sm:px-12 sm:py-12 lg:w-1/2 lg:px-16">
<div className="mx-auto w-full max-w-sm">
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">Set password</p>
{!token ? (
<>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Invalid link</h1>
<p className="mt-2 text-sm text-muted-foreground">
This link is missing its token. Request a new password reset email and try again.
</p>
<Button
size="lg"
className="mt-8 h-11 w-full"
nativeButton={false}
render={<Link href="/forgot-password" />}
>
Request a new link
</Button>
</>
) : done ? (
<>
<div className="mt-2 flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl">
<CheckCircle2Icon className="size-7 text-primary" />
Password set
</div>
<p className="mt-2 text-sm text-muted-foreground">
Your password has been updated. Sign in with your new password to continue.
</p>
<Button size="lg" className="mt-8 h-11 w-full" onClick={() => router.push("/login")}>
Go to sign in
</Button>
</>
) : (
<>
<h1 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Choose a new password</h1>
<p className="mt-2 text-sm text-muted-foreground">
Pick a strong password you haven&apos;t used before.
</p>
<form onSubmit={handleSubmit} className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
className="h-11"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
type="password"
className="h-11"
placeholder="Re-enter your password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
/>
</div>
<Button type="submit" size="lg" className="h-11" disabled={submitting}>
{submitting && <Loader2Icon className="animate-spin" />}
Set password
</Button>
</form>
</>
)}
<p className="mt-8 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-foreground hover:underline">
Back to sign in
</Link>
</p>
</div>
</div>
</div>
)
}
export default function ResetPasswordPage() {
return (
<React.Suspense fallback={null}>
<ResetPasswordForm />
</React.Suspense>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Loader2Icon, MapPinIcon } from "lucide-react"
import { searchAddress, type AddressSuggestion } from "@/lib/geocode"
import { Input } from "@/components/ui/input"
const DEBOUNCE_MS = 400
interface AddressAutocompleteProps {
id?: string
className?: string
placeholder?: string
value: string
onChange: (value: string) => void
/** Fired when the user picks a suggestion — carries the resolved lat/lng. */
onSelect: (suggestion: AddressSuggestion) => void
}
function AddressAutocomplete({
id,
className,
placeholder,
value,
onChange,
onSelect,
}: AddressAutocompleteProps) {
const [suggestions, setSuggestions] = React.useState<AddressSuggestion[]>([])
const [loading, setLoading] = React.useState(false)
const [open, setOpen] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
const skipNextSearch = React.useRef(false)
React.useEffect(() => {
if (skipNextSearch.current) {
skipNextSearch.current = false
return
}
if (value.trim().length < 3) {
setSuggestions([])
setLoading(false)
return
}
let cancelled = false
setLoading(true)
const timer = setTimeout(async () => {
const results = await searchAddress(value)
if (!cancelled) {
setSuggestions(results)
setLoading(false)
setOpen(true)
}
}, DEBOUNCE_MS)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [value])
React.useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
function handleSelect(suggestion: AddressSuggestion) {
skipNextSearch.current = true
setSuggestions([])
setOpen(false)
onChange(suggestion.label)
onSelect(suggestion)
}
return (
<div ref={containerRef} className="relative">
<Input
id={id}
placeholder={placeholder}
className={className}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => suggestions.length > 0 && setOpen(true)}
autoComplete="off"
/>
{loading && (
<Loader2Icon className="absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin text-muted-foreground" />
)}
{open && suggestions.length > 0 && (
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border bg-popover shadow-md">
{suggestions.map((s, i) => (
<button
key={`${s.lat}-${s.lng}-${i}`}
type="button"
onClick={() => handleSelect(s)}
className="flex w-full items-start gap-2 px-3 py-2 text-left text-sm hover:bg-muted"
>
<MapPinIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{s.label}</span>
</button>
))}
</div>
)}
</div>
)
}
export { AddressAutocomplete }
+110
View File
@@ -0,0 +1,110 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { MapPinIcon } from "lucide-react"
import { NAV_ITEMS, isNavItemActive } from "@/lib/nav"
import { useNavItems } from "@/hooks/use-nav-items"
import { useClientProfile } from "@/components/hexa/client-profile-context"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
interface AppSidebarProps {
/** Sandbox demo mode: navigate by key with local state instead of route links. */
activeKey?: string
onNavigate?: (key: string) => void
}
function AppSidebar({ activeKey, onNavigate }: AppSidebarProps) {
const pathname = usePathname()
const { setOpenMobile } = useSidebar()
const profile = useClientProfile()
// Sandbox demo mode (onNavigate present, no backend to call) keeps the static list;
// real dashboard usage fetches the live, permission-filtered nav.
const { items: liveItems } = useNavItems(!onNavigate)
const items = onNavigate ? NAV_ITEMS : liveItems
const gymName = profile.gymName ?? "HexaFitness Gym"
const location = [profile.city, profile.address && profile.city ? null : profile.address]
.filter(Boolean)
.join(", ") || "Sri Lanka"
const gymInitials = gymName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join("")
return (
<Sidebar collapsible="offcanvas">
<SidebarHeader className="px-3 py-4">
<div data-tour="member-app-preview" className="flex items-center gap-2.5 px-1">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-primary/10 text-primary ring-1 ring-foreground/10">
{profile.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- base64 data URL, not an optimizable remote asset
<img src={profile.logoUrl} alt={gymName} className="size-full object-cover" />
) : (
<span className="text-sm font-semibold">{gymInitials || "GY"}</span>
)}
</div>
<div className="min-w-0 leading-tight">
<p className="truncate text-base font-bold">{gymName}</p>
<p className="flex items-center gap-1 truncate text-[0.65rem] text-muted-foreground">
<MapPinIcon className="size-3 shrink-0" />
<span className="truncate">{location}</span>
</p>
</div>
</div>
</SidebarHeader>
<SidebarContent className="px-2">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const active = onNavigate
? item.key === activeKey
: isNavItemActive(item, pathname)
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
data-tour={`nav-${item.key}`}
isActive={active}
onClick={() => {
setOpenMobile(false)
onNavigate?.(item.key)
}}
render={onNavigate ? undefined : <Link href={item.href} />}
>
<item.icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="items-center p-3">
<p className="text-center text-[0.65rem] text-muted-foreground">
Professionally developed by <span className="font-medium text-foreground/70">Hexdive</span>
</p>
</SidebarFooter>
</Sidebar>
)
}
export { AppSidebar }
@@ -0,0 +1,83 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import type { AttendanceCount } from "@/lib/models/attendance"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
const chartConfig = {
attendanceCount: {
label: "Check-ins",
color: "var(--primary)",
},
} satisfies ChartConfig
interface AttendanceChartCardProps {
data: AttendanceCount[] | null
loading?: boolean
title?: string
/** Show at most this many members (sorted by count). */
limit?: number
}
function AttendanceChartCard({
data,
loading,
title = "Most active members",
limit = 8,
}: AttendanceChartCardProps) {
const rows = (data ?? [])
.slice()
.sort((a, b) => b.attendanceCount - a.attendanceCount)
.slice(0, limit)
.map((row) => ({
...row,
shortName:
(row.customerName ?? "?").length > 12
? `${row.customerName.slice(0, 11)}…`
: row.customerName,
}))
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-56 w-full rounded-lg" />
) : rows.length === 0 ? (
<div className="flex h-56 items-center justify-center text-sm text-muted-foreground">
No attendance recorded for this period.
</div>
) : (
<ChartContainer config={chartConfig} className="h-56 w-full">
<BarChart data={rows} layout="vertical" margin={{ left: 8, right: 8 }}>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis
type="category"
dataKey="shortName"
width={90}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="attendanceCount" fill="var(--color-attendanceCount)" radius={4} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}
export { AttendanceChartCard }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import { useRouter } from "next/navigation"
import { ArrowLeftIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface BackButtonProps {
label?: string
className?: string
}
function BackButton({ label, className }: BackButtonProps) {
const router = useRouter()
return (
<Button
variant="outline"
size={label ? "default" : "icon"}
className={cn("rounded-full", className)}
onClick={() => router.back()}
aria-label="Go back"
>
<ArrowLeftIcon />
{label}
</Button>
)
}
export { BackButton }
+86
View File
@@ -0,0 +1,86 @@
"use client"
import { CalendarCheck } from "lucide-react"
import { formatTime } from "@/lib/format"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
export interface CheckinEntry {
name: string
/** ISO timestamp of the check-in/out event. */
time: string
/** Optional event label, e.g. "IN" / "OUT". */
eventType?: string
}
interface CheckinListProps {
entries: CheckinEntry[] | null
loading?: boolean
title?: string
emptyMessage?: string
}
function CheckinList({
entries,
loading,
title = "Today's check-ins",
emptyMessage = "No check-ins yet today.",
}: CheckinListProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-16" />
</div>
</div>
))
) : !entries || entries.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CalendarCheck className="size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
) : (
entries.map((entry, i) => (
<div key={`${entry.name}-${entry.time}-${i}`} className="flex items-center gap-3 py-2">
<Avatar className="size-9">
<AvatarFallback className="bg-primary/10 text-xs font-semibold text-primary">
{initials(entry.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 leading-tight">
<p className="truncate text-sm font-medium">{entry.name}</p>
<p className="text-xs text-muted-foreground">{formatTime(entry.time)}</p>
</div>
{entry.eventType && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground uppercase">
{entry.eventType}
</span>
)}
</div>
))
)}
</CardContent>
</Card>
)
}
function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
}
export { CheckinList }
@@ -0,0 +1,85 @@
"use client"
import * as React from "react"
import { getGymLogo, getMyProfile } from "@/lib/api/profile"
interface ClientProfileState {
clientId: string | null
clientName: string | null
gymName: string | null
city: string | null
address: string | null
phone: string | null
email: string | null
logoUrl: string | null
loading: boolean
}
const ClientProfileContext = React.createContext<ClientProfileState>({
clientId: null,
clientName: null,
gymName: null,
city: null,
address: null,
phone: null,
email: null,
logoUrl: null,
loading: true,
})
function ClientProfileProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = React.useState<ClientProfileState>({
clientId: null,
clientName: null,
gymName: null,
city: null,
address: null,
phone: null,
email: null,
logoUrl: null,
loading: true,
})
React.useEffect(() => {
let cancelled = false
Promise.all([getMyProfile(), getGymLogo().catch(() => ({ logo: null }))])
.then(([profile, logoResult]) => {
if (cancelled) return
if (profile.clientId) {
try {
localStorage.setItem("clientId", profile.clientId)
} catch {
// ignore (e.g. private browsing storage restrictions)
}
}
const business = profile.clientBusinesses?.[0]
setState({
clientId: profile.clientId ?? null,
clientName: profile.clientName ?? null,
gymName: business?.businessName ?? profile.clientName ?? null,
city: business?.city ?? null,
address: business?.address ?? null,
phone: profile.clientPhone ?? null,
email: profile.clientEmail ?? null,
logoUrl: logoResult.logo ?? null,
loading: false,
})
})
.catch(() => {
// Best-effort chrome — leave fallbacks in place if the profile can't load.
if (!cancelled) setState((s) => ({ ...s, loading: false }))
})
return () => {
cancelled = true
}
}, [])
return <ClientProfileContext.Provider value={state}>{children}</ClientProfileContext.Provider>
}
function useClientProfile() {
return React.useContext(ClientProfileContext)
}
export { ClientProfileProvider, useClientProfile }
+57
View File
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/hexa/app-sidebar"
import { MobileNav } from "@/components/hexa/mobile-nav"
import { SiteHeader } from "@/components/hexa/site-header"
interface DashboardShellProps {
/** Sandbox demo mode: navigate by key with local state instead of route links. */
activeKey?: string
onNavigate?: (key: string) => void
onRefresh?: () => void
notificationCount?: number
children: React.ReactNode
}
function DashboardShell({
activeKey,
onNavigate,
onRefresh,
notificationCount,
children,
}: DashboardShellProps) {
const [date, setDate] = React.useState<Date | null>(null)
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- avoids rendering a time-dependent value during hydration.
setDate(new Date())
}, [])
return (
<SidebarProvider>
<AppSidebar activeKey={activeKey} onNavigate={onNavigate} />
<SidebarInset>
<SiteHeader
date={date}
onDateChange={setDate}
onRefresh={onRefresh}
notificationCount={notificationCount}
demo={Boolean(onNavigate)}
onNavigate={onNavigate}
/>
<main
data-tour="dashboard"
className="flex-1 p-3 pb-24 sm:p-4 sm:pb-24 md:pb-6 lg:p-6"
>
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">{children}</div>
</main>
<MobileNav activeKey={activeKey} onNavigate={onNavigate} />
</SidebarInset>
</SidebarProvider>
)
}
export { DashboardShell }
+101
View File
@@ -0,0 +1,101 @@
import { InboxIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface DataTableColumn<T> {
key: string
header: string
cell: (row: T) => React.ReactNode
className?: string
}
interface DataTableProps<T> {
columns: DataTableColumn<T>[]
rows: T[]
rowKey: (row: T) => string
renderMobileCard: (row: T) => React.ReactNode
emptyMessage?: string
className?: string
onRowClick?: (row: T) => void
}
function DataTable<T>({
columns,
rows,
rowKey,
renderMobileCard,
emptyMessage = "No results found.",
className,
onRowClick,
}: DataTableProps<T>) {
if (rows.length === 0) {
return (
<div className={cn("flex flex-col items-center justify-center gap-2 rounded-xl border bg-card py-16 text-center", className)}>
<InboxIcon className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
)
}
return (
<div className={cn("overflow-hidden rounded-xl border bg-card", className)}>
<div className="flex flex-col divide-y md:hidden">
{rows.map((row) => (
<div
key={rowKey(row)}
className={cn("p-4", onRowClick && "cursor-pointer")}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{renderMobileCard(row)}
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
{columns.map((col) => (
<TableHead
key={col.key}
className={cn(
"h-11 text-xs font-medium tracking-wide text-muted-foreground uppercase",
col.className
)}
>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow
key={rowKey(row)}
className={onRowClick ? "cursor-pointer" : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => (
<TableCell key={col.key} className={col.className}>
{col.cell(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export { DataTable }
export type { DataTableColumn }
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import { useMediaQuery } from "@/lib/use-media-query"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
interface DetailPanelProps {
open: boolean
onOpenChange: (open: boolean) => void
title: React.ReactNode
description?: React.ReactNode
children: React.ReactNode
}
/** Responsive detail container: bottom sheet on phones, dialog at md+. */
function DetailPanel({ open, onOpenChange, title, description, children }: DetailPanelProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className="max-h-[85vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
{description && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
<div className="px-4 pb-2">{children}</div>
</SheetContent>
</Sheet>
)
}
export { DetailPanel }
@@ -0,0 +1,137 @@
"use client"
import * as React from "react"
import { getAccessDevices } from "@/lib/api/access-devices"
import type { AccessDevice } from "@/lib/models/access-device"
import {
subscribeAccessEvents,
type DevicePresencePayload,
} from "@/lib/realtime/access-events"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useClientProfile } from "@/components/hexa/client-profile-context"
function DeviceStatusIndicator() {
const { clientId } = useClientProfile()
const [devices, setDevices] = React.useState<AccessDevice[] | null>(null)
React.useEffect(() => {
if (!clientId) return
let cancelled = false
async function load() {
try {
const result = await getAccessDevices()
if (!cancelled) setDevices(result.devices ?? [])
} catch {
// Best-effort chrome — keep the last known status rather than flashing an error.
}
}
void load()
const unsubscribe = subscribeAccessEvents((event) => {
if (event.type !== "devicePresence") return
const update = event.payload as DevicePresencePayload
setDevices((current) => {
if (!current) return current
const byId = new Map(
update.devices.map((device) => [device.deviceId, device])
)
return current.map((device) => {
const presence = byId.get(device.deviceId)
return presence ? { ...device, ...presence } : device
})
})
})
return () => {
cancelled = true
unsubscribe()
}
}, [clientId])
// No network polling: expire an online state locally if three heartbeats are
// missed. The next socket heartbeat restores it immediately.
React.useEffect(() => {
const expirations = (devices ?? [])
.filter((device) => device.isOnline && device.presenceExpiresAt)
.map((device) => Date.parse(device.presenceExpiresAt!))
.filter(Number.isFinite)
if (expirations.length === 0) return
const nextExpiration = Math.min(...expirations)
const timer = window.setTimeout(() => {
const now = Date.now()
setDevices((current) =>
current?.map((device) =>
device.isOnline &&
device.presenceExpiresAt &&
Date.parse(device.presenceExpiresAt) <= now
? { ...device, isOnline: false, presenceExpiresAt: null }
: device
) ?? current
)
}, Math.max(0, nextExpiration - Date.now()) + 50)
return () => window.clearTimeout(timer)
}, [devices])
const claimedDevices = devices?.filter((device) => device.isClaimed) ?? []
if (claimedDevices.length === 0) return null
const onlineCount = claimedDevices.filter((device) => device.isOnline).length
const allOnline = onlineCount === claimedDevices.length
const someOnline = onlineCount > 0
const statusLabel = allOnline
? claimedDevices.length === 1
? "Device online"
: "All devices online"
: someOnline
? `${onlineCount}/${claimedDevices.length} devices online`
: claimedDevices.length === 1
? "Device offline"
: "All devices offline"
return (
<Tooltip>
<TooltipTrigger
render={
<span className="flex cursor-default items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs font-medium">
<span
className={cn(
"size-2 shrink-0 rounded-full",
allOnline
? "bg-emerald-500"
: someOnline
? "bg-amber-500"
: "bg-destructive"
)}
/>
<span className="hidden sm:inline text-muted-foreground">
{statusLabel}
</span>
</span>
}
/>
<TooltipContent>
<div className="flex flex-col gap-1">
{claimedDevices.map((d, i) => (
<div key={d.deviceId ?? i} className="flex items-center gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
d.isOnline ? "bg-emerald-500" : "bg-destructive"
)}
/>
<span>{d.deviceName ?? d.deviceId ?? "Device"}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)
}
export { DeviceStatusIndicator }
+120
View File
@@ -0,0 +1,120 @@
"use client"
import { FilterIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface FilterSelectConfig {
key: string
label: string
value: string
placeholder: string
options: { label: string; value: string }[]
onChange: (value: string) => void
}
interface FilterBarProps {
searchLabel?: string
searchPlaceholder?: string
searchValue: string
onSearchChange: (value: string) => void
filters?: FilterSelectConfig[]
onFiltersClick?: () => void
resultsShowing?: number
resultsTotal?: number
onClear?: () => void
className?: string
}
function FilterBar({
searchLabel = "Search",
searchPlaceholder = "Search...",
searchValue,
onSearchChange,
filters = [],
onFiltersClick,
resultsShowing,
resultsTotal,
onClear,
className,
}: FilterBarProps) {
return (
<div className={cn("rounded-xl border bg-card p-4", className)}>
<div className="flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<div className="flex min-w-0 flex-col gap-1.5 md:min-w-56 md:flex-[2]">
<Label htmlFor="filter-bar-search">{searchLabel}</Label>
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="filter-bar-search"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="h-9 pl-8"
/>
</div>
</div>
{filters.map((filter) => (
<div key={filter.key} className="flex min-w-0 flex-col gap-1.5 md:min-w-40 md:flex-1">
<Label>{filter.label}</Label>
<Select
items={filter.options}
value={filter.value}
onValueChange={(v) => filter.onChange(v ?? "")}
>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder={filter.placeholder} />
</SelectTrigger>
<SelectContent>
{filter.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
{onFiltersClick && (
<Button variant="outline" size="lg" className="h-9 md:w-auto" onClick={onFiltersClick}>
<FilterIcon />
Filters
</Button>
)}
</div>
{(resultsShowing !== undefined || onClear) && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-sm">
<p className="text-muted-foreground">
Showing <span className="font-medium text-foreground">{resultsShowing}</span> of{" "}
<span className="font-medium text-foreground">{resultsTotal}</span> results
</p>
{onClear && (
<button
type="button"
onClick={onClear}
className="font-medium text-primary hover:underline"
>
Clear Filters
</button>
)}
</div>
)}
</div>
)
}
export { FilterBar }
export type { FilterSelectConfig }
@@ -0,0 +1,154 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { SearchIcon } from "lucide-react"
import { getMembers } from "@/lib/api/customers"
import type { MemberSubscription } from "@/lib/models/customer"
import { useFetch } from "@/lib/use-fetch"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Input } from "@/components/ui/input"
function initialsFor(name: string | null | undefined) {
return (name ?? "?").slice(0, 2).toUpperCase()
}
function normalizePhone(value: string) {
return value.replace(/[^\d]/g, "")
}
interface GlobalCustomerSearchProps {
/** Sandbox demo mode: no backend to query, so the search is disabled. */
demo?: boolean
}
function GlobalCustomerSearch({ demo = false }: GlobalCustomerSearchProps) {
const router = useRouter()
const members = useFetch(demo ? async () => [] as MemberSubscription[] : getMembers)
const containerRef = React.useRef<HTMLDivElement>(null)
const [query, setQuery] = React.useState("")
const [open, setOpen] = React.useState(false)
const [activeIndex, setActiveIndex] = React.useState(0)
React.useEffect(() => {
function onPointerDown(e: PointerEvent) {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [])
// The member list has one row per subscription -- collapse to one entry per customer
// so the same person doesn't show up twice in the suggestions.
const customers = React.useMemo(() => {
const seen = new Map<string, MemberSubscription>()
for (const row of members.data ?? []) {
if (!seen.has(row.fkCustomerId)) seen.set(row.fkCustomerId, row)
}
return Array.from(seen.values())
}, [members.data])
const results = React.useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return []
const qDigits = normalizePhone(q)
return customers
.filter((row) => {
const name = row.customer?.cusName?.toLowerCase() ?? ""
const phone = normalizePhone(row.customer?.primaryPH ?? "")
const secondaryPhone = normalizePhone(row.customer?.secondaryPH ?? "")
return (
name.includes(q) ||
(qDigits.length > 0 && (phone.includes(qDigits) || secondaryPhone.includes(qDigits)))
)
})
.slice(0, 8)
}, [customers, query])
function selectCustomer(customerId: string) {
setOpen(false)
setQuery("")
router.push(`/dashboard/members?customerId=${customerId}`)
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape") {
setOpen(false)
return
}
if (!open || results.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => (i + 1) % results.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => (i - 1 + results.length) % results.length)
} else if (e.key === "Enter") {
e.preventDefault()
const index = Math.min(activeIndex, results.length - 1)
selectCustomer(results[index].fkCustomerId)
}
}
if (demo) return null
const showDropdown = open && query.trim().length > 0
return (
<div ref={containerRef} className="relative w-full max-w-sm">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
setActiveIndex(0)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder="Search members by name or phone..."
className="h-9 rounded-full pl-9"
/>
{showDropdown && (
<div className="absolute top-full left-0 z-50 mt-1.5 w-full overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md">
{results.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">No members found.</p>
) : (
<ul className="max-h-80 overflow-y-auto py-1">
{results.map((row, index) => (
<li key={row.fkCustomerId}>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectCustomer(row.fkCustomerId)}
onMouseEnter={() => setActiveIndex(index)}
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm ${
index === activeIndex ? "bg-muted" : ""
}`}
>
<Avatar className="size-7 shrink-0">
<AvatarFallback className="bg-primary/10 text-[0.65rem] font-semibold text-primary">
{initialsFor(row.customer?.cusName)}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col leading-tight">
<span className="truncate font-medium">{row.customer?.cusName ?? "Unknown"}</span>
<span className="truncate text-xs text-muted-foreground">
{row.customer?.primaryPH ?? "No phone number"}
</span>
</div>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
export { GlobalCustomerSearch }
+25
View File
@@ -0,0 +1,25 @@
import { InfoIcon } from "lucide-react"
import { cn } from "@/lib/utils"
interface InfoBannerProps {
children: React.ReactNode
className?: string
}
function InfoBanner({ children, className }: InfoBannerProps) {
return (
<div
role="status"
className={cn(
"flex items-start gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3.5 py-3 text-sm text-blue-800 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-300",
className
)}
>
<InfoIcon className="mt-0.5 size-4 shrink-0" />
<p className="text-balance">{children}</p>
</div>
)
}
export { InfoBanner }
+358
View File
@@ -0,0 +1,358 @@
import * as React from "react"
interface InvoiceAddress {
name: string
address: string[]
phone: string
email: string
}
interface InvoicePaymentRow {
label: string
amount: number
}
interface InvoicePayment {
subtotal: number
/**
* Empty when the plan is not split into installments.
* If populated, split-payment rows will be shown.
*/
payments: InvoicePaymentRow[]
totalPaid: number
balanceDue: number
}
interface GymInvoiceProps {
invoiceNo: string
invoiceDate: string
dueDate: string
/** Retained for API compatibility; the invoice no longer renders a logo. */
logoSrc?: string
logoAlt?: string
from: InvoiceAddress
billTo: InvoiceAddress
plan: {
gymPlanName: string
type: string
memberName: string
validityPeriod: string
}
payment: InvoicePayment
paymentMethod: string
preparedBy: string
notes: string[]
footerItems: string[]
className?: string
formatAmount: (amount: number) => string
/**
* This ref now captures the full A4 invoice page.
* The PDF download uses this exact node.
*/
bodyRef?: React.Ref<HTMLDivElement>
/**
* Kept for compatibility with existing caller.
* The footer is still inside the same invoice page.
*/
footerRef?: React.Ref<HTMLDivElement>
}
function GymInvoice({
invoiceNo,
invoiceDate,
dueDate,
from,
billTo,
plan,
payment,
paymentMethod,
preparedBy,
notes,
footerItems,
formatAmount,
bodyRef,
footerRef,
className = "",
}: GymInvoiceProps) {
return (
<div className={`mx-auto flex flex-col overflow-hidden bg-[#f4f4f4] px-6 py-6 font-sans ${className}`}>
<div
className="
mx-auto
h-[calc(1123px*var(--invoice-preview-scale))]
w-[calc(794px*var(--invoice-preview-scale))]
[--invoice-preview-scale:0.46]
sm:[--invoice-preview-scale:0.62]
md:[--invoice-preview-scale:1]
"
>
<div
className="
origin-top-left
transform-[scale(var(--invoice-preview-scale))]
"
>
<div
ref={bodyRef}
className="mx-auto flex h-280.75 w-198.5 flex-col overflow-hidden bg-white px-13 pt-10 pb-8 text-[#111111] font-sans"
>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<h1 className="text-[40px] leading-none font-extrabold tracking-[2px] uppercase">
Invoice
</h1>
</div>
{/* Invoice meta */}
<div className="mt-7 grid gap-3">
<MetaRow label="Invoice No." value={invoiceNo} />
<MetaRow label="Invoice Date" value={invoiceDate} />
<MetaRow label="Due Date" value={dueDate} />
</div>
<Divider className="mt-7" />
{/* From / Bill To */}
<div className="mt-8 grid grid-cols-2 gap-16">
<InfoBlock title="From" data={from} />
<InfoBlock title="Bill To" data={billTo} />
</div>
{/* Membership / Service Details */}
<div className="mt-8">
<SectionTitle title="Membership / Service Details" />
<div className="mt-6 border-t-2 border-b border-t-[#111111] border-b-[#d1d1d1]">
<div className="grid grid-cols-[30%_18%_26%_26%] border-b-[1.5px] border-b-[#111111] [&>div:not(:last-child)]:border-r [&>div:not(:last-child)]:border-r-[#d8d8d8]">
<TableHeadCell>Gym Plan Name</TableHeadCell>
<TableHeadCell>Type</TableHeadCell>
<TableHeadCell>Name of Member(s)</TableHeadCell>
<TableHeadCell>Expiry Date / Validity Period</TableHeadCell>
</div>
<div className="grid grid-cols-[30%_18%_26%_26%] [&>div:not(:last-child)]:border-r [&>div:not(:last-child)]:border-r-[#d8d8d8]">
<TableCell>{plan.gymPlanName}</TableCell>
<TableCell>{plan.type}</TableCell>
<TableCell>{plan.memberName}</TableCell>
<TableCell>{plan.validityPeriod}</TableCell>
</div>
</div>
</div>
{/* Notes / Terms + Payment Summary */}
<div className="mt-8 grid grid-cols-[1fr_300px] items-start gap-10">
<div>
<SectionTitle title="Notes / Terms" />
<div className="mt-6 [&>p+p]:mt-4 [&>p:last-child]:border-b-0 [&>p:last-child]:pb-0">
{notes.map((note, i) => (
<p
key={i}
className="m-0 border-b border-b-[#d1d1d1] pb-3 text-[13px] leading-[1.45]"
>
{note}
</p>
))}
</div>
</div>
<PaymentSummary payment={payment} formatAmount={formatAmount} />
</div>
{/* Payment Method / Prepared By */}
<div className="mt-8 grid grid-cols-2 gap-16">
<div>
<SectionTitle title="Payment Method" />
<p className="mt-5 text-[14px]">{paymentMethod || "-"}</p>
</div>
<div>
<SectionTitle title="Prepared By" />
<p className="mt-5 text-[14px]">{preparedBy || "-"}</p>
</div>
</div>
{/* Footer stays inside the same one-page A4 receipt */}
<div ref={footerRef} className="mt-auto bg-white pt-6">
<Divider />
<div className="mt-5 flex flex-wrap items-center justify-center gap-5 text-[13px] text-[#555555]">
{footerItems.map((item, i) => (
<span key={i} className="flex items-center gap-5">
{i > 0 && <span aria-hidden>|</span>}
{item}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
function Divider({ className = "" }: { className?: string }) {
return (
<div
className={`h-1.5 border-t-2 border-b-[3px] border-t-[#111111] border-b-[#8e1821] ${className}`}
/>
)
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[130px_16px_1fr] items-center gap-x-3">
<span className="text-[13px] font-extrabold uppercase">{label}</span>
<span className="text-[13px]">:</span>
<p className="m-0 text-[14px]">{value}</p>
</div>
)
}
function SectionTitle({ title }: { title: string }) {
return (
<div>
<h2 className="m-0 text-[16px] font-extrabold tracking-[0.2px]">
{title}
</h2>
<span className="mt-1 block h-0.75 w-12 bg-[#8e1821]" />
</div>
)
}
function InfoBlock({ title, data }: { title: string; data: InvoiceAddress }) {
return (
<div className="min-w-0">
<SectionTitle title={title} />
<div className="mt-6">
<h3 className="m-0 mb-1.75 text-[14px] font-extrabold">
{data.name}
</h3>
{data.address.map((line, i) => (
<p
key={i}
className="m-0 mb-1.75 wrap-break-word text-[14px] leading-tight"
>
{line}
</p>
))}
<div className="mt-5">
<p className="m-0 mb-1.75 text-[14px] leading-tight">
{data.phone}
</p>
<p className="m-0 mb-1.75 wrap-break-word text-[14px] leading-tight">
{data.email}
</p>
</div>
</div>
</div>
)
}
function TableHeadCell({ children }: { children: React.ReactNode }) {
return (
<div className="box-border flex min-h-14 items-center justify-center px-3 py-3.5 text-center text-[12px] leading-tight font-extrabold">
{children}
</div>
)
}
function TableCell({ children }: { children: React.ReactNode }) {
return (
<div className="box-border flex min-h-13.5 items-center justify-center wrap-break-word px-3 py-3.5 text-center text-[13px] leading-[1.35]">
{children}
</div>
)
}
function PaymentSummary({
payment,
formatAmount,
}: {
payment: InvoicePayment
formatAmount: (amount: number) => string
}) {
const hasBreakdown = payment.payments.length > 0
return (
<div className="border border-[#111111]">
<SummaryRow label="Subtotal" amount={formatAmount(payment.subtotal)} />
{hasBreakdown ? (
<>
{payment.payments.map((row, i) => (
<SummaryRow
key={i}
label={row.label}
amount={formatAmount(row.amount)}
/>
))}
<SummaryRow
label="Total Paid"
amount={formatAmount(payment.totalPaid)}
/>
<SummaryRow
label="Balance Due"
amount={formatAmount(payment.balanceDue)}
highlight
/>
</>
) : (
<SummaryRow
label="Total"
amount={formatAmount(payment.subtotal)}
highlight
/>
)}
</div>
)
}
function SummaryRow({
label,
amount,
highlight = false,
}: {
label: string
amount: string
highlight?: boolean
}) {
return (
<div
className={`grid min-h-12 grid-cols-[1fr_auto] items-center gap-5 border-b border-b-[#d1d1d1] px-5 last:border-b-0 ${highlight
? "border-t-[1.5px] border-t-[#111111] bg-[#f7f7f7]"
: ""
}`}
>
<span
className={`font-extrabold uppercase ${highlight ? "text-[15px] font-black" : "text-[13px]"
}`}
>
{label}
</span>
<strong
className={`font-bold ${highlight ? "text-[15px] font-black" : "text-[13px]"
}`}
>
{amount}
</strong>
</div>
)
}
export { GymInvoice }
export type {
GymInvoiceProps,
InvoiceAddress,
InvoicePayment,
InvoicePaymentRow,
}
@@ -0,0 +1,82 @@
"use client"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import type { MonthlyRevenueStat } from "@/lib/models/stats"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
const chartConfig = {
newMemberCount: {
label: "New members",
color: "var(--primary)",
},
} satisfies ChartConfig
interface MemberTrendChartCardProps {
data: MonthlyRevenueStat[] | null
loading?: boolean
title?: string
}
/** Non-revenue analog of RevenueChartCard, for staff who can't see revenue figures. */
function MemberTrendChartCard({ data, loading, title = "New members" }: MemberTrendChartCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-56 w-full rounded-lg" />
) : !data || data.length === 0 ? (
<div className="flex h-56 items-center justify-center text-sm text-muted-foreground">
No new members recorded yet.
</div>
) : (
<ChartContainer config={chartConfig} className="h-56 w-full">
<LineChart data={data} margin={{ left: 4, right: 4, top: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="monthLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
tick={{ fontSize: 11 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={32}
allowDecimals={false}
tick={{ fontSize: 11 }}
/>
<ChartTooltip
cursor={{ stroke: "var(--border)", strokeWidth: 1 }}
content={<ChartTooltipContent formatter={(value) => String(value)} />}
/>
<Line
type="monotone"
dataKey="newMemberCount"
stroke="var(--color-newMemberCount)"
strokeWidth={2}
dot={{ r: 3, fill: "var(--color-newMemberCount)", strokeWidth: 0 }}
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--background)" }}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}
export { MemberTrendChartCard }
+148
View File
@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { LayoutGridIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { MOBILE_PRIMARY_KEYS, NAV_ITEMS, isNavItemActive, type DashboardNavItem } from "@/lib/nav"
import { useNavItems } from "@/hooks/use-nav-items"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
interface MobileNavProps {
/** Sandbox demo mode: navigate by key with local state instead of route links. */
activeKey?: string
onNavigate?: (key: string) => void
}
function MobileNav({ activeKey, onNavigate }: MobileNavProps) {
const pathname = usePathname()
const [moreOpen, setMoreOpen] = React.useState(false)
const { items: liveItems } = useNavItems(!onNavigate)
const allItems = onNavigate ? NAV_ITEMS : liveItems
const primary = allItems.filter((item) => MOBILE_PRIMARY_KEYS.includes(item.key))
const secondary = allItems.filter((item) => !MOBILE_PRIMARY_KEYS.includes(item.key))
const isActive = (item: DashboardNavItem) =>
onNavigate ? item.key === activeKey : isNavItemActive(item, pathname)
const moreActive = secondary.some(isActive)
return (
<nav className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur supports-backdrop-filter:bg-background/85 md:hidden">
<div className="grid grid-cols-5">
{primary.map((item) => (
<MobileNavButton
key={item.key}
item={item}
active={isActive(item)}
onNavigate={onNavigate}
/>
))}
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
<SheetTrigger
render={
<button
className={cn(
"flex flex-col items-center gap-1 py-2 text-[0.65rem] font-medium",
moreActive ? "text-primary" : "text-muted-foreground"
)}
>
<LayoutGridIcon className="size-5" />
<span>More</span>
</button>
}
/>
<SheetContent side="bottom" className="pb-[calc(env(safe-area-inset-bottom)+1rem)]">
<SheetHeader>
<SheetTitle>All sections</SheetTitle>
</SheetHeader>
<div className="grid grid-cols-3 gap-2 px-4">
{secondary.map((item) => {
const active = isActive(item)
const content = (
<>
<item.icon className="size-5" />
<span className="text-center text-xs leading-tight">{item.label}</span>
</>
)
const itemClass = cn(
"flex flex-col items-center gap-1.5 rounded-xl border p-3",
active
? "border-primary/40 bg-primary/10 text-primary"
: "bg-card text-muted-foreground"
)
return onNavigate ? (
<button
key={item.key}
className={itemClass}
onClick={() => {
setMoreOpen(false)
onNavigate(item.key)
}}
>
{content}
</button>
) : (
<Link
key={item.key}
href={item.href}
className={itemClass}
onClick={() => setMoreOpen(false)}
>
{content}
</Link>
)
})}
</div>
</SheetContent>
</Sheet>
</div>
</nav>
)
}
function MobileNavButton({
item,
active,
onNavigate,
}: {
item: DashboardNavItem
active: boolean
onNavigate?: (key: string) => void
}) {
const className = cn(
"flex flex-col items-center gap-1 py-2 text-[0.65rem] font-medium",
active ? "text-primary" : "text-muted-foreground"
)
const content = (
<>
<item.icon className="size-5" />
<span>{item.shortLabel ?? item.label}</span>
</>
)
if (onNavigate) {
return (
<button className={className} onClick={() => onNavigate(item.key)}>
{content}
</button>
)
}
return (
<Link href={item.href} className={className}>
{content}
</Link>
)
}
export { MobileNav }
+24
View File
@@ -0,0 +1,24 @@
import { cn } from "@/lib/utils"
interface PageHeaderProps {
title: string
description?: string
action?: React.ReactNode
className?: string
}
function PageHeader({ title, description, action, className }: PageHeaderProps) {
return (
<div className={cn("flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", className)}>
<div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
)
}
export { PageHeader }
+57
View File
@@ -0,0 +1,57 @@
import { Skeleton } from "@/components/ui/skeleton"
type PageSkeletonVariant = "stats-table" | "stats-cards" | "chart-list"
interface PageSkeletonProps {
variant?: PageSkeletonVariant
}
function PageSkeleton({ variant = "stats-table" }: PageSkeletonProps) {
return (
<div className="flex flex-col gap-4">
<div className="space-y-2">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-4 w-64 max-w-full" />
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
{variant === "stats-table" && <TableSkeleton />}
{variant === "stats-cards" && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-44 rounded-xl" />
))}
</div>
)}
{variant === "chart-list" && (
<>
<Skeleton className="h-64 rounded-xl" />
<TableSkeleton rows={3} />
</>
)}
</div>
)
}
function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<Skeleton className="h-9 w-full max-w-56" />
<Skeleton className="h-9 w-24" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-lg" />
))}
</div>
)
}
export { PageSkeleton, TableSkeleton }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface PaginationBarProps {
page: number
pageCount: number
pageSize: number
onPageSizeChange: (size: number) => void
rangeStart: number
rangeEnd: number
total: number
onPageChange: (page: number) => void
pageSizeOptions?: number[]
}
function PaginationBar({
page,
pageCount,
pageSize,
onPageSizeChange,
rangeStart,
rangeEnd,
total,
onPageChange,
pageSizeOptions = [10, 25, 50, 100],
}: PaginationBarProps) {
const pages = Array.from({ length: Math.max(pageCount, 1) }, (_, i) => i + 1)
return (
<div className="flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Show</span>
<Select
items={pageSizeOptions.map((size) => ({ label: String(size), value: String(size) }))}
value={String(pageSize)}
onValueChange={(v) => v && onPageSizeChange(Number(v))}
>
<SelectTrigger size="sm" className="w-[4.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span>entries</span>
</div>
<div className="flex items-center gap-3">
<p className="hidden text-sm text-muted-foreground sm:block">
{rangeStart} - {rangeEnd} of {total}
</p>
<Pagination className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
text=""
aria-disabled={page <= 1}
className="pointer-events-auto"
onClick={(e) => {
e.preventDefault()
if (page > 1) onPageChange(page - 1)
}}
href="#"
/>
</PaginationItem>
{pages.map((p) => (
<PaginationItem key={p}>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => {
e.preventDefault()
onPageChange(p)
}}
>
{p}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
text=""
onClick={(e) => {
e.preventDefault()
if (page < pageCount) onPageChange(page + 1)
}}
href="#"
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)
}
export { PaginationBar }
+139
View File
@@ -0,0 +1,139 @@
import { EyeIcon, HeartIcon, PencilIcon, Trash2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { StatusBadge } from "@/components/hexa/status-badge"
interface PostCardAuthor {
name: string
role?: string
avatarUrl?: string
}
interface PostCardProps {
imageUrl?: string
title: string
description: string
tag?: string
author: PostCardAuthor
published: boolean
likes: number
createdAt: string
liked?: boolean
onToggleLike?: () => void
onView?: () => void
onEdit?: () => void
onDelete?: () => void
className?: string
}
function PostCard({
imageUrl,
title,
description,
tag,
author,
published,
likes,
createdAt,
liked,
onToggleLike,
onView,
onEdit,
onDelete,
className,
}: PostCardProps) {
return (
<Card className={cn("gap-0 overflow-hidden p-0", className)}>
{imageUrl && (
<div className="aspect-video w-full overflow-hidden bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt="" className="size-full object-cover" />
</div>
)}
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold">{title}</h3>
<StatusBadge status={published ? "success" : "neutral"}>
{published ? "Published" : "Draft"}
</StatusBadge>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">{description}</p>
{tag && (
<Badge variant="secondary" className="w-fit">
{tag}
</Badge>
)}
<div className="flex items-center justify-between gap-2 border-t pt-3">
<div className="flex min-w-0 items-center gap-2">
<Avatar size="sm">
<AvatarImage src={author.avatarUrl} alt={author.name} />
<AvatarFallback>{author.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="min-w-0 leading-tight">
<p className="truncate text-sm font-medium">{author.name}</p>
{author.role && (
<p className="truncate text-xs text-muted-foreground">{author.role}</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
{onToggleLike ? (
<button
type="button"
aria-label={liked ? "Unlike post" : "Like post"}
className="flex items-center gap-1"
onClick={onToggleLike}
>
<HeartIcon
className={cn(
"size-3.5",
liked ? "fill-rose-500 text-rose-500" : "text-muted-foreground"
)}
/>
{likes}
</button>
) : (
<span className="flex items-center gap-1">
<HeartIcon className="size-3.5 fill-rose-500 text-rose-500" />
{likes}
</span>
)}
<span>{createdAt}</span>
</div>
</div>
{(onView || onEdit || onDelete) && (
<div className="flex items-center justify-end gap-1.5">
{onView && (
<Button variant="outline" size="icon-sm" aria-label="View" onClick={onView}>
<EyeIcon />
</Button>
)}
{onEdit && (
<Button variant="outline" size="icon-sm" aria-label="Edit" onClick={onEdit}>
<PencilIcon />
</Button>
)}
{onDelete && (
<Button variant="destructive" size="icon-sm" aria-label="Delete" onClick={onDelete}>
<Trash2Icon />
</Button>
)}
</div>
)}
</div>
</Card>
)
}
export { PostCard }
export type { PostCardAuthor }
+37
View File
@@ -0,0 +1,37 @@
"use client"
import * as React from "react"
import { RefreshCwIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface RefreshButtonProps {
onRefresh?: () => void | Promise<void>
className?: string
}
function RefreshButton({ onRefresh, className }: RefreshButtonProps) {
const [spinning, setSpinning] = React.useState(false)
async function handleClick() {
if (spinning) return
setSpinning(true)
await onRefresh?.()
window.setTimeout(() => setSpinning(false), 500)
}
return (
<Button
variant="outline"
size="icon"
className={cn("rounded-full", className)}
onClick={handleClick}
aria-label="Refresh"
>
<RefreshCwIcon className={cn(spinning && "animate-spin")} />
</Button>
)
}
export { RefreshButton }
@@ -0,0 +1,91 @@
"use client"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { formatCurrency } from "@/lib/format"
import type { MonthlyRevenueStat } from "@/lib/models/stats"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
const chartConfig = {
revenue: {
label: "Revenue",
color: "var(--primary)",
},
} satisfies ChartConfig
function formatCompactRevenue(value: number) {
if (Math.abs(value) >= 1000) return `Rs. ${(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 })}k`
return `Rs. ${value.toLocaleString()}`
}
interface RevenueChartCardProps {
data: MonthlyRevenueStat[] | null
loading?: boolean
title?: string
}
function RevenueChartCard({ data, loading, title = "Monthly revenue" }: RevenueChartCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium sm:text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-56 w-full rounded-lg" />
) : !data || data.length === 0 ? (
<div className="flex h-56 items-center justify-center text-sm text-muted-foreground">
No revenue recorded yet.
</div>
) : (
<ChartContainer config={chartConfig} className="h-56 w-full">
<LineChart data={data} margin={{ left: 4, right: 4, top: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="monthLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
tick={{ fontSize: 11 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={56}
tick={{ fontSize: 11 }}
tickFormatter={(value) => formatCompactRevenue(Number(value))}
/>
<ChartTooltip
cursor={{ stroke: "var(--border)", strokeWidth: 1 }}
content={
<ChartTooltipContent
formatter={(value) => formatCurrency(Number(value))}
/>
}
/>
<Line
type="monotone"
dataKey="revenue"
stroke="var(--color-revenue)"
strokeWidth={2}
dot={{ r: 3, fill: "var(--color-revenue)", strokeWidth: 0 }}
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--background)" }}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}
export { RevenueChartCard }
+121
View File
@@ -0,0 +1,121 @@
"use client"
import { driver, type Driver, type DriveStep } from "driver.js"
import "driver.js/dist/driver.css"
interface TourStepConfig extends DriveStep {
key: string
}
const WHATSAPP_NUMBER = "94770000000" // placeholder — replace with a real business number before ship
function buildSteps(): TourStepConfig[] {
return [
{
key: "dashboard",
element: '[data-tour="dashboard"]',
popover: {
title: "Your gym at a glance",
description: "This is your dashboard with revenue, members, and check-ins, all in one place.",
side: "bottom",
},
},
{
key: "attendance",
element: '[data-tour="nav-attendance"]',
popover: {
title: "Mark a check-in",
description: "Tap any member to check them in. No door device is required to get started.",
side: "right",
},
},
{
key: "members",
element: '[data-tour="nav-members"]',
popover: {
title: "Member profiles",
description: "Manage all your gym members from one place—track memberships, plans, and activity.",
side: "right",
},
},
{
key: "payments",
element: '[data-tour="nav-payments"]',
popover: {
title: "Track who's due",
description: "See who's due for payment. Payment reminders are sent automatically via WhatsApp",
side: "right",
},
},
{
key: "invoice",
element: '[data-tour="nav-payments"]',
popover: {
title: "Professional invoices",
description: "Receipts are generated automatically—no spreadsheets needed.",
side: "right",
},
},
{
key: "member-app-preview",
element: '[data-tour="member-app-preview"]',
popover: {
title: "Your members get an app too",
description: "Workouts, meal plans, and QR check-ins—all in your members' pockets",
side: "bottom",
},
},
{
key: "finale",
popover: {
title: "Imagine this with your gym's name on it",
description: `We'll call you shortly or reach out now. <a href="https://wa.me/${WHATSAPP_NUMBER}" target="_blank" rel="noopener noreferrer" style="color: var(--primary); font-weight: 600; text-decoration: underline;">Chat with us on WhatsApp</a>`,
},
},
]
}
interface StartSandboxTourOptions {
onStepComplete: (stepKey: string) => void
}
function startSandboxTour({ onStepComplete }: StartSandboxTourOptions): Driver {
const steps = buildSteps()
let lastReportedKey: string | null = null
function reportOnce(stepKey: string) {
if (lastReportedKey === stepKey) return
lastReportedKey = stepKey
onStepComplete(stepKey)
}
const tourDriver = driver({
showProgress: true,
allowClose: true,
steps: steps.map((step) => ({
element: step.element,
popover: {
...step.popover,
onNextClick: () => {
reportOnce(step.key)
tourDriver.moveNext()
},
onDoneClick: () => {
reportOnce(step.key)
tourDriver.destroy()
},
},
})),
onDestroyStarted: () => {
const activeStep = tourDriver.getActiveStep()
const activeKey = steps.find((s) => s.element === activeStep?.element)?.key
if (activeKey) reportOnce(activeKey)
tourDriver.destroy()
},
})
tourDriver.drive()
return tourDriver
}
export { startSandboxTour }
+78
View File
@@ -0,0 +1,78 @@
"use client"
import type { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
interface SelectableCardOption {
value: string
label: string
description?: string
icon?: LucideIcon
}
interface SelectableCardGroupProps {
options: SelectableCardOption[]
value: string
onValueChange: (value: string) => void
className?: string
columns?: 1 | 2 | 3
disabled?: boolean
}
function SelectableCardGroup({
options,
value,
onValueChange,
className,
columns = 2,
disabled = false,
}: SelectableCardGroupProps) {
return (
<RadioGroup
value={value}
onValueChange={(v) => onValueChange(String(v))}
disabled={disabled}
className={cn(
"grid gap-3",
columns === 1 && "grid-cols-1",
columns === 2 && "grid-cols-1 sm:grid-cols-2",
columns === 3 && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
className
)}
>
{options.map((opt) => {
const Icon = opt.icon
return (
<label
key={opt.value}
htmlFor={`selectable-card-${opt.value}`}
className={cn(
"flex items-start gap-3 rounded-xl border bg-card p-4 transition-colors has-data-checked:border-primary has-data-checked:bg-primary/5 has-data-checked:ring-1 has-data-checked:ring-primary/30",
disabled ? "cursor-not-allowed opacity-70" : "cursor-pointer"
)}
>
<RadioGroupItem
id={`selectable-card-${opt.value}`}
value={opt.value}
className="mt-1"
/>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2 font-medium">
{Icon && <Icon className="size-4 text-primary" />}
{opt.label}
</div>
{opt.description && (
<p className="text-sm text-muted-foreground">{opt.description}</p>
)}
</div>
</label>
)
})}
</RadioGroup>
)
}
export { SelectableCardGroup }
export type { SelectableCardOption }
+165
View File
@@ -0,0 +1,165 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import {
BellIcon,
LogOutIcon,
SettingsIcon,
UserIcon,
} from "lucide-react"
import { logout } from "@/lib/api/session"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { GlobalCustomerSearch } from "@/components/hexa/global-customer-search"
import { RefreshButton } from "@/components/hexa/refresh-button"
import { ThemeToggle } from "@/components/hexa/theme-toggle"
import { useClientProfile } from "@/components/hexa/client-profile-context"
interface SiteHeaderProps {
date: Date | null
onDateChange: (date: Date) => void
notificationCount?: number
onRefresh?: () => void
demo?: boolean
onNavigate?: (key: string) => void
}
function SiteHeader({
date,
notificationCount = 0,
onRefresh,
demo = false,
onNavigate,
}: SiteHeaderProps) {
const fullDate = date?.toLocaleDateString("en-LK", { month: "long", day: "numeric", year: "numeric", timeZone: "Asia/Colombo" }) ?? "Today"
const compactDate = date?.toLocaleDateString("en-LK", { month: "short", day: "numeric", timeZone: "Asia/Colombo" }) ?? "Today"
const router = useRouter()
const profile = useClientProfile()
async function handleLogout() {
if (demo) {
router.push("/login")
return
}
await logout()
router.replace("/login")
}
async function navigateToProfile() {
if (onNavigate) {
onNavigate("settings")
return
}
router.push("/dashboard/settings")
}
const displayName = demo ? "Demo Gym" : profile.clientName ?? profile.gymName ?? "My Gym"
const initials =
displayName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join("") || "G"
return (
<header className="sticky top-0 z-30 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 sm:px-4">
<SidebarTrigger className="rounded-full" />
<div className="ml-1 flex h-9 shrink-0 items-center rounded-full border bg-muted/30 px-3 text-sm" aria-label="Today's date">
<span className="hidden sm:inline">{fullDate}</span>
<span className="sm:hidden">{compactDate}</span>
</div>
<div className="flex flex-1 justify-center px-2">
<GlobalCustomerSearch demo={demo} />
</div>
<div className="ml-auto flex items-center gap-2">
<RefreshButton onRefresh={onRefresh} />
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon" className="relative rounded-full">
<BellIcon />
{notificationCount > 0 && (
<Badge className="absolute -top-1.5 -right-1.5 size-5 justify-center rounded-full p-0 text-[0.65rem]">
{notificationCount}
</Badge>
)}
</Button>
}
/>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex-col items-start gap-0.5">
<span className="font-medium">New membership payment received</span>
<span className="text-xs text-muted-foreground">2 minutes ago</span>
</DropdownMenuItem>
<DropdownMenuItem className="flex-col items-start gap-0.5">
<span className="font-medium">Staff member added</span>
<span className="text-xs text-muted-foreground">1 hour ago</span>
</DropdownMenuItem>
<DropdownMenuItem className="flex-col items-start gap-0.5">
<span className="font-medium">Gym plan updated</span>
<span className="text-xs text-muted-foreground">Yesterday</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button aria-label="Profile menu">
<Avatar>
<AvatarFallback className="bg-primary text-primary-foreground">
{initials}
</AvatarFallback>
</Avatar>
</button>
}
/>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>{displayName}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={navigateToProfile}>
<UserIcon />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOutIcon />
{demo ? "Exit demo" : "Log out"}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
export { SiteHeader }
+45
View File
@@ -0,0 +1,45 @@
import { cva, type VariantProps } from "class-variance-authority"
import type { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
const iconVariants = cva("flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4 sm:size-10 sm:rounded-xl sm:[&_svg]:size-5", {
variants: {
tone: {
primary: "bg-primary/10 text-primary",
blue: "bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400",
green: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400",
amber: "bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400",
purple: "bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400",
},
},
defaultVariants: {
tone: "primary",
},
})
interface StatCardProps extends VariantProps<typeof iconVariants> {
icon: LucideIcon
label: string
value: React.ReactNode
hint?: string
className?: string
}
function StatCard({ icon: Icon, label, value, hint, tone, className }: StatCardProps) {
return (
<Card className={cn("flex-row items-center gap-2.5 p-2.5 sm:gap-3 sm:p-3", className)}>
<div className={cn(iconVariants({ tone }))}>
<Icon />
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-xs text-muted-foreground sm:text-sm">{label}</span>
<span className="text-base leading-tight font-semibold tracking-tight sm:text-xl">{value}</span>
{hint && <span className="truncate text-xs text-muted-foreground">{hint}</span>}
</div>
</Card>
)
}
export { StatCard }
+53
View File
@@ -0,0 +1,53 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const statusBadgeVariants = cva(
"inline-flex h-6 w-fit shrink-0 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium whitespace-nowrap",
{
variants: {
status: {
success: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400",
warning: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-400",
danger: "border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-400",
info: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-400",
neutral: "border-border bg-muted text-muted-foreground",
},
},
defaultVariants: {
status: "neutral",
},
}
)
const dotVariants = cva("size-1.5 shrink-0 rounded-full", {
variants: {
status: {
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-red-500",
info: "bg-blue-500",
neutral: "bg-muted-foreground",
},
},
defaultVariants: {
status: "neutral",
},
})
interface StatusBadgeProps
extends React.ComponentProps<"span">,
VariantProps<typeof statusBadgeVariants> {
dot?: boolean
}
function StatusBadge({ className, status, dot = true, children, ...props }: StatusBadgeProps) {
return (
<span className={cn(statusBadgeVariants({ status }), className)} {...props}>
{dot && <span className={cn(dotVariants({ status }))} />}
{children}
</span>
)
}
export { StatusBadge, statusBadgeVariants }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { MoonIcon, SunIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function ThemeToggle({ className }: { className?: string }) {
const { resolvedTheme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time client-mount flag to avoid SSR/client theme mismatch
setMounted(true)
}, [])
return (
<Button
variant="outline"
size="icon"
className={cn("rounded-full", className)}
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
{mounted && resolvedTheme === "dark" ? <SunIcon /> : <MoonIcon />}
</Button>
)
}
export { ThemeToggle }
@@ -0,0 +1,102 @@
"use client"
import * as React from "react"
import { useMutation } from "@/lib/use-mutation"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
interface AddCategoryDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
kind: "workout" | "meal"
onCreate: (name: string, description?: string) => Promise<void>
}
function AddCategoryDialog({ open, onOpenChange, kind, onCreate }: AddCategoryDialogProps) {
const [name, setName] = React.useState("")
const [description, setDescription] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setName("")
setDescription("")
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name.trim()) return
try {
await run(() => onCreate(name.trim(), description.trim() || undefined))
onOpenChange(false)
} catch {
// run() already toasted the real error; stay open so the user can retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>New {kind === "workout" ? "workout" : "meal"} category</DialogTitle>
<DialogDescription>
Group your {kind === "workout" ? "workout" : "meal"} plans by category.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="category-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="category-name"
placeholder={kind === "workout" ? "e.g. Strength, Cardio" : "e.g. Weight loss, Bulking"}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="category-desc">Description</Label>
<Textarea
id="category-desc"
placeholder="Optional short description"
maxLength={200}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || busy}>
{busy && <Spinner className="size-4" />}
Create Category
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { AddCategoryDialog }
@@ -0,0 +1,304 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { createMember, getMembers } from "@/lib/api/customers"
import { addSubscriptionMember, getSubscription } from "@/lib/api/subscriptions"
import { useMediaQuery } from "@/lib/use-media-query"
import { useMutation } from "@/lib/use-mutation"
import type { SubscriptionSummary } from "@/lib/models/subscription"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface AddGroupMembersDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
summary: SubscriptionSummary | null
/** Called after a member is added so the caller can refresh. When a member was actually
* attached (not the demo path), this also carries the freshly refetched subscription so
* the caller can update its own copy (e.g. groupSummary) — without this, a caller that
* reopens the dialog later would show a stale member count. */
onChanged: (freshSummary?: SubscriptionSummary) => void
/** Sandbox demo mode: mutate local state instead of calling the backend. */
demo?: boolean
/** Pre-computed eligible-members list for the "existing member" tab (parent owns the customer roster). */
demoEligibleOptions?: { label: string; value: string }[]
demoAttachExisting?: (subscriptionId: string, customerId: string) => void
demoCreateAndAttach?: (subscriptionId: string, values: typeof EMPTY_NEW) => void
}
const EMPTY_NEW = {
fullname: "",
userName: "",
email: "",
phoneNumber: "",
gender: "",
birthday: "",
}
function AddGroupMembersDialog({
open,
onOpenChange,
summary,
onChanged,
demo = false,
demoEligibleOptions,
demoAttachExisting,
demoCreateAndAttach,
}: AddGroupMembersDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const [existingOptions, setExistingOptions] = React.useState<{ label: string; value: string }[]>([])
const [selectedExisting, setSelectedExisting] = React.useState("")
const [newValues, setNewValues] = React.useState(EMPTY_NEW)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
// Cosmetic only ("Done" vs "Cancel") -- the actual count always comes straight from
// `summary`, which is kept in sync with the server after every add (see afterAdd below).
const [hasAdded, setHasAdded] = React.useState(false)
const memberCount = summary?.members.length ?? 0
const limit = summary?.memberLimit ?? 0
const atLimit = memberCount >= limit
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setSelectedExisting("")
setNewValues(EMPTY_NEW)
}
}
// The dialog now stays open across multiple adds (a Group plan's members are usually added
// one at a time right after creation), so the "existing member" list needs to re-sync
// whenever the live member count changes, not just once when the dialog first opens.
React.useEffect(() => {
if (!open) return
if (demo) {
setExistingOptions(demoEligibleOptions ?? [])
} else {
void loadEligible(summary)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, memberCount, demo, summary])
async function loadEligible(current: SubscriptionSummary | null) {
if (!current) return
try {
const members = await getMembers()
const alreadyIn = new Set(current.members.map((m) => m.customerId))
// Dedupe by customer; exclude those already in this subscription.
const seen = new Set<string>()
const options: { label: string; value: string }[] = []
for (const row of members) {
const id = row.fkCustomerId
if (alreadyIn.has(id) || seen.has(id) || !row.customer) continue
seen.add(id)
options.push({ label: row.customer.cusName ?? row.customer.email ?? "Member", value: id })
}
setExistingOptions(options)
} catch {
// Non-fatal; the create-new tab still works.
}
}
/** Re-fetches the subscription so `summary` (and the caller's own copy of it) reflects
* the member just added, then refreshes the eligible-members list against that truth.
* This is what keeps the "N/limit" count and the "full" gate correct -- a client-side
* counter can't be trusted since the caller may reopen this dialog from stale data. */
async function afterAdd() {
setSelectedExisting("")
setNewValues(EMPTY_NEW)
setHasAdded(true)
if (!summary) return
if (demo) {
onChanged()
return
}
try {
const fresh = await getSubscription(summary.subscriptionId)
onChanged(fresh)
void loadEligible(fresh)
} catch {
// Non-fatal; the count may lag until the dialog is reopened.
onChanged()
}
}
async function attachExisting() {
if (!summary || !selectedExisting || busy) return
if (demo) {
demoAttachExisting?.(summary.subscriptionId, selectedExisting)
toast.success("Member added to the plan")
setSelectedExisting("")
onChanged()
return
}
try {
await run(() => addSubscriptionMember(summary.subscriptionId, selectedExisting))
toast.success("Member added to the plan")
// Stay open instead of closing -- a Group plan usually needs several members added back
// to back, and the "plan is full" state below takes over once the limit is reached.
setSelectedExisting("")
onChanged()
} catch {
// run() already toasted the real error.
}
}
async function createAndAttach(e: React.FormEvent) {
e.preventDefault()
if (!summary || busy) return
const v = newValues
if (!v.fullname.trim() || !v.userName.trim() || !v.email.trim() || !v.phoneNumber.trim() || !v.gender) return
if (demo) {
demoCreateAndAttach?.(summary.subscriptionId, v)
toast.success("Member created and added to the plan")
setNewValues(EMPTY_NEW)
onChanged()
return
}
try {
await run(async () => {
const created = await createMember({
fullname: v.fullname.trim(),
userName: v.userName.trim(),
email: v.email.trim(),
mobileNumber: v.phoneNumber.trim(),
gender: v.gender,
birthday: v.birthday,
})
await addSubscriptionMember(summary.subscriptionId, created.customer.cusId)
})
toast.success("Member created and added to the plan")
// Stay open instead of closing -- same reasoning as attachExisting above.
setNewValues(EMPTY_NEW)
onChanged()
} catch {
// run() already toasted the real error.
}
}
function setNew<K extends keyof typeof EMPTY_NEW>(key: K, value: string) {
setNewValues((n) => ({ ...n, [key]: value }))
}
const Footer = isDesktop ? DialogFooter : SheetFooter
const footerClassName = isDesktop ? "-mx-4 -mt-3 -mb-4" : "flex-col-reverse"
const buttonWidthClassName = isDesktop ? undefined : "w-full"
const body = atLimit ? (
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-center text-sm text-muted-foreground">
This plan is full ({limit} members).
</p>
<Button type="button" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>Done</Button>
</div>
) : (
<Tabs defaultValue="existing">
<TabsList className="w-full">
<TabsTrigger value="existing" className="flex-1">Existing member</TabsTrigger>
<TabsTrigger value="new" className="flex-1">New member</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>Select a member</Label>
<Select items={existingOptions} value={selectedExisting || null} onValueChange={(v) => setSelectedExisting(v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder={existingOptions.length ? "Choose a member" : "No eligible members"} /></SelectTrigger>
<SelectContent>{existingOptions.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>{hasAdded ? "Done" : "Cancel"}</Button>
<Button type="button" className={buttonWidthClassName} onClick={attachExisting} disabled={!selectedExisting || busy}>{busy && <Spinner className="size-4" />}Add to plan</Button>
</Footer>
</TabsContent>
<TabsContent value="new" className="mt-3">
<form onSubmit={createAndAttach} className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-fullname">Full Name <span className="text-destructive">*</span></Label><Input id="gm-fullname" value={newValues.fullname} onChange={(e) => setNew("fullname", e.target.value)} required /></div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-username">Username <span className="text-destructive">*</span></Label><Input id="gm-username" value={newValues.userName} onChange={(e) => setNew("userName", e.target.value)} required /></div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-phone">Phone <span className="text-destructive">*</span></Label><Input id="gm-phone" type="tel" value={newValues.phoneNumber} onChange={(e) => setNew("phoneNumber", e.target.value)} required /></div>
</div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-email">Email <span className="text-destructive">*</span></Label><Input id="gm-email" type="email" value={newValues.email} onChange={(e) => setNew("email", e.target.value)} required /></div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Gender <span className="text-destructive">*</span></Label>
<Select items={[{ label: "Male", value: "Male" }, { label: "Female", value: "Female" }, { label: "Other", value: "Other" }]} value={newValues.gender || null} onValueChange={(v) => setNew("gender", v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent><SelectItem value="Male">Male</SelectItem><SelectItem value="Female">Female</SelectItem><SelectItem value="Other">Other</SelectItem></SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-birthday">Birthday</Label><Input id="gm-birthday" type="date" value={newValues.birthday} onChange={(e) => setNew("birthday", e.target.value)} max={new Date().toISOString().slice(0, 10)} /></div>
</div>
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>{hasAdded ? "Done" : "Cancel"}</Button>
<Button type="submit" className={buttonWidthClassName} disabled={busy}>{busy && <Spinner className="size-4" />}Create & add</Button>
</Footer>
</form>
</TabsContent>
</Tabs>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>Add group members</DialogTitle>
<DialogDescription>
{summary?.planName} · {memberCount}/{limit} members
</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<SheetContent
side="bottom"
className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>Add group members</SheetTitle>
<SheetDescription>
{summary?.planName} · {memberCount}/{limit} members
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-2">{body}</div>
</SheetContent>
</Sheet>
)
}
export { AddGroupMembersDialog }
@@ -0,0 +1,456 @@
"use client"
import * as React from "react"
import { useMutation } from "@/lib/use-mutation"
import type { Place } from "@/lib/models/place"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
/** UI-level scope; "Couple"/"Family" both map to a Group plan with a member limit. */
type ScopeChoice = "Individual" | "Couple" | "Family"
type DurationMode = "Rolling" | "FixedRange"
interface GymPlanFormValues {
name: string
description: string
scope: ScopeChoice
/** Member limit for Family plans (Couple is fixed at 2). */
memberLimit: string
durationMode: DurationMode
/** Duration in days (stringified) — Rolling only. */
duration: string
/** ISO date strings — FixedRange only. */
validFrom: string
validTo: string
price: string
hasOnboardingFee: boolean
onboardingFeeAmount: string
allowSplit: boolean
installmentCount: string
/** Places this plan grants access to. Empty = unrestricted (every active device). */
placeIds: string[]
}
const EMPTY_VALUES: GymPlanFormValues = {
name: "",
description: "",
scope: "Individual",
memberLimit: "3",
durationMode: "Rolling",
duration: "",
validFrom: "",
validTo: "",
price: "",
hasOnboardingFee: false,
onboardingFeeAmount: "",
allowSplit: false,
installmentCount: "",
placeIds: [],
}
const DURATION_OPTIONS = [
{ label: "1 month (30 days)", value: "30" },
{ label: "3 months (90 days)", value: "90" },
{ label: "6 months (180 days)", value: "180" },
{ label: "12 months (365 days)", value: "365" },
{ label: "Custom…", value: "custom" },
]
const SCOPE_OPTIONS: { label: string; value: ScopeChoice }[] = [
{ label: "Individual", value: "Individual" },
{ label: "Couple (2 members)", value: "Couple" },
{ label: "Family (group)", value: "Family" },
]
interface AddGymPlanDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: GymPlanFormValues) => Promise<void> | void
mode?: "create" | "edit"
initialValues?: Partial<GymPlanFormValues>
/** Active places to choose from. Empty selection means "unrestricted". */
places?: Place[]
}
function AddGymPlanDialog({
open,
onOpenChange,
onSubmit,
mode = "create",
initialValues,
places = [],
}: AddGymPlanDialogProps) {
const [values, setValues] = React.useState<GymPlanFormValues>(EMPTY_VALUES)
const [customDuration, setCustomDuration] = React.useState(false)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
const merged = { ...EMPTY_VALUES, ...initialValues }
setValues(merged)
// A duration that isn't one of the presets means it was entered as custom.
setCustomDuration(
!!merged.duration && !DURATION_OPTIONS.some((o) => o.value === merged.duration)
)
}
}
function set<K extends keyof GymPlanFormValues>(key: K, value: GymPlanFormValues[K]) {
setValues((v) => ({ ...v, [key]: value }))
}
const isRolling = values.durationMode === "Rolling"
const durationValid = isRolling
? !!values.duration && Number(values.duration) >= 1
: !!values.validFrom && !!values.validTo && values.validTo > values.validFrom
const feeValid = !values.hasOnboardingFee || Number(values.onboardingFeeAmount) > 0
const canSubmit = !!values.name.trim() && !!values.price && durationValid && feeValid && !busy
// Installment preview: "Rs. 6,000 → 6 × Rs. 1,000".
const price = Number(values.price) || 0
const count = Math.max(1, Number(values.installmentCount) || 0)
const perInstallment = count > 0 ? Math.round((price / count) * 100) / 100 : 0
const onboardingFee = values.hasOnboardingFee ? Number(values.onboardingFeeAmount) || 0 : 0
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() => Promise.resolve(onSubmit(values)))
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{mode === "create" ? "Add Gym Plan" : "Edit Gym Plan"}</DialogTitle>
<DialogDescription>
{mode === "create"
? "Create a new membership plan for your gym."
: "Update this membership plan."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-name">
Plan Name <span className="text-destructive">*</span>
</Label>
<Input
id="plan-name"
placeholder="Enter plan name"
value={values.name}
onChange={(e) => set("name", e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-description">Description</Label>
<Textarea
id="plan-description"
placeholder="Short description of this plan"
maxLength={300}
value={values.description}
onChange={(e) => set("description", e.target.value)}
/>
</div>
{/* Scope */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Plan Type</Label>
<Select
items={SCOPE_OPTIONS}
value={values.scope}
onValueChange={(v) => set("scope", (v as ScopeChoice) ?? "Individual")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{values.scope === "Family" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-member-limit">
Max members <span className="text-destructive">*</span>
</Label>
<Input
id="plan-member-limit"
type="number"
min={2}
value={values.memberLimit}
onChange={(e) => set("memberLimit", e.target.value)}
/>
</div>
)}
</div>
{/* Duration */}
<div className="flex flex-col gap-1.5">
<Label>Duration</Label>
<Select
items={[
{ label: "Rolling (from join date)", value: "Rolling" },
{ label: "Fixed date range", value: "FixedRange" },
]}
value={values.durationMode}
onValueChange={(v) => set("durationMode", (v as DurationMode) ?? "Rolling")}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Rolling">Rolling (from join date)</SelectItem>
<SelectItem value="FixedRange">Fixed date range</SelectItem>
</SelectContent>
</Select>
</div>
{isRolling ? (
<div className="flex flex-col gap-1.5">
<Label>
Length <span className="text-destructive">*</span>
</Label>
<Select
items={DURATION_OPTIONS}
value={customDuration ? "custom" : values.duration || null}
onValueChange={(v) => {
if (v === "custom") {
setCustomDuration(true)
set("duration", "")
} else {
setCustomDuration(false)
set("duration", v ?? "")
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select length" />
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{customDuration && (
<Input
className="mt-1.5"
type="number"
min={1}
placeholder="Number of days"
value={values.duration}
onChange={(e) => set("duration", e.target.value)}
/>
)}
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-valid-from">
Valid from <span className="text-destructive">*</span>
</Label>
<Input
id="plan-valid-from"
type="date"
value={values.validFrom}
onChange={(e) => set("validFrom", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-valid-to">
Valid to <span className="text-destructive">*</span>
</Label>
<Input
id="plan-valid-to"
type="date"
value={values.validTo}
onChange={(e) => set("validTo", e.target.value)}
/>
</div>
</div>
)}
{/* Places */}
<div className="flex flex-col gap-1.5">
<Label>Places</Label>
<p className="text-xs text-muted-foreground">
Which access zones this plan reaches. Leave all unchecked for unrestricted
access (every terminal).
</p>
{places.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No places set up yet — this plan will be unrestricted.
</p>
) : (
<div className="flex flex-col gap-2 rounded-lg border p-3">
{places.map((place) => {
const checked = values.placeIds.includes(place.placeId)
return (
<label
key={place.placeId}
className="flex items-center gap-2 text-sm"
>
<Checkbox
checked={checked}
onCheckedChange={(isChecked) =>
set(
"placeIds",
isChecked
? [...values.placeIds, place.placeId]
: values.placeIds.filter((id) => id !== place.placeId)
)
}
/>
{place.name}
</label>
)
})}
</div>
)}
</div>
{/* Price */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="plan-price">
Price (LKR) <span className="text-destructive">*</span>
</Label>
<Input
id="plan-price"
type="number"
min={0}
placeholder="Enter total price"
value={values.price}
onChange={(e) => set("price", e.target.value)}
required
/>
</div>
{/* One-time onboarding fee */}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="leading-tight">
<Label htmlFor="plan-onboarding-fe">Charge an onboarding fee</Label>
<p className="text-xs text-muted-foreground">
Added once when a new membership starts.
</p>
</div>
<Switch
id="plan-onboarding-fe"
checked={values.hasOnboardingFee}
onCheckedChange={(checked) => set("hasOnboardingFee", checked)}
/>
</div>
{values.hasOnboardingFee && (
<div className="flex flex-col gap-1.5 border-t pt-2">
<Label htmlFor="plan-onboarding-fe-amount">
Onboarding fee (LKR) <span className="text-destructive">*</span>
</Label>
<Input
id="plan-onboarding-fe-amount"
type="number"
min={0.01}
step={0.01}
placeholder="Enter one-time fee"
value={values.onboardingFeeAmount}
onChange={(e) => set("onboardingFeeAmount", e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Initial invoice total: Rs. {(price + onboardingFee).toLocaleString()}
</p>
</div>
)}
</div>
{/* Split payments */}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="leading-tight">
<Label htmlFor="plan-split">Allow split payments</Label>
<p className="text-xs text-muted-foreground">
Members pay in equal installments until settled.
</p>
</div>
<Switch
id="plan-split"
checked={values.allowSplit}
onCheckedChange={(checked) => set("allowSplit", checked)}
/>
</div>
{values.allowSplit && (
<div className="flex flex-col gap-1.5 border-t pt-2">
<Label htmlFor="plan-installments">Number of installments</Label>
<Input
id="plan-installments"
type="number"
min={2}
placeholder="e.g. 6"
value={values.installmentCount}
onChange={(e) => set("installmentCount", e.target.value)}
/>
{price > 0 && count >= 2 && (
<p className="text-xs text-muted-foreground">
Rs. {price.toLocaleString()} → {count} × Rs. {perInstallment.toLocaleString()}
</p>
)}
</div>
)}
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{mode === "create" ? "Add Gym Plan" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { AddGymPlanDialog }
export type { GymPlanFormValues }
@@ -0,0 +1,398 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { assignCustomerPlan } from "@/lib/api/customers"
import { addSubscriptionMember } from "@/lib/api/subscriptions"
import { useMediaQuery } from "@/lib/use-media-query"
import { runMutation } from "@/lib/use-mutation"
import { clampDiscountValue } from "@/lib/validation"
import type { GymPlan } from "@/lib/models/gym-plan"
import type { MemberSubscription } from "@/lib/models/customer"
import { SelectableCardGroup } from "@/components/hexa/selectable-card"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export interface GroupPlanOption {
subscriptionId: string
label: string
}
const DISCOUNT_TYPE_ITEMS = [
{ label: "Percentage", value: "Percentage" },
{ label: "Flat amount", value: "Flat" },
]
/** yyyy-mm-dd in local time, matching what a `type="date"` input reads/writes. */
function toIsoDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
function addDays(date: Date, days: number): Date {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
function formatDisplayDate(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
}
interface AssignPlanDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
member: MemberSubscription | null
plans: GymPlan[]
/** Existing Group-scope subscriptions for this gym that still have room. */
groupOptions: GroupPlanOption[]
/** Fired after a successful assignment so the caller can refresh and, for a new Group
* plan, jump straight to adding the rest of the household. */
onAssigned: (result: { subscriptionId?: string | null; startedNewGroupPlan: boolean }) => void
/** Sandbox demo mode: mutate local state via these instead of calling the backend. */
demo?: boolean
demoAssignNewPlan?: (customerId: string, planId: string) => void
demoJoinGroupPlan?: (subscriptionId: string, customerId: string) => void
}
function AssignPlanDialog({
open,
onOpenChange,
member,
plans,
groupOptions,
onAssigned,
demo = false,
demoAssignNewPlan,
demoJoinGroupPlan,
}: AssignPlanDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const [selectedPlan, setSelectedPlan] = React.useState("")
const [selectedGroup, setSelectedGroup] = React.useState("")
const [expiryMode, setExpiryMode] = React.useState<"auto" | "custom">("auto")
const [customExpiryDate, setCustomExpiryDate] = React.useState("")
const [chargePayment, setChargePayment] = React.useState(true)
const [giveDiscount, setGiveDiscount] = React.useState(false)
const [discountType, setDiscountType] = React.useState<"Percentage" | "Flat">("Percentage")
const [discountValue, setDiscountValue] = React.useState("")
const [busy, setBusy] = React.useState(false)
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setSelectedPlan("")
setSelectedGroup("")
setExpiryMode("auto")
setCustomExpiryDate("")
setChargePayment(true)
setGiveDiscount(false)
setDiscountType("Percentage")
setDiscountValue("")
setBusy(false)
}
}
const planItems = plans.map((p) => ({
label: `${p.planName} — Rs. ${p.planPrice.toLocaleString()}`,
value: p.customerPlanId,
}))
const selectedPlanDetails = plans.find((p) => p.customerPlanId === selectedPlan) ?? null
const isFixedRange = selectedPlanDetails?.durationMode === "FixedRange"
// Fixed-range plans always expire on the plan's own end date; rolling plans default to
// "starts today" so the member gets the full duration, but the admin can override it.
const autoExpiryIso = selectedPlanDetails
? isFixedRange
? (selectedPlanDetails.validTo ?? null)
: toIsoDate(addDays(new Date(), selectedPlanDetails.durationInDays))
: null
function selectPlan(id: string) {
setSelectedPlan(id)
setExpiryMode("auto")
setCustomExpiryDate("")
}
async function assignNewPlan() {
if (!member || !selectedPlan || busy) return
const plan = plans.find((p) => p.customerPlanId === selectedPlan)
if (!plan) return
// The API always requires an explicit expiry date on this endpoint — never send it
// blank/undefined, or the assignment fails server-side.
let expiryDate: string | null
if (plan.durationMode === "FixedRange") {
expiryDate = plan.validTo ?? null
if (!expiryDate) {
toast.error("This plan is missing its end date — fix it from Plans first.")
return
}
} else {
expiryDate =
expiryMode === "custom" && customExpiryDate
? customExpiryDate
: toIsoDate(addDays(new Date(), plan.durationInDays))
}
setBusy(true)
try {
if (demo) {
demoAssignNewPlan?.(member.fkCustomerId, selectedPlan)
toast.success("Plan assigned")
onOpenChange(false)
return
}
const result = await runMutation(() =>
assignCustomerPlan(member.fkCustomerId, {
customerPlanId: selectedPlan,
expiryDate,
chargePayment,
discountType: giveDiscount && discountValue ? discountType : undefined,
discountValue: giveDiscount && discountValue ? Number(discountValue) : undefined,
})
)
toast.success("Plan assigned")
onAssigned({ subscriptionId: result.subscriptionId, startedNewGroupPlan: plan.planScope === "Group" })
onOpenChange(false)
} catch {
// runMutation() already toasted the real error.
} finally {
setBusy(false)
}
}
async function joinGroupPlan() {
if (!member || !selectedGroup || busy) return
setBusy(true)
try {
if (demo) {
demoJoinGroupPlan?.(selectedGroup, member.fkCustomerId)
toast.success("Added to the group plan")
onOpenChange(false)
return
}
await runMutation(() => addSubscriptionMember(selectedGroup, member.fkCustomerId))
toast.success("Added to the group plan")
onAssigned({ subscriptionId: selectedGroup, startedNewGroupPlan: false })
onOpenChange(false)
} catch {
// runMutation() already toasted the real error.
} finally {
setBusy(false)
}
}
const Footer = isDesktop ? DialogFooter : SheetFooter
const footerClassName = isDesktop ? "-mx-4 -mt-3 -mb-4" : "flex-col-reverse"
const buttonWidthClassName = isDesktop ? undefined : "w-full"
const body = (
<Tabs defaultValue="new">
<TabsList className="w-full">
<TabsTrigger value="new" className="flex-1">New plan</TabsTrigger>
<TabsTrigger value="join" className="flex-1">Join group plan</TabsTrigger>
</TabsList>
<TabsContent value="new" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>Membership Plan</Label>
<Select items={planItems} value={selectedPlan || null} onValueChange={(v) => selectPlan(v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder="Select a plan" /></SelectTrigger>
<SelectContent>{planItems.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Starts a new subscription with {member?.customer?.cusName ?? "this member"} as the primary member.
</p>
</div>
{selectedPlanDetails && isFixedRange && (
<p className="text-xs text-muted-foreground">
Fixed-term plan — runs through{" "}
{autoExpiryIso ? formatDisplayDate(autoExpiryIso) : "the plan's end date"}.
</p>
)}
{selectedPlanDetails && !isFixedRange && (
<div className="flex flex-col gap-1.5">
<Label>Expiry date</Label>
<SelectableCardGroup
columns={2}
value={expiryMode}
onValueChange={(v) => setExpiryMode(v as "auto" | "custom")}
options={[
{
value: "auto",
label: "Starts now",
description: autoExpiryIso
? `Ends ${formatDisplayDate(autoExpiryIso)} (${selectedPlanDetails.durationInDays}-day plan)`
: "Uses the plan's duration",
},
{ value: "custom", label: "Pick a date", description: "Choose a specific expiry date" },
]}
/>
{expiryMode === "custom" && (
<Input
type="date"
value={customExpiryDate || autoExpiryIso || ""}
min={toIsoDate(new Date())}
onChange={(e) => setCustomExpiryDate(e.target.value)}
/>
)}
</div>
)}
{selectedPlanDetails && (
<>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="leading-tight">
<Label htmlFor="assign-charge-payment">Charge Payment</Label>
<p className="text-xs text-muted-foreground">
{chargePayment
? "The invoice will bill the plan price as usual."
: "The invoice will be created for Rs. 0.00 and marked Paid automatically — no payment link will be sent."}
</p>
</div>
<Switch id="assign-charge-payment" checked={chargePayment} onCheckedChange={setChargePayment} />
</div>
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="leading-tight">
<Label htmlFor="assign-give-discount">Give Discount</Label>
<p className="text-xs text-muted-foreground">
Optionally reduce the plan price for this member.
</p>
</div>
<Switch id="assign-give-discount" checked={giveDiscount} onCheckedChange={setGiveDiscount} />
</div>
{giveDiscount && (
<div className="flex gap-2">
<Select
items={DISCOUNT_TYPE_ITEMS}
value={discountType}
onValueChange={(v) => {
const nextType = (v as "Percentage" | "Flat") ?? "Percentage"
setDiscountType(nextType)
setDiscountValue((prev) => clampDiscountValue(prev, nextType, selectedPlanDetails?.planPrice))
}}
>
<SelectTrigger className="w-36 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DISCOUNT_TYPE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min={0}
max={discountType === "Percentage" ? 100 : selectedPlanDetails?.planPrice}
placeholder={discountType === "Percentage" ? "e.g. 10 (%)" : "e.g. 500 (Rs.)"}
value={discountValue}
onChange={(e) =>
setDiscountValue(clampDiscountValue(e.target.value, discountType, selectedPlanDetails?.planPrice))
}
/>
</div>
)}
</div>
</>
)}
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
type="button"
className={buttonWidthClassName}
onClick={assignNewPlan}
disabled={!selectedPlan || busy || (expiryMode === "custom" && !customExpiryDate)}
>
{busy && <Spinner className="size-4" />}Assign plan
</Button>
</Footer>
</TabsContent>
<TabsContent value="join" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>Existing group plan</Label>
<Select items={groupOptions.map((g) => ({ label: g.label, value: g.subscriptionId }))} value={selectedGroup || null} onValueChange={(v) => setSelectedGroup(v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder={groupOptions.length ? "Choose a group plan" : "No group plans with room"} /></SelectTrigger>
<SelectContent>{groupOptions.map((opt) => <SelectItem key={opt.subscriptionId} value={opt.subscriptionId}>{opt.label}</SelectItem>)}</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Adds {member?.customer?.cusName ?? "this member"} as a member of an existing couple/family plan — no separate charge.
</p>
</div>
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" className={buttonWidthClassName} onClick={joinGroupPlan} disabled={!selectedGroup || busy}>{busy && <Spinner className="size-4" />}Add to plan</Button>
</Footer>
</TabsContent>
</Tabs>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign a plan</DialogTitle>
<DialogDescription>{member?.customer?.cusName ?? "This member"} doesn&apos;t have a plan yet.</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<SheetContent side="bottom" className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]">
<SheetHeader>
<SheetTitle>Assign a plan</SheetTitle>
<SheetDescription>{member?.customer?.cusName ?? "This member"} doesn&apos;t have a plan yet.</SheetDescription>
</SheetHeader>
<div className="px-4 pb-2">{body}</div>
</SheetContent>
</Sheet>
)
}
export { AssignPlanDialog }
@@ -0,0 +1,226 @@
"use client"
import * as React from "react"
import { CalendarCheckIcon, CalendarIcon, TrendingUpIcon, UsersIcon } from "lucide-react"
import {
getAllAttendance,
getAttendanceCounts,
getTodayAttendance,
} from "@/lib/api/attendance"
import {
DEMO_ATTENDANCE_COUNTS,
DEMO_ATTENDANCE_HISTORY,
DEMO_TODAY_ATTENDANCE,
} from "@/lib/demo/mock-data"
import { formatDate, formatTime } from "@/lib/format"
import { sortByNewest } from "@/lib/validation"
import type { AttendanceRecord } from "@/lib/models/attendance"
import { subscribeAccessEvents } from "@/lib/realtime/access-events"
import { useFetch } from "@/lib/use-fetch"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CheckinList } from "@/components/hexa/checkin-list"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { PageHeader } from "@/components/hexa/page-header"
import { StatCard } from "@/components/hexa/stat-card"
import { StatusBadge } from "@/components/hexa/status-badge"
function sameDay(iso: string, day: Date) {
const d = new Date(iso)
return (
d.getFullYear() === day.getFullYear() &&
d.getMonth() === day.getMonth() &&
d.getDate() === day.getDate()
)
}
function personName(record: AttendanceRecord) {
return (
record.customer?.cusName ??
record.accessProfile?.displayName ??
record.user?.userName ??
"Unknown"
)
}
interface AttendanceViewProps {
demo?: boolean
}
function AttendanceView({ demo = false }: AttendanceViewProps) {
const today = useFetch(demo ? async () => DEMO_TODAY_ATTENDANCE : getTodayAttendance)
const all = useFetch(demo ? async () => DEMO_ATTENDANCE_HISTORY : getAllAttendance)
const counts = useFetch(demo ? async () => DEMO_ATTENDANCE_COUNTS : () => getAttendanceCounts())
const reloadToday = today.reload
const reloadAll = all.reload
const reloadCounts = counts.reload
const [historyDay, setHistoryDay] = React.useState<Date | undefined>(undefined)
React.useEffect(() => {
if (demo) return
const reload = () => {
reloadToday()
reloadAll()
reloadCounts()
}
const unsubscribe = subscribeAccessEvents((event) => {
if (event.type === "attendance") reload()
})
const timer = window.setInterval(reload, 15_000)
return () => {
unsubscribe()
window.clearInterval(timer)
}
}, [demo, reloadAll, reloadCounts, reloadToday])
const records = sortByNewest(all.data ?? [], (r) => r.eventTime)
const historyRows = historyDay
? records.filter((r) => sameDay(r.eventTime, historyDay))
: records
const monthTotal = (counts.data ?? []).reduce((sum, c) => sum + c.attendanceCount, 0)
const peopleToday = today.data?.length ?? 0
const currentlyIn = (today.data ?? []).filter(
(entry) => !entry.eventType?.toLowerCase().includes("out")
).length
const columns: DataTableColumn<AttendanceRecord>[] = [
{
key: "member",
header: "Person",
cell: (row) => <span className="font-medium">{personName(row)}</span>,
},
{
key: "date",
header: "Date",
cell: (row) => <span className="text-muted-foreground">{formatDate(row.eventTime)}</span>,
},
{
key: "time",
header: "Time",
cell: (row) => <span className="text-muted-foreground">{formatTime(row.eventTime)}</span>,
},
{
key: "type",
header: "Event",
cell: (row) => (
<StatusBadge status={row.eventType?.toLowerCase().includes("out") ? "neutral" : "success"}>
{row.eventType || "IN"}
</StatusBadge>
),
},
]
return (
<div className="flex flex-col gap-4">
<PageHeader title="Attendance" description="Latest attendance state for each person" />
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
icon={CalendarCheckIcon}
label="People Today"
value={today.loading ? "…" : peopleToday}
hint="Latest record per person"
/>
<StatCard
icon={UsersIcon}
tone="blue"
label="Currently In"
value={today.loading ? "…" : currentlyIn}
hint="Based on latest event"
/>
<StatCard
icon={TrendingUpIcon}
tone="green"
label="This Month"
value={counts.loading ? "…" : monthTotal}
hint="Total check-ins"
/>
<StatCard
icon={CalendarIcon}
tone="purple"
label="People Tracked"
value={all.loading ? "…" : records.length}
hint="Latest record per person"
/>
</div>
<Tabs defaultValue="today">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="today" className="flex-1 sm:flex-none">Today</TabsTrigger>
<TabsTrigger value="history" className="flex-1 sm:flex-none">History</TabsTrigger>
</TabsList>
<TabsContent value="today" className="mt-3">
<CheckinList
entries={
today.data?.map((t) => ({ name: t.customerName, time: t.eventTime })) ?? null
}
loading={today.loading}
/>
</TabsContent>
<TabsContent value="history" className="mt-3 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="size-4" />
{historyDay ? formatDate(historyDay.toISOString()) : "All dates"}
</Button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode="single"
selected={historyDay}
onSelect={(d) => setHistoryDay(d ?? undefined)}
/>
</PopoverContent>
</Popover>
{historyDay && (
<Button variant="ghost" size="sm" onClick={() => setHistoryDay(undefined)}>
Clear
</Button>
)}
</div>
<DataTable
columns={columns}
rows={historyRows.slice(0, 100)}
rowKey={(row) => row.attendanceId}
emptyMessage={
all.loading
? "Loading attendance records…"
: historyDay
? "No check-ins on this date."
: "No attendance records yet."
}
renderMobileCard={(row) => (
<div className="flex items-center justify-between gap-2">
<div className="leading-tight">
<p className="font-medium">{personName(row)}</p>
<p className="text-xs text-muted-foreground">
{formatDate(row.eventTime)} · {formatTime(row.eventTime)}
</p>
</div>
<StatusBadge
status={row.eventType?.toLowerCase().includes("out") ? "neutral" : "success"}
>
{row.eventType || "IN"}
</StatusBadge>
</div>
)}
/>
</TabsContent>
</Tabs>
</div>
)
}
export { AttendanceView }
@@ -0,0 +1,474 @@
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, PlusIcon, Trash2Icon, WalletIcon } from "lucide-react"
import { toast } from "sonner"
import {
cancelBooking,
checkInBooking,
createGuestBooking,
getSessionBookings,
markGuestBookingPaid,
} from "@/lib/api/bookings"
import { getFacilitySessions } from "@/lib/api/facilities"
import type { Booking } from "@/lib/models/booking"
import type { Facility, FacilitySession } from "@/lib/models/facility"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { InfoBanner } from "@/components/hexa/info-banner"
import { StatusBadge } from "@/components/hexa/status-badge"
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
})
}
function bookingStatusTone(status: string) {
if (status === "Cancelled") return "danger" as const
if (status === "CheckedIn") return "success" as const
return "info" as const
}
function paymentStatusTone(status: string) {
if (status === "Paid" || status === "NotRequired") return "success" as const
if (status === "Unpaid") return "warning" as const
return "neutral" as const
}
interface BookingRosterTabProps {
facilities: Facility[]
}
function BookingRosterTab({ facilities }: BookingRosterTabProps) {
const sessionsFetch = useFetch(() => getFacilitySessions({}))
const sessions = (sessionsFetch.data ?? []).filter((s) => !s.isCancelled)
const [sessionId, setSessionId] = React.useState("")
React.useEffect(() => {
if (!sessionId && sessions.length > 0) setSessionId(sessions[0].facilitySessionId)
// eslint-disable-next-line react-hooks/exhaustive-deps -- only auto-select once sessions load
}, [sessions.length])
const bookingsFetch = useFetch(() => (sessionId ? getSessionBookings(sessionId) : Promise.resolve([])))
const bookings = bookingsFetch.data ?? []
const [otpCode, setOtpCode] = React.useState("")
const { busy: checkingIn, run: runCheckIn } = useMutation()
const [payTarget, setPayTarget] = React.useState<Booking | null>(null)
const [cancelTarget, setCancelTarget] = React.useState<Booking | null>(null)
const [guestDialogOpen, setGuestDialogOpen] = React.useState(false)
const sessionOptions = sessions.map((s) => ({
label: `${s.facilityName ?? "-"} · ${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`,
value: s.facilitySessionId,
}))
async function handleCheckIn(e: React.FormEvent) {
e.preventDefault()
if (!otpCode.trim()) return
try {
await runCheckIn(() => checkInBooking(otpCode.trim()))
toast.success("Checked in")
setOtpCode("")
bookingsFetch.reload()
} catch {
// run() already toasted the real error.
}
}
async function confirmCancel() {
if (!cancelTarget) return
const target = cancelTarget
setCancelTarget(null)
try {
await runMutation(() => cancelBooking(target.bookingId))
toast.success("Booking cancelled")
bookingsFetch.reload()
} catch {
// runMutation() already toasted the real error.
}
}
function copyShareLink(booking: Booking) {
if (!booking.shareUrl) return
navigator.clipboard.writeText(booking.shareUrl)
toast.success("Link copied", { description: "Share it with the guest so they have their receipt." })
}
const columns: DataTableColumn<Booking>[] = [
{
key: "who",
header: "Booked By",
cell: (row) => (
<div>
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
</div>
),
},
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>,
},
{
key: "payment",
header: "Payment",
cell: (row) => (
<StatusBadge status={paymentStatusTone(row.paymentStatus)}>
{row.paymentStatus}
{row.amountCharged > 0 ? ` · Rs. ${row.amountCharged.toLocaleString()}` : ""}
</StatusBadge>
),
},
{ key: "booked-at", header: "Booked At", cell: (row) => formatDateTime(row.bookedAt) },
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) => (
<div className="flex justify-center gap-1.5">
{row.shareUrl && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Copy share link" onClick={() => copyShareLink(row)}>
<CopyIcon />
</Button>
}
/>
<TooltipContent>Copy share link</TooltipContent>
</Tooltip>
)}
{row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
<WalletIcon />
</Button>
}
/>
<TooltipContent>Mark as paid</TooltipContent>
</Tooltip>
)}
{row.status !== "Cancelled" && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Cancel booking" onClick={() => setCancelTarget(row)}>
<Trash2Icon />
</Button>
}
/>
<TooltipContent>Cancel booking</TooltipContent>
</Tooltip>
)}
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
<form onSubmit={handleCheckIn} className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="checkin-otp">Check in by OTP</Label>
<Input
id="checkin-otp"
placeholder="Enter the guest's OTP code"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
className="w-56"
/>
</div>
<Button type="submit" disabled={!otpCode.trim() || checkingIn}>
{checkingIn ? <Spinner className="size-4" /> : <CheckIcon />}
Check In
</Button>
</form>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Label>Session</Label>
<Select items={sessionOptions} value={sessionId} onValueChange={(v) => setSessionId(v ?? "")}>
<SelectTrigger className="w-96 max-w-full">
<SelectValue placeholder="Select a session" />
</SelectTrigger>
<SelectContent>
{sessionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={() => setGuestDialogOpen(true)} disabled={!sessionId}>
<PlusIcon />
Add Walk-in Guest
</Button>
</div>
{!sessionId ? (
<InfoBanner>Schedule a session first, then come back here to manage its roster.</InfoBanner>
) : bookingsFetch.loading ? (
<div className="flex justify-center py-10">
<Spinner className="size-6" />
</div>
) : (
<DataTable
columns={columns}
rows={bookings}
rowKey={(row) => row.bookingId}
emptyMessage="No bookings for this session yet."
renderMobileCard={(row) => (
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
</div>
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2">
<StatusBadge status={paymentStatusTone(row.paymentStatus)}>{row.paymentStatus}</StatusBadge>
<div className="flex gap-1.5">
{row.shareUrl && (
<Button variant="outline" size="icon-sm" aria-label="Copy share link" onClick={() => copyShareLink(row)}>
<CopyIcon />
</Button>
)}
{row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && (
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
<WalletIcon />
</Button>
)}
{row.status !== "Cancelled" && (
<Button variant="outline" size="icon-sm" aria-label="Cancel booking" onClick={() => setCancelTarget(row)}>
<Trash2Icon />
</Button>
)}
</div>
</div>
</div>
)}
/>
)}
<AddWalkInGuestDialog
open={guestDialogOpen}
onOpenChange={setGuestDialogOpen}
facilitySessionId={sessionId}
onCreated={bookingsFetch.reload}
/>
<MarkPaidDialog booking={payTarget} onOpenChange={(open) => !open && setPayTarget(null)} onPaid={bookingsFetch.reload} />
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this booking?</AlertDialogTitle>
<AlertDialogDescription>This frees up a spot in the session. This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
<AlertDialogAction onClick={confirmCancel}>Cancel Booking</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
interface AddWalkInGuestDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
facilitySessionId: string
onCreated: () => void
}
function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated }: AddWalkInGuestDialogProps) {
const [guestName, setGuestName] = React.useState("")
const [guestPhone, setGuestPhone] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setGuestName("")
setGuestPhone("")
}
}
const canSubmit = !!guestName.trim() && !!guestPhone.trim() && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
const booking = await run(() =>
createGuestBooking({ facilitySessionId, guestName: guestName.trim(), guestPhone: guestPhone.trim() })
)
if (booking.shareUrl) {
navigator.clipboard.writeText(booking.shareUrl)
toast.success("Booking created — share link copied", {
description: `OTP ${booking.otpCode} for ${booking.guestName}.`,
})
} else {
toast.success("Booking created", { description: `OTP ${booking.otpCode} for ${booking.guestName}.` })
}
onOpenChange(false)
onCreated()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Add Walk-in Guest</DialogTitle>
<DialogDescription>
Books a spot for a non-member and hands them an OTP receipt with a shareable link.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="guest-name">
Guest Name <span className="text-destructive">*</span>
</Label>
<Input id="guest-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="guest-phone">
Mobile Number <span className="text-destructive">*</span>
</Label>
<Input id="guest-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Add Guest Booking
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
interface MarkPaidDialogProps {
booking: Booking | null
onOpenChange: (open: boolean) => void
onPaid: () => void
}
function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps) {
const [amount, setAmount] = React.useState("")
const { busy, run } = useMutation()
const [prevBooking, setPrevBooking] = React.useState(booking)
if (booking !== prevBooking) {
setPrevBooking(booking)
setAmount(booking?.amountCharged ? String(booking.amountCharged) : "")
}
const canSubmit = !!booking && Number(amount) > 0 && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!booking || !canSubmit) return
try {
await run(() => markGuestBookingPaid(booking.bookingId, Number(amount)))
toast.success("Payment recorded")
onOpenChange(false)
onPaid()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={!!booking} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-sm">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Mark as Paid</DialogTitle>
<DialogDescription>Record the amount collected from {booking?.guestName ?? "this guest"}.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="paid-amount">
Amount (Rs.) <span className="text-destructive">*</span>
</Label>
<Input
id="paid-amount"
type="number"
min={0.01}
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Confirm Payment
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { BookingRosterTab }
+780
View File
@@ -0,0 +1,780 @@
"use client"
import * as React from "react"
import {
CalendarPlusIcon,
CheckIcon,
DumbbellIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
XIcon,
} from "lucide-react"
import { toast } from "sonner"
import {
cancelFacilitySession,
createFacility,
createFacilitySession,
getFacilities,
getFacilitySessions,
setFacilityStatus,
updateFacility,
} from "@/lib/api/facilities"
import type { Facility, FacilityInput, FacilitySession } from "@/lib/models/facility"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge"
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
const FACILITY_TYPE_OPTIONS: { label: string; value: FacilityInput["facilityType"] }[] = [
{ label: "Gym", value: "Gym" },
{ label: "Swimming Pool", value: "Pool" },
{ label: "Basketball Court", value: "Court" },
{ label: "Class (e.g. Karate)", value: "Class" },
]
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
})
}
/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */
function toLocalInputValue(date: Date) {
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function BookingsView() {
const facilitiesFetch = useFetch(() => getFacilities(true))
const facilities = facilitiesFetch.data ?? []
if (facilitiesFetch.loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Bookings"
description="Facilities, scheduled sessions, the booking roster and time-bank cards"
/>
<Tabs defaultValue="facilities" className="gap-4">
<TabsList>
<TabsTrigger value="facilities">Facilities</TabsTrigger>
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="roster">Roster &amp; Check-in</TabsTrigger>
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
</TabsList>
<TabsContent value="facilities">
<FacilitiesTab
facilities={facilities}
onReload={facilitiesFetch.reload}
/>
</TabsContent>
<TabsContent value="sessions">
<SessionsTab facilities={facilities} />
</TabsContent>
<TabsContent value="roster">
<BookingRosterTab facilities={facilities} />
</TabsContent>
<TabsContent value="time-bank-cards">
<TimeBankCardsTab facilities={facilities} />
</TabsContent>
</Tabs>
</div>
)
}
// ---------- Facilities ----------
interface FacilitiesTabProps {
facilities: Facility[]
onReload: () => void
}
function FacilitiesTab({ facilities, onReload }: FacilitiesTabProps) {
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editTarget, setEditTarget] = React.useState<Facility | null>(null)
const [statusTarget, setStatusTarget] = React.useState<Facility | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
async function applyStatus(facility: Facility, isActive: boolean) {
setStatusPendingId(facility.facilityId)
try {
await runMutation(() => setFacilityStatus(facility.facilityId, isActive))
toast.success(isActive ? "Facility activated" : "Facility deactivated", {
description: `"${facility.name}" is now ${isActive ? "active" : "inactive"}.`,
})
onReload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
const columns: DataTableColumn<Facility>[] = [
{
key: "name",
header: "Facility",
cell: (row) => (
<div className="flex items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<DumbbellIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
{row.description && (
<p className="text-xs text-muted-foreground">{row.description}</p>
)}
</div>
</div>
),
},
{ key: "type", header: "Type", cell: (row) => <Badge variant="outline">{row.facilityType}</Badge> },
{ key: "capacity", header: "Default Capacity", cell: (row) => row.defaultCapacity },
{
key: "status",
header: "Status",
cell: (row) => (
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
),
},
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) => (
<div className="flex justify-center gap-1.5">
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
<PencilIcon />
</Button>
}
/>
<TooltipContent>Edit facility</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
disabled={statusPendingId === row.facilityId}
onClick={() => setStatusTarget(row)}
>
{statusPendingId === row.facilityId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
}
/>
<TooltipContent>{row.isActive ? "Deactivate facility" : "Activate facility"}</TooltipContent>
</Tooltip>
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button
onClick={() => {
setEditTarget(null)
setDialogOpen(true)
}}
>
<PlusIcon />
Add Facility
</Button>
</div>
<DataTable
columns={columns}
rows={facilities}
rowKey={(row) => row.facilityId}
emptyMessage="No facilities yet. Add the gym, pool, court or a class to get started."
renderMobileCard={(row) => (
<div className="flex flex-col gap-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<DumbbellIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
<Badge variant="outline">{row.facilityType}</Badge>
</div>
</div>
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<span className="text-sm text-muted-foreground">Capacity: {row.defaultCapacity}</span>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
<PencilIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
disabled={statusPendingId === row.facilityId}
onClick={() => setStatusTarget(row)}
>
{statusPendingId === row.facilityId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
</div>
</div>
</div>
)}
/>
<FacilityDialog
open={dialogOpen || !!editTarget}
onOpenChange={(open) => {
if (!open) {
setDialogOpen(false)
setEditTarget(null)
}
}}
editTarget={editTarget}
onSaved={onReload}
/>
<AlertDialog open={!!statusTarget} onOpenChange={(open) => !open && setStatusTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{statusTarget?.isActive ? "Deactivate this facility?" : "Activate this facility?"}
</AlertDialogTitle>
<AlertDialogDescription>
{statusTarget?.isActive
? `"${statusTarget?.name}" will stop accepting new bookings until reactivated.`
: `"${statusTarget?.name}" will accept new bookings again.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const facility = statusTarget
setStatusTarget(null)
if (facility) void applyStatus(facility, !facility.isActive)
}}
>
{statusTarget?.isActive ? "Deactivate" : "Activate"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
interface FacilityDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget: Facility | null
onSaved: () => void
}
function FacilityDialog({ open, onOpenChange, editTarget, onSaved }: FacilityDialogProps) {
const [name, setName] = React.useState("")
const [facilityType, setFacilityType] = React.useState<FacilityInput["facilityType"]>("Gym")
const [description, setDescription] = React.useState("")
const [capacity, setCapacity] = React.useState("1")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setName(editTarget?.name ?? "")
setFacilityType(editTarget?.facilityType ?? "Gym")
setDescription(editTarget?.description ?? "")
setCapacity(String(editTarget?.defaultCapacity ?? 1))
}
}
const canSubmit = !!name.trim() && Number(capacity) >= 1 && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
const input: FacilityInput = {
name: name.trim(),
facilityType,
description: description.trim() || undefined,
defaultCapacity: Number(capacity),
}
try {
await run(() =>
editTarget ? updateFacility(editTarget.facilityId, input) : createFacility(input)
)
toast.success(editTarget ? "Facility updated" : "Facility created", {
description: `"${input.name}" was saved.`,
})
onOpenChange(false)
onSaved()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{editTarget ? "Edit Facility" : "Add Facility"}</DialogTitle>
<DialogDescription>
{editTarget ? "Update this facility's details." : "Add the gym, pool, court or a class."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="facility-name"
placeholder="e.g. Main Gym Floor"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Select
items={FACILITY_TYPE_OPTIONS}
value={facilityType}
onValueChange={(v) => setFacilityType((v as FacilityInput["facilityType"]) ?? "Gym")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{FACILITY_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-capacity">
Default Capacity <span className="text-destructive">*</span>
</Label>
<Input
id="facility-capacity"
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
required
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-description">Description</Label>
<Textarea
id="facility-description"
placeholder="Optional short description"
maxLength={300}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{editTarget ? "Save Changes" : "Add Facility"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ---------- Sessions ----------
interface SessionsTabProps {
facilities: Facility[]
}
function SessionsTab({ facilities }: SessionsTabProps) {
const [facilityFilter, setFacilityFilter] = React.useState("all")
const sessionsFetch = useFetch(() =>
getFacilitySessions(facilityFilter === "all" ? {} : { facilityId: facilityFilter })
)
const sessions = sessionsFetch.data ?? []
const [dialogOpen, setDialogOpen] = React.useState(false)
const [cancelTarget, setCancelTarget] = React.useState<FacilitySession | null>(null)
const { busy: cancelling, run: runCancel } = useMutation()
const filterOptions = [
{ label: "All facilities", value: "all" },
...facilities.map((f) => ({ label: f.name, value: f.facilityId })),
]
async function confirmCancel() {
if (!cancelTarget) return
const target = cancelTarget
setCancelTarget(null)
try {
await runCancel(() => cancelFacilitySession(target.facilitySessionId))
toast.success("Session cancelled")
sessionsFetch.reload()
} catch {
// run() already toasted the real error.
}
}
const columns: DataTableColumn<FacilitySession>[] = [
{ key: "facility", header: "Facility", cell: (row) => row.facilityName ?? "-" },
{ key: "start", header: "Start", cell: (row) => formatDateTime(row.startTime) },
{ key: "end", header: "End", cell: (row) => formatDateTime(row.endTime) },
{
key: "capacity",
header: "Booked / Capacity",
cell: (row) => `${row.bookedCount} / ${row.capacity}`,
},
{
key: "status",
header: "Status",
cell: (row) =>
row.isCancelled ? (
<StatusBadge status="danger">Cancelled</StatusBadge>
) : (
<StatusBadge status="success">Scheduled</StatusBadge>
),
},
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) =>
row.isCancelled ? null : (
<div className="flex justify-center">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Cancel session"
onClick={() => setCancelTarget(row)}
>
<Trash2Icon />
</Button>
}
/>
<TooltipContent>Cancel session</TooltipContent>
</Tooltip>
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Label>Facility</Label>
<Select items={filterOptions} value={facilityFilter} onValueChange={(v) => setFacilityFilter(v ?? "all")}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All facilities" />
</SelectTrigger>
<SelectContent>
{filterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => setDialogOpen(true)} disabled={facilities.length === 0}>
<CalendarPlusIcon />
Schedule Session
</Button>
</div>
{sessionsFetch.loading ? (
<div className="flex justify-center py-10">
<Spinner className="size-6" />
</div>
) : (
<DataTable
columns={columns}
rows={sessions}
rowKey={(row) => row.facilitySessionId}
emptyMessage="No sessions scheduled yet."
renderMobileCard={(row) => (
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">{row.facilityName ?? "-"}</p>
<p className="text-sm text-muted-foreground">
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
</p>
</div>
{row.isCancelled ? (
<StatusBadge status="danger">Cancelled</StatusBadge>
) : (
<StatusBadge status="success">Scheduled</StatusBadge>
)}
</div>
<div className="flex items-center justify-between border-t pt-2">
<span className="text-sm text-muted-foreground">
{row.bookedCount} / {row.capacity} booked
</span>
{!row.isCancelled && (
<Button variant="outline" size="icon-sm" aria-label="Cancel session" onClick={() => setCancelTarget(row)}>
<Trash2Icon />
</Button>
)}
</div>
</div>
)}
/>
)}
<ScheduleSessionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
facilities={facilities}
onScheduled={sessionsFetch.reload}
/>
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this session?</AlertDialogTitle>
<AlertDialogDescription>
Existing bookings for this session will need to be handled separately. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Session</AlertDialogCancel>
<AlertDialogAction onClick={confirmCancel} disabled={cancelling}>
Cancel Session
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
interface ScheduleSessionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
facilities: Facility[]
onScheduled: () => void
}
function ScheduleSessionDialog({ open, onOpenChange, facilities, onScheduled }: ScheduleSessionDialogProps) {
const [facilityId, setFacilityId] = React.useState("")
const [startTime, setStartTime] = React.useState("")
const [endTime, setEndTime] = React.useState("")
const [capacity, setCapacity] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
const now = new Date()
now.setMinutes(0, 0, 0)
now.setHours(now.getHours() + 1)
const later = new Date(now.getTime() + 60 * 60 * 1000)
setFacilityId(facilities[0]?.facilityId ?? "")
setStartTime(toLocalInputValue(now))
setEndTime(toLocalInputValue(later))
setCapacity("")
}
}
const facilityOptions = facilities.map((f) => ({ label: f.name, value: f.facilityId }))
const canSubmit = !!facilityId && !!startTime && !!endTime && endTime > startTime && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() =>
createFacilitySession({
facilityId,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
capacity: capacity ? Number(capacity) : undefined,
})
)
toast.success("Session scheduled")
onOpenChange(false)
onScheduled()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Schedule Session</DialogTitle>
<DialogDescription>Open a bookable time slot for a facility.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label>
Facility <span className="text-destructive">*</span>
</Label>
<Select
items={facilityOptions}
value={facilityId}
onValueChange={(v) => setFacilityId(v ?? "")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select facility" />
</SelectTrigger>
<SelectContent>
{facilityOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="session-start">
Start <span className="text-destructive">*</span>
</Label>
<Input
id="session-start"
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="session-end">
End <span className="text-destructive">*</span>
</Label>
<Input
id="session-end"
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="session-capacity">Capacity override</Label>
<Input
id="session-capacity"
type="number"
min={1}
placeholder="Uses the facility's default capacity if left blank"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Schedule Session
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { BookingsView }

Some files were not shown because too many files have changed in this diff Show More