9 Commits

Author SHA1 Message Date
Jose Selesan
000277b312 refactor: remove role display from ComplexSettingsPage and adjust HomePage layout 2026-04-27 14:40:57 -03:00
Jose Selesan
7a3452c2d1 refactor: update home page UI to v2 patterns and introduce availability heatmap component 2026-04-27 08:46:32 -03:00
Jose Selesan
d6e32f3e84 feat: implement booking cancellation feature in HomePage 2026-04-24 15:44:46 -03:00
Jose Selesan
82dbc8ad99 feat: update styles for HomePage and RootLayout, adjust background color in CSS 2026-04-24 15:32:49 -03:00
Jose Selesan
fb19458ddf feat: add price field to CourtBooking model and related schemas
- Added `price` field to `CourtBookingScalarFieldEnum` in `prismaNamespace.ts` and `prismaNamespaceBrowser.ts`.
- Updated `CourtBooking` model to include average and sum aggregations for `price`.
- Enhanced input types and aggregate input types to support `price`.
- Modified `public-booking.service.ts` to calculate and return price information for bookings.
- Updated frontend components to display price information in booking confirmation and selection pages.
- Introduced `priceRangeSchema` in API contract to validate price ranges.
2026-04-24 15:22:11 -03:00
Jose Selesan
c63f0f2109 feat: update public booking page layout for improved responsiveness 2026-04-24 14:50:46 -03:00
Jose Selesan
f1a7e6c24a feat: enhance public booking page with improved layout and date formatting
- Added new date formatting function for better user experience.
- Updated layout of the public booking page for improved responsiveness and aesthetics.
- Integrated sport selection with visual icons for better clarity.
- Enhanced error handling and loading states for availability queries.

feat: revamp select complex page for better user interaction

- Redesigned the select complex page layout for a more modern look.
- Added contextual information about complex selection.
- Improved button interactions with loading indicators and icons.

style: update CSS variables for a cohesive sports-club theme

- Adjusted color variables to reflect a sports-club identity.
- Enhanced background styles for both light and dark modes.
- Improved overall styling consistency across components.
2026-04-24 14:47:39 -03:00
Jose Selesan
c63b5a0a07 feat: enhance sport selection UI in public booking page
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 14:14:09 -03:00
Jose Selesan
503c29613b feat(interface-design): add comprehensive interface design skill documentation and references
- Introduced SKILL.md outlining the principles and processes for effective interface design.
- Added critique.md for evaluating design outputs and ensuring craft over correctness.
- Created example.md to illustrate the application of subtle layering principles in design decisions.
- Developed principles.md detailing core craft principles for consistent design quality.
- Implemented validation.md for memory management and pattern reuse in the design system.
- Established system.md for the Playzer design system, defining direction, domain concepts, and design decisions.
- Added StatusBadge component for displaying booking statuses with appropriate styles and labels.
- Updated skills-lock.json to include the new interface-design skill.
2026-04-24 11:23:45 -03:00
41 changed files with 3267 additions and 1050 deletions

View File

@@ -0,0 +1,391 @@
---
name: interface-design
description: This skill is for interface design — dashboards, admin panels, apps, tools, and interactive products. NOT for marketing design (landing pages, marketing sites, campaigns).
---
# Interface Design
Build interface design with craft and consistency.
## Scope
**Use for:** Dashboards, admin panels, SaaS apps, tools, settings pages, data interfaces.
**Not for:** Landing pages, marketing sites, campaigns. Redirect those to `/frontend-design`.
---
# The Problem
You will generate generic output. Your training has seen thousands of dashboards. The patterns are strong.
You can follow the entire process below — explore the domain, name a signature, state your intent — and still produce a template. Warm colors on cold structures. Friendly fonts on generic layouts. "Kitchen feel" that looks like every other app.
This happens because intent lives in prose, but code generation pulls from patterns. The gap between them is where defaults win.
The process below helps. But process alone doesn't guarantee craft. You have to catch yourself.
---
# Where Defaults Hide
Defaults don't announce themselves. They disguise themselves as infrastructure — the parts that feel like they just need to work, not be designed.
**Typography feels like a container.** Pick something readable, move on. But typography isn't holding your design — it IS your design. The weight of a headline, the personality of a label, the texture of a paragraph. These shape how the product feels before anyone reads a word. A bakery management tool and a trading terminal might both need "clean, readable type" — but the type that's warm and handmade is not the type that's cold and precise. If you're reaching for your usual font, you're not designing.
**Navigation feels like scaffolding.** Build the sidebar, add the links, get to the real work. But navigation isn't around your product — it IS your product. Where you are, where you can go, what matters most. A page floating in space is a component demo, not software. The navigation teaches people how to think about the space they're in.
**Data feels like presentation.** You have numbers, show numbers. But a number on screen is not design. The question is: what does this number mean to the person looking at it? What will they do with it? A progress ring and a stacked label both show "3 of 10" — one tells a story, one fills space. If you're reaching for number-on-label, you're not designing.
**Token names feel like implementation detail.** But your CSS variables are design decisions. `--ink` and `--parchment` evoke a world. `--gray-700` and `--surface-2` evoke a template. Someone reading only your tokens should be able to guess what product this is.
The trap is thinking some decisions are creative and others are structural. There are no structural decisions. Everything is design. The moment you stop asking "why this?" is the moment defaults take over.
---
# Intent First
Before touching code, answer these. Not in your head — out loud, to yourself or the user.
**Who is this human?**
Not "users." The actual person. Where are they when they open this? What's on their mind? What did they do 5 minutes ago, what will they do 5 minutes after? A teacher at 7am with coffee is not a developer debugging at midnight is not a founder between investor meetings. Their world shapes the interface.
**What must they accomplish?**
Not "use the dashboard." The verb. Grade these submissions. Find the broken deployment. Approve the payment. The answer determines what leads, what follows, what hides.
**What should this feel like?**
Say it in words that mean something. "Clean and modern" means nothing — every AI says that. Warm like a notebook? Cold like a terminal? Dense like a trading floor? Calm like a reading app? The answer shapes color, type, spacing, density — everything.
If you cannot answer these with specifics, stop. Ask the user. Do not guess. Do not default.
## Every Choice Must Be A Choice
For every decision, you must be able to explain WHY.
- Why this layout and not another?
- Why this color temperature?
- Why this typeface?
- Why this spacing scale?
- Why this information hierarchy?
If your answer is "it's common" or "it's clean" or "it works" — you haven't chosen. You've defaulted. Defaults are invisible. Invisible choices compound into generic output.
**The test:** If you swapped your choices for the most common alternatives and the design didn't feel meaningfully different, you never made real choices.
## Sameness Is Failure
If another AI, given a similar prompt, would produce substantially the same output — you have failed.
This is not about being different for its own sake. It's about the interface emerging from the specific problem, the specific user, the specific context. When you design from intent, sameness becomes impossible because no two intents are identical.
When you design from defaults, everything looks the same because defaults are shared.
## Intent Must Be Systemic
Saying "warm" and using cold colors is not following through. Intent is not a label — it's a constraint that shapes every decision.
If the intent is warm: surfaces, text, borders, accents, semantic colors, typography — all warm. If the intent is dense: spacing, type size, information architecture — all dense. If the intent is calm: motion, contrast, color saturation — all calm.
Check your output against your stated intent. Does every token reinforce it? Or did you state an intent and then default anyway?
---
# Product Domain Exploration
This is where defaults get caught — or don't.
Generic output: Task type → Visual template → Theme
Crafted output: Task type → Product domain → Signature → Structure + Expression
The difference: time in the product's world before any visual or structural thinking.
## Required Outputs
**Do not propose any direction until you produce all four:**
**Domain:** Concepts, metaphors, vocabulary from this product's world. Not features — territory. Minimum 5.
**Color world:** What colors exist naturally in this product's domain? Not "warm" or "cool" — go to the actual world. If this product were a physical space, what would you see? What colors belong there that don't belong elsewhere? List 5+.
**Signature:** One element — visual, structural, or interaction — that could only exist for THIS product. If you can't name one, keep exploring.
**Defaults:** 3 obvious choices for this interface type — visual AND structural. You can't avoid patterns you haven't named.
## Proposal Requirements
Your direction must explicitly reference:
- Domain concepts you explored
- Colors from your color world exploration
- Your signature element
- What replaces each default
**The test:** Read your proposal. Remove the product name. Could someone identify what this is for? If not, it's generic. Explore deeper.
---
# The Mandate
**Before showing the user, look at what you made.**
Ask yourself: "If they said this lacks craft, what would they mean?"
That thing you just thought of — fix it first.
Your first output is probably generic. That's normal. The work is catching it before the user has to.
## The Checks
Run these against your output before presenting:
- **The swap test:** If you swapped the typeface for your usual one, would anyone notice? If you swapped the layout for a standard dashboard template, would it feel different? The places where swapping wouldn't matter are the places you defaulted.
- **The squint test:** Blur your eyes. Can you still perceive hierarchy? Is anything jumping out harshly? Craft whispers.
- **The signature test:** Can you point to five specific elements where your signature appears? Not "the overall feel" — actual components. A signature you can't locate doesn't exist.
- **The token test:** Read your CSS variables out loud. Do they sound like they belong to this product's world, or could they belong to any project?
If any check fails, iterate before showing.
---
# Craft Foundations
## Subtle Layering
This is the backbone of craft. Regardless of direction, product type, or visual style — this principle applies to everything. You should barely notice the system working. When you look at Vercel's dashboard, you don't think "nice borders." You just understand the structure. The craft is invisible — that's how you know it's working.
### Surface Elevation
Surfaces stack. A dropdown sits above a card which sits above the page. Build a numbered system — base, then increasing elevation levels. In dark mode, higher elevation = slightly lighter. In light mode, higher elevation = slightly lighter or uses shadow.
Each jump should be only a few percentage points of lightness. You can barely see the difference in isolation. But when surfaces stack, the hierarchy emerges. Whisper-quiet shifts that you feel rather than see.
**Key decisions:**
- **Sidebars:** Same background as canvas, not different. Different colors fragment the visual space into "sidebar world" and "content world." A subtle border is enough separation.
- **Dropdowns:** One level above their parent surface. If both share the same level, the dropdown blends into the card and layering is lost.
- **Inputs:** Slightly darker than their surroundings, not lighter. Inputs are "inset" — they receive content. A darker background signals "type here" without heavy borders.
### Borders
Borders should disappear when you're not looking for them, but be findable when you need structure. Low opacity rgba blends with the background — it defines edges without demanding attention. Solid hex borders look harsh in comparison.
Build a progression — not all borders are equal. Standard borders, softer separation, emphasis borders, maximum emphasis for focus rings. Match intensity to the importance of the boundary.
**The squint test:** Blur your eyes at the interface. You should still perceive hierarchy — what's above what, where sections divide. But nothing should jump out. No harsh lines. No jarring color shifts. Just quiet structure.
This separates professional interfaces from amateur ones. Get this wrong and nothing else matters.
## Infinite Expression
Every pattern has infinite expressions. **No interface should look the same.**
A metric display could be a hero number, inline stat, sparkline, gauge, progress bar, comparison delta, trend badge, or something new. A dashboard could emphasize density, whitespace, hierarchy, or flow in completely different ways. Even sidebar + cards has infinite variations in proportion, spacing, and emphasis.
**Before building, ask:**
- What's the ONE thing users do most here?
- What products solve similar problems brilliantly? Study them.
- Why would this interface feel designed for its purpose, not templated?
**NEVER produce identical output.** Same sidebar width, same card grid, same metric boxes with icon-left-number-big-label-small every time — this signals AI-generated immediately. It's forgettable.
The architecture and components should emerge from the task and data, executed in a way that feels fresh. Linear's cards don't look like Notion's. Vercel's metrics don't look like Stripe's. Same concepts, infinite expressions.
## Color Lives Somewhere
Every product exists in a world. That world has colors.
Before you reach for a palette, spend time in the product's world. What would you see if you walked into the physical version of this space? What materials? What light? What objects?
Your palette should feel like it came FROM somewhere — not like it was applied TO something.
**Beyond Warm and Cold:** Temperature is one axis. Is this quiet or loud? Dense or spacious? Serious or playful? Geometric or organic? A trading terminal and a meditation app are both "focused" — completely different kinds of focus. Find the specific quality, not the generic label.
**Color Carries Meaning:** Gray builds structure. Color communicates — status, action, emphasis, identity. Unmotivated color is noise. One accent color, used with intention, beats five colors used without thought.
---
# Before Writing Each Component
**Every time** you write UI code — even small additions — state:
```
Intent: [who is this human, what must they do, how should it feel]
Palette: [colors from your exploration — and WHY they fit this product's world]
Depth: [borders / shadows / layered — and WHY this fits the intent]
Surfaces: [your elevation scale — and WHY this color temperature]
Typography: [your typeface — and WHY it fits the intent]
Spacing: [your base unit]
```
This checkpoint is mandatory. It forces you to connect every technical choice back to intent.
If you can't explain WHY for each choice, you're defaulting. Stop and think.
---
# Design Principles
## Token Architecture
Every color in your interface should trace back to a small set of primitives: foreground (text hierarchy), background (surface elevation), border (separation hierarchy), brand, and semantic (destructive, warning, success). No random hex values — everything maps to primitives.
### Text Hierarchy
Don't just have "text" and "gray text." Build four levels — primary, secondary, tertiary, muted. Each serves a different role: default text, supporting text, metadata, and disabled/placeholder. Use all four consistently. If you're only using two, your hierarchy is too flat.
### Border Progression
Borders aren't binary. Build a scale that matches intensity to importance — standard separation, softer separation, emphasis, maximum emphasis. Not every boundary deserves the same weight.
### Control Tokens
Form controls have specific needs. Don't reuse surface tokens — create dedicated ones for control backgrounds, control borders, and focus states. This lets you tune interactive elements independently from layout surfaces.
## Spacing
Pick a base unit and stick to multiples. Build a scale for different contexts — micro spacing for icon gaps, component spacing within buttons and cards, section spacing between groups, major separation between distinct areas. Random values signal no system.
## Padding
Keep it symmetrical. If one side has a value, others should match unless content naturally requires asymmetry.
## Depth
Choose ONE approach and commit:
- **Borders-only** — Clean, technical. For dense tools.
- **Subtle shadows** — Soft lift. For approachable products.
- **Layered shadows** — Premium, dimensional. For cards that need presence.
- **Surface color shifts** — Background tints establish hierarchy without shadows.
Don't mix approaches.
## Border Radius
Sharper feels technical. Rounder feels friendly. Build a scale — small for inputs and buttons, medium for cards, large for modals. Don't mix sharp and soft randomly.
## Typography
Build distinct levels distinguishable at a glance. Headlines need weight and tight tracking for presence. Body needs comfortable weight for readability. Labels need medium weight that works at smaller sizes. Data needs monospace with tabular number spacing for alignment. Don't rely on size alone — combine size, weight, and letter-spacing.
## Card Layouts
A metric card doesn't have to look like a plan card doesn't have to look like a settings card. Design each card's internal structure for its specific content — but keep the surface treatment consistent: same border weight, shadow depth, corner radius, padding scale.
## Controls
Native `<select>` and `<input type="date">` render OS-native elements that cannot be styled. Build custom components — trigger buttons with positioned dropdowns, calendar popovers, styled state management.
## Iconography
Icons clarify, not decorate — if removing an icon loses no meaning, remove it. Choose one icon set and stick with it. Give standalone icons presence with subtle background containers.
## Animation
Fast micro-interactions, smooth easing. Larger transitions can be slightly longer. Use deceleration easing. Avoid spring/bounce in professional interfaces.
## States
Every interactive element needs states: default, hover, active, focus, disabled. Data needs states too: loading, empty, error. Missing states feel broken.
## Navigation Context
Screens need grounding. A data table floating in space feels like a component demo, not a product. Include navigation showing where you are in the app, location indicators, and user context. When building sidebars, consider same background as main content with border separation rather than different colors.
## Dark Mode
Dark interfaces have different needs. Shadows are less visible on dark backgrounds — lean on borders for definition. Semantic colors (success, warning, error) often need slight desaturation. The hierarchy system still applies, just with inverted values.
---
# Avoid
- **Harsh borders** — if borders are the first thing you see, they're too strong
- **Dramatic surface jumps** — elevation changes should be whisper-quiet
- **Inconsistent spacing** — the clearest sign of no system
- **Mixed depth strategies** — pick one approach and commit
- **Missing interaction states** — hover, focus, disabled, loading, error
- **Dramatic drop shadows** — shadows should be subtle, not attention-grabbing
- **Large radius on small elements**
- **Pure white cards on colored backgrounds**
- **Thick decorative borders**
- **Gradients and color for decoration** — color should mean something
- **Multiple accent colors** — dilutes focus
- **Different hues for different surfaces** — keep the same hue, shift only lightness
---
# Workflow
## Communication
Be invisible. Don't announce modes or narrate process.
**Never say:** "I'm in ESTABLISH MODE", "Let me check system.md..."
**Instead:** Jump into work. State suggestions with reasoning.
## Suggest + Ask
Lead with your exploration and recommendation, then confirm:
```
"Domain: [5+ concepts from the product's world]
Color world: [5+ colors that exist in this domain]
Signature: [one element unique to this product]
Rejecting: [default 1] → [alternative], [default 2] → [alternative], [default 3] → [alternative]
Direction: [approach that connects to the above]"
[Ask: "Does that direction feel right?"]
```
## If Project Has system.md
Read `.interface-design/system.md` and apply. Decisions are made.
## If No system.md
1. Explore domain — Produce all four required outputs
2. Propose — Direction must reference all four
3. Confirm — Get user buy-in
4. Build — Apply principles
5. **Evaluate** — Run the mandate checks before showing
6. Offer to save
---
# After Completing a Task
When you finish building something, **always offer to save**:
```
"Want me to save these patterns for future sessions?"
```
If yes, write to `.interface-design/system.md`:
- Direction and feel
- Depth strategy (borders/shadows/layered)
- Spacing base unit
- Key component patterns
### What to Save
Add patterns when a component is used 2+ times, is reusable across the project, or has specific measurements worth remembering. Don't save one-off components, temporary experiments, or variations better handled with props.
### Consistency Checks
If system.md defines values, check against them: spacing on the defined grid, depth using the declared strategy throughout, colors from the defined palette, documented patterns reused instead of reinvented.
This compounds — each save makes future work faster and more consistent.
---
# Deep Dives
For more detail on specific topics:
- `references/principles.md` — Code examples, specific values, dark mode
- `references/validation.md` — Memory management, when to update system.md
- `references/critique.md` — Post-build craft critique protocol
# Commands
- `/interface-design:status` — Current system state
- `/interface-design:audit` — Check code against system
- `/interface-design:extract` — Extract patterns from code
- `/interface-design:critique` — Critique your build for craft, then rebuild what defaulted

View File

@@ -0,0 +1,67 @@
# Critique
Your first build shipped the structure. Now look at it the way a design lead reviews a junior's work — not asking "does this work?" but "would I put my name on this?"
---
## The Gap
There's a distance between correct and crafted. Correct means the layout holds, the grid aligns, the colors don't clash. Crafted means someone cared about every decision down to the last pixel. You can feel the difference immediately — the way you tell a hand-thrown mug from an injection-molded one. Both hold coffee. One has presence.
Your first output lives in correct. This command pulls it toward crafted.
---
## See the Composition
Step back. Look at the whole thing.
Does the layout have rhythm? Great interfaces breathe unevenly — dense tooling areas give way to open content, heavy elements balance against light ones, the eye travels through the page with purpose. Default layouts are monotone: same card size, same gaps, same density everywhere. Flatness is the sound of no one deciding.
Are proportions doing work? A 280px sidebar next to full-width content says "navigation serves content." A 360px sidebar says "these are peers." The specific number declares what matters. If you can't articulate what your proportions are saying, they're not saying anything.
Is there a clear focal point? Every screen has one thing the user came here to do. That thing should dominate — through size, position, contrast, or the space around it. When everything competes equally, nothing wins and the interface feels like a parking lot.
---
## See the Craft
Move close. Pixel-close.
The spacing grid is non-negotiable — every value a multiple of 4, no exceptions — but correctness alone isn't craft. Craft is knowing that a tool panel at 16px padding feels workbench-tight while the same card at 24px feels like a brochure. The same number can be right in one context and lazy in another. Density is a design decision, not a constant.
Typography should be legible even squinted. If size is the only thing separating your headline from your body from your label, the hierarchy is too weak. Weight, tracking, and opacity create layers that size alone can't.
Surfaces should whisper hierarchy. Not thick borders, not dramatic shadows — quiet tonal shifts where you feel the depth without seeing it. Remove every border from your CSS mentally. Can you still perceive the structure through surface color alone? If not, your surfaces aren't working hard enough.
Interactive elements need life. Every button, link, and clickable region should respond to hover and press. Not dramatically — a subtle shift in background, a gentle darkening. Missing states make an interface feel like a photograph of software instead of software.
---
## See the Content
Read every visible string as a user would. Not checking for typos — checking for truth.
Does this screen tell one coherent story? Could a real person at a real company be looking at exactly this data right now? Or does the page title belong to one product, the article body to another, and the sidebar metrics to a third?
Content incoherence breaks the illusion faster than any visual flaw. A beautifully designed interface with nonsensical content is a movie set with no script.
---
## See the Structure
Open the CSS and find the lies — the places that look right but are held together with tape.
Negative margins undoing a parent's padding. Calc() values that exist only as workarounds. Absolute positioning to escape layout flow. Each is a shortcut where a clean solution exists. Cards with full-width dividers use flex column and section-level padding. Centered content uses max-width with auto margins. The correct answer is always simpler than the hack.
---
## Again
Look at your output one final time.
Ask: "If they said this lacks craft, what would they point to?"
That thing you just thought of — fix it. Then ask again.
The first build was the draft. The critique is the design.

View File

@@ -0,0 +1,86 @@
# Craft in Action
This shows how the subtle layering principle translates to real decisions. Learn the thinking, not the code. Your values will differ — the approach won't.
---
## The Subtle Layering Mindset
Before looking at any example, internalize this: **you should barely notice the system working.**
When you look at Vercel's dashboard, you don't think "nice borders." You just understand the structure. When you look at Supabase, you don't think "good surface elevation." You just know what's above what. The craft is invisible — that's how you know it's working.
---
## Example: Dashboard with Sidebar and Dropdown
### The Surface Decisions
**Why so subtle?** Each elevation jump should be only a few percentage points of lightness. You can barely see the difference in isolation. But when surfaces stack, the hierarchy emerges. This is the Vercel/Supabase way — whisper-quiet shifts that you feel rather than see.
**What NOT to do:** Don't make dramatic jumps between elevations. That's jarring. Don't use different hues for different levels. Keep the same hue, shift only lightness.
### The Border Decisions
**Why rgba, not solid colors?** Low opacity borders blend with their background. A low-opacity white border on a dark surface is barely there — it defines the edge without demanding attention. Solid hex borders look harsh in comparison.
**The test:** Look at your interface from arm's length. If borders are the first thing you notice, reduce opacity. If you can't find where regions end, increase slightly.
### The Sidebar Decision
**Why same background as canvas, not different?**
Many dashboards make the sidebar a different color. This fragments the visual space — now you have "sidebar world" and "content world."
Better: Same background, subtle border separation. The sidebar is part of the app, not a separate region. Vercel does this. Supabase does this. The border is enough.
### The Dropdown Decision
**Why surface-200, not surface-100?**
The dropdown floats above the card it emerged from. If both were surface-100, the dropdown would blend into the card — you'd lose the sense of layering. Surface-200 is just light enough to feel "above" without being dramatically different.
**Why border-overlay instead of border-default?**
Overlays (dropdowns, popovers) often need slightly more definition because they're floating in space. A touch more border opacity helps them feel contained without being harsh.
---
## Example: Form Controls
### Input Background Decision
**Why darker, not lighter?**
Inputs are "inset" — they receive content, they don't project it. A slightly darker background signals "type here" without needing heavy borders. This is the alternative-background principle.
### Focus State Decision
**Why subtle focus states?**
Focus needs to be visible, but you don't need a glowing ring or dramatic color. A noticeable increase in border opacity is enough for a clear state change. Subtle-but-noticeable — the same principle as surfaces.
---
## Adapt to Context
Your product might need:
- Warmer hues (slight yellow/orange tint)
- Cooler hues (blue-gray base)
- Different lightness progression
- Light mode (principles invert — higher elevation = shadow, not lightness)
**The principle is constant:** barely different, still distinguishable. The values adapt to context.
---
## The Craft Check
Apply the squint test to your work:
1. Blur your eyes or step back
2. Can you still perceive hierarchy?
3. Is anything jumping out at you?
4. Can you tell where regions begin and end?
If hierarchy is visible and nothing is harsh — the subtle layering is working.

View File

@@ -0,0 +1,235 @@
# Core Craft Principles
These apply regardless of design direction. This is the quality floor.
---
## Surface & Token Architecture
Professional interfaces don't pick colors randomly — they build systems. Understanding this architecture is the difference between "looks okay" and "feels like a real product."
### The Primitive Foundation
Every color in your interface should trace back to a small set of primitives:
- **Foreground** — text colors (primary, secondary, muted)
- **Background** — surface colors (base, elevated, overlay)
- **Border** — edge colors (default, subtle, strong)
- **Brand** — your primary accent
- **Semantic** — functional colors (destructive, warning, success)
Don't invent new colors. Map everything to these primitives.
### Surface Elevation Hierarchy
Surfaces stack. A dropdown sits above a card which sits above the page. Build a numbered system:
```
Level 0: Base background (the app canvas)
Level 1: Cards, panels (same visual plane as base)
Level 2: Dropdowns, popovers (floating above)
Level 3: Nested dropdowns, stacked overlays
Level 4: Highest elevation (rare)
```
In dark mode, higher elevation = slightly lighter. In light mode, higher elevation = slightly lighter or uses shadow. The principle: **elevated surfaces need visual distinction from what's beneath them.**
### The Subtlety Principle
This is where most interfaces fail. Study Vercel, Supabase, Linear — their surfaces are **barely different** but still distinguishable. Their borders are **light but not invisible**.
**For surfaces:** The difference between elevation levels should be subtle — a few percentage points of lightness, not dramatic jumps. In dark mode, surface-100 might be 7% lighter than base, surface-200 might be 9%, surface-300 might be 12%. You can barely see it, but you feel it.
**For borders:** Borders should define regions without demanding attention. Use low opacity (0.05-0.12 alpha for dark mode, slightly higher for light). The border should disappear when you're not looking for it, but be findable when you need to understand the structure.
**The test:** Squint at your interface. You should still perceive the hierarchy — what's above what, where regions begin and end. But no single border or surface should jump out at you. If borders are the first thing you notice, they're too strong. If you can't find where one region ends and another begins, they're too subtle.
**Common AI mistakes to avoid:**
- Borders that are too visible (1px solid gray instead of subtle rgba)
- Surface jumps that are too dramatic (going from dark to light instead of dark to slightly-less-dark)
- Using different hues for different surfaces (gray card on blue background)
- Harsh dividers where subtle borders would do
### Text Hierarchy via Tokens
Don't just have "text" and "gray text." Build four levels:
- **Primary** — default text, highest contrast
- **Secondary** — supporting text, slightly muted
- **Tertiary** — metadata, timestamps, less important
- **Muted** — disabled, placeholder, lowest contrast
Use all four consistently. If you're only using two, your hierarchy is too flat.
### Border Progression
Borders aren't binary. Build a scale:
- **Default** — standard borders
- **Subtle/Muted** — softer separation
- **Strong** — emphasis, hover states
- **Stronger** — maximum emphasis, focus rings
Match border intensity to the importance of the boundary.
### Dedicated Control Tokens
Form controls (inputs, checkboxes, selects) have specific needs. Don't just reuse surface tokens — create dedicated ones:
- **Control background** — often different from surface backgrounds
- **Control border** — needs to feel interactive
- **Control focus** — clear focus indication
This separation lets you tune controls independently from layout surfaces.
### Context-Aware Bases
Different areas of your app might need different base surfaces:
- **Marketing pages** — might use darker/richer backgrounds
- **Dashboard/app** — might use neutral working backgrounds
- **Sidebar** — might differ from main canvas
The surface hierarchy works the same way — it just starts from a different base.
### Alternative Backgrounds for Depth
Beyond shadows, use contrasting backgrounds to create depth. An "alternative" or "inset" background makes content feel recessed. Useful for:
- Empty states in data grids
- Code blocks
- Inset panels
- Visual grouping without borders
---
## Spacing System
Pick a base unit (4px and 8px are common) and use multiples throughout. The specific number matters less than consistency — every spacing value should be explainable as "X times the base unit."
Build a scale for different contexts:
- Micro spacing (icon gaps, tight element pairs)
- Component spacing (within buttons, inputs, cards)
- Section spacing (between related groups)
- Major separation (between distinct sections)
## Symmetrical Padding
TLBR must match. If top padding is 16px, left/bottom/right must also be 16px. Exception: when content naturally creates visual balance.
```css
/* Good */
padding: 16px;
padding: 12px 16px; /* Only when horizontal needs more room */
/* Bad */
padding: 24px 16px 12px 16px;
```
## Border Radius Consistency
Sharper corners feel technical, rounder corners feel friendly. Pick a scale that fits your product's personality and use it consistently.
The key is having a system: small radius for inputs and buttons, medium for cards, large for modals or containers. Don't mix sharp and soft randomly — inconsistent radius is as jarring as inconsistent spacing.
## Depth & Elevation Strategy
Match your depth approach to your design direction. Choose ONE and commit:
**Borders-only (flat)** — Clean, technical, dense. Works for utility-focused tools where information density matters more than visual lift. Linear, Raycast, and many developer tools use almost no shadows — just subtle borders to define regions.
**Subtle single shadows** — Soft lift without complexity. A simple `0 1px 3px rgba(0,0,0,0.08)` can be enough. Works for approachable products that want gentle depth.
**Layered shadows** — Rich, premium, dimensional. Multiple shadow layers create realistic depth. Stripe and Mercury use this approach. Best for cards that need to feel like physical objects.
**Surface color shifts** — Background tints establish hierarchy without any shadows. A card at `#fff` on a `#f8fafc` background already feels elevated.
```css
/* Borders-only approach */
--border: rgba(0, 0, 0, 0.08);
--border-subtle: rgba(0, 0, 0, 0.05);
border: 0.5px solid var(--border);
/* Single shadow approach */
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
/* Layered shadow approach */
--shadow-layered:
0 0 0 0.5px rgba(0, 0, 0, 0.05),
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 4px rgba(0, 0, 0, 0.03),
0 4px 8px rgba(0, 0, 0, 0.02);
```
## Card Layouts
Monotonous card layouts are lazy design. A metric card doesn't have to look like a plan card doesn't have to look like a settings card.
Design each card's internal structure for its specific content — but keep the surface treatment consistent: same border weight, shadow depth, corner radius, padding scale, typography.
## Isolated Controls
UI controls deserve container treatment. Date pickers, filters, dropdowns — these should feel like crafted objects.
**Never use native form elements for styled UI.** Native `<select>`, `<input type="date">`, and similar elements render OS-native dropdowns that cannot be styled. Build custom components instead:
- Custom select: trigger button + positioned dropdown menu
- Custom date picker: input + calendar popover
- Custom checkbox/radio: styled div with state management
Custom select triggers must use `display: inline-flex` with `white-space: nowrap` to keep text and chevron icons on the same row.
## Typography Hierarchy
Build distinct levels that are visually distinguishable at a glance:
- **Headlines** — heavier weight, tighter letter-spacing for presence
- **Body** — comfortable weight for readability
- **Labels/UI** — medium weight, works at smaller sizes
- **Data** — often monospace, needs `tabular-nums` for alignment
Don't rely on size alone. Combine size, weight, and letter-spacing to create clear hierarchy. If you squint and can't tell headline from body, the hierarchy is too weak.
## Monospace for Data
Numbers, IDs, codes, timestamps belong in monospace. Use `tabular-nums` for columnar alignment. Mono signals "this is data."
## Iconography
Icons clarify, not decorate — if removing an icon loses no meaning, remove it. Choose a consistent icon set and stick with it throughout the product.
Give standalone icons presence with subtle background containers. Icons next to text should align optically, not mathematically.
## Animation
Keep it fast and functional. Micro-interactions (hover, focus) should feel instant — around 150ms. Larger transitions (modals, panels) can be slightly longer — 200-250ms.
Use smooth deceleration easing (ease-out variants). Avoid spring/bounce effects in professional interfaces — they feel playful, not serious.
## Contrast Hierarchy
Build a four-level system: foreground (primary) → secondary → muted → faint. Use all four consistently.
## Color Carries Meaning
Gray builds structure. Color communicates — status, action, emphasis, identity. Unmotivated color is noise. Color that reinforces the product's world is character.
## Navigation Context
Screens need grounding. A data table floating in space feels like a component demo, not a product. Consider including:
- **Navigation** — sidebar or top nav showing where you are in the app
- **Location indicator** — breadcrumbs, page title, or active nav state
- **User context** — who's logged in, what workspace/org
When building sidebars, consider using the same background as the main content area. Rely on a subtle border for separation rather than different background colors.
## Dark Mode
Dark interfaces have different needs:
**Borders over shadows** — Shadows are less visible on dark backgrounds. Lean more on borders for definition.
**Adjust semantic colors** — Status colors (success, warning, error) often need to be slightly desaturated for dark backgrounds.
**Same structure, different values** — The hierarchy system still applies, just with inverted values.

View File

@@ -0,0 +1,48 @@
# Memory Management
When and how to update `.interface-design/system.md`.
## When to Add Patterns
Add to system.md when:
- Component used 2+ times
- Pattern is reusable across the project
- Has specific measurements worth remembering
## Pattern Format
```markdown
### Button Primary
- Height: 36px
- Padding: 12px 16px
- Radius: 6px
- Font: 14px, 500 weight
```
## Don't Document
- One-off components
- Temporary experiments
- Variations better handled with props
## Pattern Reuse
Before creating a component, check system.md:
- Pattern exists? Use it.
- Need variation? Extend, don't create new.
Memory compounds: each pattern saved makes future work faster and more consistent.
---
# Validation Checks
If system.md defines specific values, check consistency:
**Spacing** — All values multiples of the defined base?
**Depth** — Using the declared strategy throughout? (borders-only means no shadows)
**Colors** — Using defined palette, not random hex codes?
**Patterns** — Reusing documented patterns instead of creating new?

211
.interface-design/system.md Normal file
View File

@@ -0,0 +1,211 @@
# Design System - Playzer
## Direction
**Feel:** Technical but warm. Like a well-organized sports facility — functional, clean, but with life. Not cold like a trading terminal, not playful like a toy app.
**Users:** Owners and employees managing sports complex bookings. They're juggling schedules, phone calls, and walk-ins. The interface should stay out of the way while they're working.
---
## Domain
**Concepts:**
- Turno (booking slot) — A scheduled time block
- Cancha (court) — Sports court/pitch
- Horario — Time slot
- Complex — The sports facility
- Estado — Booking status (confirmed, cancelled, etc.)
**Color world:**
- Court green (grass/tennis)
- Clay tan (paddle court)
- Pool blue (swimming)
- Wood grain (locker rooms)
- Concrete gray (tech floor)
**Signature:**
- Status pills use subtle borders + background tint, not bold badges
- Status communicates without demanding attention — whisper-level hierarchy
---
## Decisions
### Depth
**Approach:** Borders-only with surface elevation shifts
- No drop shadows
- Higher elevation = slightly lighter surface (in dark: lighter = higher)
- Subtle rgba borders define edges quietly
### Spacing
**Base unit:** 4px
- Micro: 2-4px (icon gaps)
- Component: 8-12px (inside buttons, badges)
- Section: 16-24px (between groups)
- Major: 32-40px (between distinct areas)
### Typography
**Font:** Geist Variable
**Scale:**
- Display: 28px / semibold / -0.02em tracking
- Heading: 20px / semibold / -0.01em tracking
- Body: 14px / normal / 0 tracking
- Label: 12px / medium / 0 tracking
- Caption: 11px / normal / 0.02em tracking
### Border Radius
**Scale:**
- `sm`: 4px (inputs, small badges)
- `md`: 6px (buttons, cards)
- `lg`: 10px (dialogs, large cards)
- `xl`: 14px (modals)
### Borders
**Progression:**
- Subtle: `oklch(1 0 0 / 8%)` (background edges)
- Default: `oklch(1 0 0 / 12%)` (card borders)
- Emphasis: `oklch(1 0 0 / 20%)` (active states)
### Status Semantic Colors
**Light mode:**
- Confirmed: `bg-emerald-50 border-emerald-200 text-emerald-700`
- Pending: `bg-amber-50 border-amber-200 text-amber-700`
- Cancelled: `bg-rose-50 border-rose-200 text-rose-700`
- No-show: `bg-orange-50 border-orange-200 text-orange-700`
- Completed: `bg-sky-50 border-sky-200 text-sky-700`
**Dark mode:**
- Slight desaturation, lower opacity backgrounds
---
## Patterns
### Card
- Background: `bg-card`
- Border: `border` token
- Radius: `md`
- Padding: `p-4` (compact) / `p-5` (default)
### Status Badge
```tsx
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium">
{icon}
{label}
</span>
```
### Input
- Background: `bg-input` (darker than surface)
- No heavy borders by default
- Focus ring via `ring-3 ring-ring/50`
### Layout
- Sidebar: Same background as main, subtle border separation
- Content: Max-width 6xl, centered
- Padding: `px-4 sm:px-6`
---
## TODO
- [x] Status tokens in CSS variables
- [x] Refactor status badges to use semantic classes
- [x] StatusBadge component created
- [x] Home panel redesign with metrics + timeline
---
## Home Page Patterns (v2)
### Structure
- Header + URL Card: 2-column grid (lg:)
- Main: Timeline de reservas
- Sidebar: Stats grid consolidado (sticky)
### URL Card (destacada)
```tsx
<div className="group relative overflow-hidden rounded-2xl border border-blue-200/60 bg-blue-50/50 p-5">
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/80 to-blue-100/30" />
<div className="relative flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-blue-600/70">
Reserva online
</p>
<a href={url} className="mt-1 block truncate text-sm font-medium text-blue-700 underline-offset-2 hover:underline">
{url}
</a>
</div>
<button className="relative flex shrink-0 items-center gap-2 rounded-lg bg-blue-500/10 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-500/20">
<Copy className="size-4" />
<span>Copiar</span>
</button>
</div>
</div>
```
### Stats Pills (header)
```tsx
<span className="rounded-full border border-emerald-200/60 bg-emerald-50/60 px-3 py-1 text-xs font-medium text-emerald-700">
{count} hoy
</span>
```
### Stats Sidebar (grid 2x2)
```tsx
<section className="space-y-3 rounded-2xl border border-border/70 bg-card p-4">
<h2 className="text-xs font-semibold uppercase tracking-[0.28em] text-muted-foreground">
Resumen
</h2>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border border-border/50 bg-background/80 p-3">
<p className="text-xs text-muted-foreground">Total</p>
<p className="mt-1 text-xl font-semibold tracking-tight">{value}</p>
</div>
<div className="rounded-xl border border-emerald-200/40 bg-emerald-50/40 p-3">
<p className="text-xs text-emerald-700/80">Hoy</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-emerald-700">{value}</p>
</div>
{/* ... confirmed (blue), completed (sky) */}
</div>
</section>
```
### Timeline Item (v2 - mejorado)
```tsx
<div className={[
'group flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-3.5 transition-all hover:bg-muted/50',
isPast && 'opacity-50',
isNow && 'border-l-2 border-l-emerald-500 bg-emerald-50/30',
].join(' ')}>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex w-14 flex-col items-center">
<span className="text-sm font-medium tabular-nums">{startTime}</span>
<span className="text-[11px] text-muted-foreground">{endTime}</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{courtName}</p>
<p className="truncate text-xs text-muted-foreground">{customer}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<StatusBadge status={...} />
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button size="icon-xs" variant="ghost" title="Marcar cumplida"><Check className="size-3.5" /></Button>
<Button size="icon-xs" variant="ghost" title="Cancelar">×</Button>
</div>
</div>
</div>
```
### Date Group Header
```tsx
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold capitalize">{dateLabel}</h2>
<div className="h-px flex-1 border-t border-dashed border-border/40" />
<span className="shrink-0 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{count}
</span>
</div>
```

View File

@@ -82,6 +82,7 @@ model CourtBooking {
bookingDate DateTime @map("booking_date") @db.Date
startTime String @map("start_time") @db.VarChar(5)
endTime String @map("end_time") @db.VarChar(5)
price Decimal? @map("price") @db.Decimal(19, 2)
customerName String @map("customer_name") @db.VarChar(120)
customerPhone String @map("customer_phone") @db.VarChar(30)
status CourtBookingStatus @default(CONFIRMED)

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "court_bookings" ADD COLUMN "price" DECIMAL(19,2);

View File

@@ -280,6 +280,17 @@ export type EnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
}
export type DecimalNullableFilter<$PrismaModel = never> = {
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
not?: Prisma.NestedDecimalNullableFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
}
export type EnumCourtBookingStatusFilter<$PrismaModel = never> = {
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
@@ -287,6 +298,22 @@ export type EnumCourtBookingStatusFilter<$PrismaModel = never> = {
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
}
export type DecimalNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
not?: Prisma.NestedDecimalNullableWithAggregatesFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_sum?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_min?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_max?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
}
export type EnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
@@ -625,6 +652,17 @@ export type NestedEnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel = never
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
}
export type NestedDecimalNullableFilter<$PrismaModel = never> = {
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
not?: Prisma.NestedDecimalNullableFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
}
export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = {
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
@@ -632,6 +670,22 @@ export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = {
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
}
export type NestedDecimalNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
not?: Prisma.NestedDecimalNullableWithAggregatesFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_sum?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_min?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_max?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
}
export type NestedEnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>

File diff suppressed because one or more lines are too long

View File

@@ -1728,6 +1728,7 @@ export const CourtBookingScalarFieldEnum = {
bookingDate: 'bookingDate',
startTime: 'startTime',
endTime: 'endTime',
price: 'price',
customerName: 'customerName',
customerPhone: 'customerPhone',
status: 'status',

View File

@@ -245,6 +245,7 @@ export const CourtBookingScalarFieldEnum = {
bookingDate: 'bookingDate',
startTime: 'startTime',
endTime: 'endTime',
price: 'price',
customerName: 'customerName',
customerPhone: 'customerPhone',
status: 'status',

View File

@@ -20,10 +20,20 @@ export type CourtBookingModel = runtime.Types.Result.DefaultSelection<Prisma.$Co
export type AggregateCourtBooking = {
_count: CourtBookingCountAggregateOutputType | null
_avg: CourtBookingAvgAggregateOutputType | null
_sum: CourtBookingSumAggregateOutputType | null
_min: CourtBookingMinAggregateOutputType | null
_max: CourtBookingMaxAggregateOutputType | null
}
export type CourtBookingAvgAggregateOutputType = {
price: runtime.Decimal | null
}
export type CourtBookingSumAggregateOutputType = {
price: runtime.Decimal | null
}
export type CourtBookingMinAggregateOutputType = {
id: string | null
bookingCode: string | null
@@ -31,6 +41,7 @@ export type CourtBookingMinAggregateOutputType = {
bookingDate: Date | null
startTime: string | null
endTime: string | null
price: runtime.Decimal | null
customerName: string | null
customerPhone: string | null
status: $Enums.CourtBookingStatus | null
@@ -45,6 +56,7 @@ export type CourtBookingMaxAggregateOutputType = {
bookingDate: Date | null
startTime: string | null
endTime: string | null
price: runtime.Decimal | null
customerName: string | null
customerPhone: string | null
status: $Enums.CourtBookingStatus | null
@@ -59,6 +71,7 @@ export type CourtBookingCountAggregateOutputType = {
bookingDate: number
startTime: number
endTime: number
price: number
customerName: number
customerPhone: number
status: number
@@ -68,6 +81,14 @@ export type CourtBookingCountAggregateOutputType = {
}
export type CourtBookingAvgAggregateInputType = {
price?: true
}
export type CourtBookingSumAggregateInputType = {
price?: true
}
export type CourtBookingMinAggregateInputType = {
id?: true
bookingCode?: true
@@ -75,6 +96,7 @@ export type CourtBookingMinAggregateInputType = {
bookingDate?: true
startTime?: true
endTime?: true
price?: true
customerName?: true
customerPhone?: true
status?: true
@@ -89,6 +111,7 @@ export type CourtBookingMaxAggregateInputType = {
bookingDate?: true
startTime?: true
endTime?: true
price?: true
customerName?: true
customerPhone?: true
status?: true
@@ -103,6 +126,7 @@ export type CourtBookingCountAggregateInputType = {
bookingDate?: true
startTime?: true
endTime?: true
price?: true
customerName?: true
customerPhone?: true
status?: true
@@ -146,6 +170,18 @@ export type CourtBookingAggregateArgs<ExtArgs extends runtime.Types.Extensions.I
* Count returned CourtBookings
**/
_count?: true | CourtBookingCountAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
* Select which fields to average
**/
_avg?: CourtBookingAvgAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
* Select which fields to sum
**/
_sum?: CourtBookingSumAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
@@ -179,6 +215,8 @@ export type CourtBookingGroupByArgs<ExtArgs extends runtime.Types.Extensions.Int
take?: number
skip?: number
_count?: CourtBookingCountAggregateInputType | true
_avg?: CourtBookingAvgAggregateInputType
_sum?: CourtBookingSumAggregateInputType
_min?: CourtBookingMinAggregateInputType
_max?: CourtBookingMaxAggregateInputType
}
@@ -190,12 +228,15 @@ export type CourtBookingGroupByOutputType = {
bookingDate: Date
startTime: string
endTime: string
price: runtime.Decimal | null
customerName: string
customerPhone: string
status: $Enums.CourtBookingStatus
createdAt: Date
updatedAt: Date
_count: CourtBookingCountAggregateOutputType | null
_avg: CourtBookingAvgAggregateOutputType | null
_sum: CourtBookingSumAggregateOutputType | null
_min: CourtBookingMinAggregateOutputType | null
_max: CourtBookingMaxAggregateOutputType | null
}
@@ -225,6 +266,7 @@ export type CourtBookingWhereInput = {
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
startTime?: Prisma.StringFilter<"CourtBooking"> | string
endTime?: Prisma.StringFilter<"CourtBooking"> | string
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
@@ -240,6 +282,7 @@ export type CourtBookingOrderByWithRelationInput = {
bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder
price?: Prisma.SortOrderInput | Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
status?: Prisma.SortOrder
@@ -259,6 +302,7 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
startTime?: Prisma.StringFilter<"CourtBooking"> | string
endTime?: Prisma.StringFilter<"CourtBooking"> | string
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
@@ -274,14 +318,17 @@ export type CourtBookingOrderByWithAggregationInput = {
bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder
price?: Prisma.SortOrderInput | Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
_count?: Prisma.CourtBookingCountOrderByAggregateInput
_avg?: Prisma.CourtBookingAvgOrderByAggregateInput
_max?: Prisma.CourtBookingMaxOrderByAggregateInput
_min?: Prisma.CourtBookingMinOrderByAggregateInput
_sum?: Prisma.CourtBookingSumOrderByAggregateInput
}
export type CourtBookingScalarWhereWithAggregatesInput = {
@@ -294,6 +341,7 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
bookingDate?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
startTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
price?: Prisma.DecimalNullableWithAggregatesFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
@@ -307,6 +355,7 @@ export type CourtBookingCreateInput = {
bookingDate: Date | string
startTime: string
endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string
customerPhone: string
status?: $Enums.CourtBookingStatus
@@ -322,6 +371,7 @@ export type CourtBookingUncheckedCreateInput = {
bookingDate: Date | string
startTime: string
endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string
customerPhone: string
status?: $Enums.CourtBookingStatus
@@ -335,6 +385,7 @@ export type CourtBookingUpdateInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
@@ -350,6 +401,7 @@ export type CourtBookingUncheckedUpdateInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
@@ -364,6 +416,7 @@ export type CourtBookingCreateManyInput = {
bookingDate: Date | string
startTime: string
endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string
customerPhone: string
status?: $Enums.CourtBookingStatus
@@ -377,6 +430,7 @@ export type CourtBookingUpdateManyMutationInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
@@ -391,6 +445,7 @@ export type CourtBookingUncheckedUpdateManyInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
@@ -421,6 +476,7 @@ export type CourtBookingCountOrderByAggregateInput = {
bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder
price?: Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
status?: Prisma.SortOrder
@@ -428,6 +484,10 @@ export type CourtBookingCountOrderByAggregateInput = {
updatedAt?: Prisma.SortOrder
}
export type CourtBookingAvgOrderByAggregateInput = {
price?: Prisma.SortOrder
}
export type CourtBookingMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
bookingCode?: Prisma.SortOrder
@@ -435,6 +495,7 @@ export type CourtBookingMaxOrderByAggregateInput = {
bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder
price?: Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
status?: Prisma.SortOrder
@@ -449,6 +510,7 @@ export type CourtBookingMinOrderByAggregateInput = {
bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder
price?: Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
status?: Prisma.SortOrder
@@ -456,6 +518,10 @@ export type CourtBookingMinOrderByAggregateInput = {
updatedAt?: Prisma.SortOrder
}
export type CourtBookingSumOrderByAggregateInput = {
price?: Prisma.SortOrder
}
export type CourtBookingCreateNestedManyWithoutCourtInput = {
create?: Prisma.XOR<Prisma.CourtBookingCreateWithoutCourtInput, Prisma.CourtBookingUncheckedCreateWithoutCourtInput> | Prisma.CourtBookingCreateWithoutCourtInput[] | Prisma.CourtBookingUncheckedCreateWithoutCourtInput[]
connectOrCreate?: Prisma.CourtBookingCreateOrConnectWithoutCourtInput | Prisma.CourtBookingCreateOrConnectWithoutCourtInput[]
@@ -498,6 +564,14 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtNestedInput = {
deleteMany?: Prisma.CourtBookingScalarWhereInput | Prisma.CourtBookingScalarWhereInput[]
}
export type NullableDecimalFieldUpdateOperationsInput = {
set?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
increment?: runtime.Decimal | runtime.DecimalJsLike | number | string
decrement?: runtime.Decimal | runtime.DecimalJsLike | number | string
multiply?: runtime.Decimal | runtime.DecimalJsLike | number | string
divide?: runtime.Decimal | runtime.DecimalJsLike | number | string
}
export type EnumCourtBookingStatusFieldUpdateOperationsInput = {
set?: $Enums.CourtBookingStatus
}
@@ -508,6 +582,7 @@ export type CourtBookingCreateWithoutCourtInput = {
bookingDate: Date | string
startTime: string
endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string
customerPhone: string
status?: $Enums.CourtBookingStatus
@@ -521,6 +596,7 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
bookingDate: Date | string
startTime: string
endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string
customerPhone: string
status?: $Enums.CourtBookingStatus
@@ -564,6 +640,7 @@ export type CourtBookingScalarWhereInput = {
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
startTime?: Prisma.StringFilter<"CourtBooking"> | string
endTime?: Prisma.StringFilter<"CourtBooking"> | string
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
@@ -577,6 +654,7 @@ export type CourtBookingCreateManyCourtInput = {
bookingDate: Date | string
startTime: string
endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string
customerPhone: string
status?: $Enums.CourtBookingStatus
@@ -590,6 +668,7 @@ export type CourtBookingUpdateWithoutCourtInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
@@ -603,6 +682,7 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
@@ -616,6 +696,7 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
@@ -632,6 +713,7 @@ export type CourtBookingSelect<ExtArgs extends runtime.Types.Extensions.Internal
bookingDate?: boolean
startTime?: boolean
endTime?: boolean
price?: boolean
customerName?: boolean
customerPhone?: boolean
status?: boolean
@@ -647,6 +729,7 @@ export type CourtBookingSelectCreateManyAndReturn<ExtArgs extends runtime.Types.
bookingDate?: boolean
startTime?: boolean
endTime?: boolean
price?: boolean
customerName?: boolean
customerPhone?: boolean
status?: boolean
@@ -662,6 +745,7 @@ export type CourtBookingSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.
bookingDate?: boolean
startTime?: boolean
endTime?: boolean
price?: boolean
customerName?: boolean
customerPhone?: boolean
status?: boolean
@@ -677,6 +761,7 @@ export type CourtBookingSelectScalar = {
bookingDate?: boolean
startTime?: boolean
endTime?: boolean
price?: boolean
customerName?: boolean
customerPhone?: boolean
status?: boolean
@@ -684,7 +769,7 @@ export type CourtBookingSelectScalar = {
updatedAt?: boolean
}
export type CourtBookingOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingCode" | "courtId" | "bookingDate" | "startTime" | "endTime" | "customerName" | "customerPhone" | "status" | "createdAt" | "updatedAt", ExtArgs["result"]["courtBooking"]>
export type CourtBookingOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingCode" | "courtId" | "bookingDate" | "startTime" | "endTime" | "price" | "customerName" | "customerPhone" | "status" | "createdAt" | "updatedAt", ExtArgs["result"]["courtBooking"]>
export type CourtBookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
court?: boolean | Prisma.CourtDefaultArgs<ExtArgs>
}
@@ -707,6 +792,7 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
bookingDate: Date
startTime: string
endTime: string
price: runtime.Decimal | null
customerName: string
customerPhone: string
status: $Enums.CourtBookingStatus
@@ -1142,6 +1228,7 @@ export interface CourtBookingFieldRefs {
readonly bookingDate: Prisma.FieldRef<"CourtBooking", 'DateTime'>
readonly startTime: Prisma.FieldRef<"CourtBooking", 'String'>
readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'>
readonly price: Prisma.FieldRef<"CourtBooking", 'Decimal'>
readonly customerName: Prisma.FieldRef<"CourtBooking", 'String'>
readonly customerPhone: Prisma.FieldRef<"CourtBooking", 'String'>
readonly status: Prisma.FieldRef<"CourtBooking", 'CourtBookingStatus'>

View File

@@ -202,6 +202,10 @@ async function getComplexWithBookingData(complexSlug: string) {
availabilities: {
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
},
orderBy: { createdAt: 'asc' },
},
@@ -233,6 +237,7 @@ function mapBookingResponse(input: {
createdAt: Date;
startTime: string;
endTime: string;
price: number;
customerName: string;
customerPhone: string;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
@@ -261,6 +266,7 @@ function mapBookingResponse(input: {
date: formatIsoDate(input.bookingDate),
startTime: input.startTime,
endTime: input.endTime,
price: input.price,
customerName: input.customerName,
customerPhone: input.customerPhone,
status: input.status,
@@ -284,6 +290,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
bookingDate: true,
startTime: true,
endTime: true,
price: true,
status: true,
createdAt: true,
court: {
@@ -325,6 +332,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
slug: booking.court.sport.slug,
},
status: booking.status,
price: Number(booking.price),
createdAt: booking.createdAt.toISOString(),
};
}
@@ -397,6 +405,50 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied))
);
const basePrice = Number(court.basePrice);
const priceRuleMap = new Map<string, number>();
for (const rule of court.priceRules) {
const ruleDay = rule.dayOfWeek;
const ruleStart = rule.startTime;
const ruleEnd = rule.endTime;
const price = Number(rule.price);
if (ruleStart && ruleEnd && ruleDay) {
const startMin = toMinutes(ruleStart);
const endMin = toMinutes(ruleEnd);
for (let mins = startMin; mins < endMin; mins += 30) {
priceRuleMap.set(`${ruleDay}-${minutesToTime(mins)}`, price);
}
} else if (ruleDay && !ruleStart && !ruleEnd) {
priceRuleMap.set(`day-${ruleDay}`, price);
}
}
const calcPrice = (startTime: string) => {
if (!basePrice || basePrice <= 0) {
return 0;
}
const timeKey = `${dayOfWeek}-${startTime}`;
const dayKey = `day-${dayOfWeek}`;
return priceRuleMap.get(timeKey) ?? priceRuleMap.get(dayKey) ?? basePrice;
};
const price =
availableSlots.length > 0
? (() => {
const prices = availableSlots.map((slot) => calcPrice(slot.startTime));
const validPrices = prices.filter((p) => p > 0);
if (validPrices.length === 0) {
return undefined;
}
return {
min: Math.min(...validPrices),
max: Math.max(...validPrices),
};
})()
: undefined;
return {
courtId: court.id,
courtName: court.name,
@@ -408,6 +460,7 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
slotDurationMinutes: court.slotDurationMinutes,
availabilityDay: dayOfWeek,
availableSlots,
price,
};
})
.filter((court) => court.availableSlots.length > 0);
@@ -476,6 +529,60 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
);
}
function calcBookingPrice(
court: {
basePrice: number | null;
priceRules: Array<{
dayOfWeek: string | null;
startTime: string | null;
endTime: string | null;
price: number;
}>;
},
dayOfWeek: string,
startTime: string
): number {
if (!court.basePrice) {
return 0;
}
const slotMinutes = toMinutes(startTime);
const exactMatch = court.priceRules.find((rule) => {
if (!rule.dayOfWeek || rule.dayOfWeek !== dayOfWeek) {
return false;
}
if (!rule.startTime || !rule.endTime) {
return false;
}
const ruleStart = toMinutes(rule.startTime);
const ruleEnd = toMinutes(rule.endTime);
return slotMinutes >= ruleStart && slotMinutes < ruleEnd;
});
if (exactMatch) {
return Number(exactMatch.price);
}
const dayMatch = court.priceRules.find((rule) => {
if (!rule.dayOfWeek || rule.dayOfWeek !== dayOfWeek) {
return false;
}
if (rule.startTime || rule.endTime) {
return false;
}
return true;
});
if (dayMatch) {
return Number(dayMatch.price);
}
return Number(court.basePrice);
}
const bookingPrice = calcBookingPrice(selectedCourt, dayOfWeek, selectedSlot.startTime);
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const booking = await db.$transaction(async (tx) => {
@@ -531,6 +638,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
bookingDate,
startTime: selectedSlot.startTime,
endTime: selectedSlot.endTime,
price: bookingPrice,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
status: 'CONFIRMED',
@@ -542,6 +650,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
createdAt: true,
startTime: true,
endTime: true,
price: true,
customerName: true,
customerPhone: true,
status: true,
@@ -556,6 +665,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
createdAt: booking.createdAt,
startTime: booking.startTime,
endTime: booking.endTime,
price: Number(booking.price),
customerName: booking.customerName,
customerPhone: booking.customerPhone,
status: booking.status,

View File

@@ -1,11 +1,9 @@
import { beforeEach, expect, mock, test } from 'bun:test';
import type { InviteComplexUserResponse } from '@repo/api-contract';
import { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
import type { InviteComplexUserResponse } from '@repo/api-contract';
const inviteComplexUserMock = mock(
async () => undefined as unknown as InviteComplexUserResponse
);
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
class MockComplexMembersError extends Error {
status: 400 | 403 | 404 | 409;

View File

@@ -1,10 +1,10 @@
import { beforeEach, expect, mock, test } from 'bun:test';
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
import {
ComplexMembersError,
inviteComplexUser,
} from '@/modules/complex/services/complex-members.service';
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
beforeEach(() => {
prismaMock._reset();

View File

@@ -5,14 +5,16 @@ import type { PrismaClient } from '@/generated/prisma/client';
import type { PrismaClientMock } from 'bun-mock-prisma';
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
export const sendMailMock = mock(async (_input: {
to: string;
subject: string;
html: string;
text: string;
}) => undefined);
export const transactionMock = mock(async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) =>
fn(prismaMock)
export const sendMailMock = mock(
async (_input: {
to: string;
subject: string;
html: string;
text: string;
}) => undefined
);
export const transactionMock = mock(
async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) => fn(prismaMock)
);
export const dbMock = {
complexUser: prismaMock.complexUser,

View File

@@ -15,10 +15,12 @@
"@better-auth/infra": "^0.2.4",
"@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-slot": "^1.2.4",
"@repo/api-contract": "workspace:*",
"@tabler/icons-react": "^3.41.1",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-query-devtools": "^5.96.1",
"@tanstack/react-router": "^1.168.10",

View File

@@ -0,0 +1,34 @@
import { type LucideProps } from 'lucide-react';
import { forwardRef } from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SoccerBallIcon = forwardRef<any, LucideProps>(({ className, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
className={className}
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2c.8 2.2 2.2 4.2 4 5.8" />
<path d="M12 2c-.8 2.2-2.2 4.2-4 5.8" />
<path d="M2 12c2.2.8 4.2 2.2 5.8 4" />
<path d="M2 12c2.2-.8 4.2-2.2 5.8-4" />
<path d="M22 12c-2.2.8-4.2 2.2-5.8 4" />
<path d="M22 12c-2.2-.8-4.2-2.2-5.8-4" />
<path d="M12 22c.8-2.2 2.2-4.2 4-5.8" />
<path d="M12 22c-.8-2.2-2.2-4.2-4-5.8" />
</svg>
));
export { SoccerBallIcon as FootballIcon };

View File

@@ -0,0 +1,113 @@
import React from 'react';
// Types for the component
type Slot = {
startTime: string;
endTime: string;
};
type Court = {
courtId: string;
courtName: string;
sport: { id: string; name: string; slug: string };
slotDurationMinutes: number;
availableSlots: Slot[];
};
type SelectedSlot = {
courtId: string;
startTime: string;
} | null;
type AvailabilityHeatMapProps = {
courts: Court[];
selectedSlot: SelectedSlot;
onSelectSlot: (payload: {
courtId: string;
courtName: string;
sportId: string;
sportName: string;
startTime: string;
endTime: string;
price?: { min: number; max: number };
}) => void;
};
/**
* AvailabilityHeatMap
*
* A visual heatmap style component that displays each court as a card and colours the
* slot buttons according to how many slots are available for that court. The colour scale
* goes from green (few slots) to red (many slots), providing an immediate visual cue of
* density the signature element requested for the bookings panel.
*/
export default function AvailabilityHeatMap({
courts,
selectedSlot,
onSelectSlot,
}: AvailabilityHeatMapProps) {
// Determine the maximum number of slots among all courts to normalise the heat scale.
const maxSlots = Math.max(1, ...courts.map((c) => c.availableSlots.length));
// Convert a slot count into an HSL colour ranging from green (low) to red (high).
const slotCountToHue = (count: number) => {
const ratio = count / maxSlots; // 0 => green, 1 => red
const hue = Math.round(120 - 120 * ratio); // 120° (green) to 0° (red)
return `hsl(${hue}, 70%, 55%)`;
};
return (
<div className="grid gap-4">
{courts.map((court) => {
const bg = slotCountToHue(court.availableSlots.length);
return (
<div
key={court.courtId}
className="rounded-2xl border border-border/70 bg-background p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-2">
<p className="font-semibold text-foreground">{court.courtName}</p>
<span className="text-xs text-muted-foreground">
{court.availableSlots.length} horarios
</span>
</div>
<div className="grid grid-cols-4 gap-2">
{court.availableSlots.map((slot) => {
const isSelected =
selectedSlot?.courtId === court.courtId &&
selectedSlot?.startTime === slot.startTime;
return (
<button
key={slot.startTime}
type="button"
onClick={() =>
onSelectSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
})
}
className={`h-10 rounded-xl border text-sm font-medium transition-all ${
isSelected
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border/70 hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
}`}
style={{
backgroundColor: isSelected ? undefined : bg,
opacity: isSelected ? 1 : 0.8,
}}
>
{slot.startTime}
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -5,13 +5,13 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
'border-border bg-card shadow-sm hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/30',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
@@ -22,16 +22,15 @@ const buttonVariants = cva(
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
xs: "h-6 gap-1 rounded-lg px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-lg px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-10 gap-1.5 rounded-xl px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-9 rounded-xl',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
"size-6 rounded-lg in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-lg in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-10 rounded-xl',
},
},
defaultVariants: {

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
'h-10 w-full min-w-0 rounded-xl border border-input bg-card px-3 py-2 text-base shadow-[0_1px_0_rgba(15,23,42,0.02)] transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/20 dark:disabled:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}

View File

@@ -36,6 +36,7 @@ function SelectTrigger({
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-9 data-[size=sm]:rounded-lg *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/20 dark:hover:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -61,7 +62,7 @@ function SelectContent({
data-slot="select-content"
data-align-trigger={position === 'item-aligned'}
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
@@ -105,7 +106,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-1.5 rounded-lg py-2 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}

View File

@@ -0,0 +1,49 @@
import { cn } from '@/lib/utils';
import { type LucideIcon } from 'lucide-react';
type StatusType = 'CONFIRMED' | 'PENDING' | 'CANCELLED' | 'NO_SHOW' | 'COMPLETED';
type StatusBadgeProps = {
status: StatusType;
label: string;
icon?: LucideIcon;
className?: string;
};
const statusStyles: Record<StatusType, string> = {
CONFIRMED: 'bg-status-confirmed-bg border-status-confirmed-border text-status-confirmed-text',
PENDING: 'bg-status-pending-bg border-status-pending-border text-status-pending-text',
CANCELLED: 'bg-status-cancelled-bg border-status-cancelled-border text-status-cancelled-text',
NO_SHOW: 'bg-status-noshow-bg border-status-noshow-border text-status-noshow-text',
COMPLETED: 'bg-status-completed-bg border-status-completed-border text-status-completed-text',
};
export function StatusBadge({ status, label, icon: Icon, className }: StatusBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium',
statusStyles[status],
className
)}
>
{Icon && <Icon className="size-3" />}
{label}
</span>
);
}
export function effectiveStatusClassName(
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'
): string {
return statusStyles[status];
}
export function effectiveStatusLabel(
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'
): string {
if (status === 'NO_SHOW') return 'No show';
if (status === 'COMPLETED') return 'Cumplida';
if (status === 'CANCELLED') return 'Cancelada';
return 'Confirmada';
}

View File

@@ -43,19 +43,30 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
];
return (
<div className="mx-auto w-full max-w-6xl px-4 py-4 sm:px-6">
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h2 className="text-2xl font-semibold">
<Settings className="mb-1 mr-2 inline size-6" />
Configuración del complejo
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{complexQuery.data?.complexName ?? 'Configura los datos de tu complejo'}
</p>
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
<section className="overflow-hidden rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm sm:p-8">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
<span className="size-2 rounded-full bg-primary" />
Administración del complejo
</div>
<h2 className="text-3xl font-semibold tracking-tight">
<Settings className="mb-1 mr-2 inline size-6" />
Configuración del complejo
</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
{complexQuery.data?.complexName ??
'Configurá los datos del complejo, las canchas y los usuarios.'}
</p>
</div>
</div>
</section>
<div className="mt-6 grid gap-6 lg:grid-cols-[200px_1fr]">
<nav className="flex flex-row gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
<div className="mt-6 grid gap-6 lg:grid-cols-[220px_1fr]">
<nav className="flex flex-row gap-2 overflow-x-auto rounded-2xl border border-border/70 bg-card p-2 shadow-sm lg:flex-col lg:overflow-visible">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
@@ -64,11 +75,10 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors lg:w-full lg:justify-start ${
isActive
? 'bg-muted text-foreground'
className={`inline-flex items-center gap-2 rounded-2xl px-3 py-3 text-sm font-medium transition-all lg:w-full lg:justify-start ${isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
}`}
>
<Icon className="size-4" />
{tab.label}
@@ -77,7 +87,7 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
})}
</nav>
<div className="min-w-0">
<div className="min-w-0 rounded-2xl border border-border/70 bg-card p-4 shadow-sm sm:p-6">
{activeTab === 'details' && (
<ComplexDetailsSection
complex={complexQuery.data ?? null}

View File

@@ -1,3 +1,4 @@
import AvailabilityHeatMap from '@/components/ui/availability-heatmap';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
@@ -19,6 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { StatusBadge } from '@/components/ui/status-badge';
import { ApiClientError, apiClient } from '@/lib/api-client';
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -66,11 +68,23 @@ function extractMessage(error: unknown, fallback: string) {
return fallback;
}
function formatDateLabel(dateIso: string) {
function formatDateLabel(dateIso: string, todayIso: string) {
const [year, month, day] = dateIso.split('-').map(Number);
if (!year || !month || !day) return dateIso;
const [tYear, tMonth, tDay] = todayIso.split('-').map(Number);
const isToday = year === tYear && month === tMonth && day === tDay;
const tmrw = new Date(tYear, tMonth - 1, tDay);
tmrw.setDate(tmrw.getDate() + 1);
const isTomorrow =
year === tmrw.getFullYear() && month === tmrw.getMonth() + 1 && day === tmrw.getDate();
if (isToday) return 'Hoy';
if (isTomorrow) return 'Mañana';
const date = new Date(year, month - 1, day);
return new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
@@ -104,21 +118,6 @@ function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' |
return 'Confirmada';
}
function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') {
if (status === 'NO_SHOW') {
return 'border-orange-200 bg-orange-50 text-orange-700';
}
if (status === 'COMPLETED') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700';
}
if (status === 'CANCELLED') {
return 'border-rose-200 bg-rose-50 text-rose-700';
}
return 'border-sky-200 bg-sky-50 text-sky-700';
}
export function HomePage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -129,8 +128,12 @@ export function HomePage() {
const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
const [selectedCourtId, setSelectedCourtId] = useState<string>('');
const [selectedStartTime, setSelectedStartTime] = useState<string>('');
const [selectedSlot, setSelectedSlot] = useState<{ courtId: string; startTime: string } | null>(
null
);
const [isManualBookingOpen, setIsManualBookingOpen] = useState(false);
const [urlCopied, setUrlCopied] = useState(false);
const [bookingToCancel, setBookingToCancel] = useState<AdminBooking | null>(null);
const currentComplexQuery = useQuery({
queryKey: ['current-complex'],
@@ -311,6 +314,7 @@ export function HomePage() {
) {
setSelectedCourtId('');
setSelectedStartTime('');
setSelectedSlot(null);
}
}, [manualAvailabilityQuery.data, selectedCourtId]);
@@ -321,11 +325,13 @@ export function HomePage() {
useEffect(() => {
if (!selectedCourt) {
setSelectedStartTime('');
setSelectedSlot(null);
return;
}
if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) {
setSelectedStartTime('');
setSelectedSlot(null);
}
}, [selectedCourt, selectedStartTime]);
@@ -341,10 +347,29 @@ export function HomePage() {
return [...groups.entries()];
}, [bookingsQuery.data?.bookings]);
const dashboardStats = useMemo(() => {
const bookings = bookingsQuery.data?.bookings ?? [];
const todayBookings = bookings.filter((booking) => booking.date === todayIso);
const confirmedBookings = bookings.filter(
(booking) => getEffectiveStatus(booking) === 'CONFIRMED'
);
const completedBookings = bookings.filter(
(booking) => getEffectiveStatus(booking) === 'COMPLETED'
);
return {
total: bookings.length,
today: todayBookings.length,
confirmed: confirmedBookings.length,
completed: completedBookings.length,
};
}, [bookingsQuery.data?.bookings, todayIso]);
const updateStatusMutation = useMutation({
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) =>
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
onSuccess: () => {
setBookingToCancel(null);
void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
void queryClient.invalidateQueries({
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug],
@@ -352,6 +377,18 @@ export function HomePage() {
},
});
function handleCancelBooking(booking: AdminBooking) {
setBookingToCancel(booking);
}
function confirmCancelBooking() {
if (!bookingToCancel) return;
updateStatusMutation.mutate({
bookingId: bookingToCancel.id,
status: 'CANCELLED',
});
}
const {
register,
handleSubmit,
@@ -388,6 +425,7 @@ export function HomePage() {
reset();
setSelectedCourtId('');
setSelectedStartTime('');
setSelectedSlot(null);
setIsManualBookingOpen(false);
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
@@ -439,348 +477,439 @@ export function HomePage() {
);
}
return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-6">
<section className="rounded-xl border bg-card p-5 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Panel de reservas</h1>
<p className="mt-2 text-sm text-muted-foreground">
Gestiona turnos del día y futuros para {selectedComplex.complexName}.
</p>
<div className="mt-2 flex items-center gap-2">
<a
href={publicBookingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground underline hover:text-foreground"
>
{publicBookingUrl}
</a>
<button
type="button"
onClick={copyToClipboard}
className="text-muted-foreground hover:text-foreground"
title="Copiar URL"
>
{urlCopied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
const manualBookingDialog = (
<ResponsiveDialog
open={isManualBookingOpen}
onOpenChange={(open) => {
setIsManualBookingOpen(open);
if (!open) {
reset();
setManualDate(todayIso);
setSelectedSportId(undefined);
setSelectedCourtId('');
setSelectedStartTime('');
setSelectedSlot(null);
}
}}
>
<ResponsiveDialogTrigger asChild>
<Button className="w-full">Nueva reserva</Button>
</ResponsiveDialogTrigger>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-lg"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Reserva manual</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Crea un turno para atención telefónica o mostrador.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<form
id="manual-booking-form"
className="grid gap-4"
onSubmit={handleSubmit(onSubmitManualBooking)}
>
<div className="grid gap-3">
<Field>
<FieldLabel htmlFor="manualDate">Fecha</FieldLabel>
<DatePicker
value={fromIsoDateLocal(manualDate)}
onChange={(date) => {
const isoDate = date ? toIsoDateLocal(date) : todayIso;
setManualDate(isoDate);
setSelectedCourtId('');
setSelectedStartTime('');
setSelectedSlot(null);
}}
minDate={new Date()}
placeholder="Selecciona una fecha"
/>
</Field>
{manualAvailabilityQuery.data?.sportSelectionRequired && (
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select
value={selectedSportId ?? ''}
onValueChange={(value) => {
setSelectedSportId(value);
setSelectedCourtId('');
setSelectedStartTime('');
setSelectedSlot(null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{manualAvailabilityQuery.data?.sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{manualAvailabilityQuery.data?.courts && (
<Field>
<FieldLabel>Disponibilidad</FieldLabel>
<AvailabilityHeatMap
courts={manualAvailabilityQuery.data.courts}
selectedSlot={selectedSlot}
onSelectSlot={({ courtId, startTime }) => {
setSelectedCourtId(courtId);
setSelectedStartTime(startTime);
setSelectedSlot({ courtId, startTime });
}}
/>
</Field>
)}
</div>
<ResponsiveDialog
open={isManualBookingOpen}
onOpenChange={(open) => {
setIsManualBookingOpen(open);
if (!open) {
reset();
setManualDate(todayIso);
setSelectedSportId(undefined);
setSelectedCourtId('');
setSelectedStartTime('');
}
}}
{manualAvailabilityQuery.isLoading && (
<p className="text-sm text-muted-foreground">Cargando horarios disponibles...</p>
)}
{manualAvailabilityQuery.isError && (
<p className="text-sm text-destructive">
{extractMessage(
manualAvailabilityQuery.error,
'No pudimos cargar disponibilidad para la reserva manual.'
)}
</p>
)}
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
{createManualBookingMutation.isError && (
<p className="text-sm text-destructive">
{extractMessage(
createManualBookingMutation.error,
'No pudimos crear la reserva manual.'
)}
</p>
)}
</form>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Cancelar</Button>
</ResponsiveDialogClose>
<Button
type="submit"
form="manual-booking-form"
disabled={
!isValid ||
createManualBookingMutation.isPending ||
!selectedCourtId ||
!selectedStartTime
}
>
<ResponsiveDialogTrigger asChild>
<Button>Nueva Reserva</Button>
</ResponsiveDialogTrigger>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-lg"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Reserva manual</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Crea un turno para atención telefónica o mostrador.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
{createManualBookingMutation.isPending ? 'Guardando...' : 'Crear reserva'}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
<form
id="manual-booking-form"
className="grid gap-4"
onSubmit={handleSubmit(onSubmitManualBooking)}
>
<div className="grid gap-3 md:grid-cols-2">
<Field>
<FieldLabel htmlFor="manualDate">Fecha</FieldLabel>
<DatePicker
value={fromIsoDateLocal(manualDate)}
onChange={(date) => {
const isoDate = date ? toIsoDateLocal(date) : todayIso;
setManualDate(isoDate);
setSelectedCourtId('');
setSelectedStartTime('');
}}
minDate={new Date()}
placeholder="Selecciona una fecha"
/>
</Field>
const urlCard = (
<div className="group relative overflow-hidden rounded-2xl border border-blue-200/60 bg-blue-50/50 p-5">
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/80 to-blue-100/30" />
<div className="relative flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-blue-600/70">
Reserva online
</p>
<a
href={publicBookingUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 block truncate text-sm font-medium text-blue-700 underline-offset-2 hover:underline"
>
{publicBookingUrl}
</a>
</div>
<button
type="button"
onClick={copyToClipboard}
className="relative flex shrink-0 items-center gap-2 rounded-lg bg-blue-500/10 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-500/20"
title="Copiar URL"
>
{urlCopied ? (
<>
<Check className="size-4" />
<span>Copiado</span>
</>
) : (
<>
<Copy className="size-4" />
<span>Copiar</span>
</>
)}
</button>
</div>
</div>
);
{manualAvailabilityQuery.data?.sportSelectionRequired && (
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select
value={selectedSportId ?? ''}
onValueChange={(value) => {
setSelectedSportId(value);
setSelectedCourtId('');
setSelectedStartTime('');
}}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{manualAvailabilityQuery.data?.sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
const headerPills = (
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-200/60 bg-emerald-50/60 px-3 py-1 text-xs font-medium text-emerald-700">
{dashboardStats.today} hoy
</span>
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
{dashboardStats.confirmed} confirmadas
</span>
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
{dashboardStats.completed} cumplidas
</span>
</div>
);
<Field>
<FieldLabel>Cancha</FieldLabel>
<Select
value={selectedCourtId}
onValueChange={(value) => {
setSelectedCourtId(value);
setSelectedStartTime('');
}}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona una cancha" />
</SelectTrigger>
<SelectContent>
{(manualAvailabilityQuery.data?.courts ?? []).map((court) => (
<SelectItem key={court.courtId} value={court.courtId}>
{court.courtName} · {court.sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
const dateSelector = (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<Field>
<FieldLabel htmlFor="fromDate">Desde</FieldLabel>
<DatePicker
value={fromIsoDateLocal(fromDate)}
onChange={(date) => {
const isoDate = date ? toIsoDateLocal(date) : todayIso;
setFromDate(isoDate);
}}
disabled={(date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0);
return checkDate < today;
}}
placeholder="Selecciona"
/>
</Field>
</div>
);
<Field>
<FieldLabel>Horario</FieldLabel>
<Select value={selectedStartTime} onValueChange={setSelectedStartTime}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un horario" />
</SelectTrigger>
<SelectContent>
{(selectedCourt?.availableSlots ?? []).map((slot) => {
const isToday = manualDate === todayIso;
const currentTime = new Date();
const currentHours = currentTime.getHours().toString().padStart(2, '0');
const currentMinutes = currentTime
.getMinutes()
.toString()
.padStart(2, '0');
const currentTimeStr = `${currentHours}:${currentMinutes}`;
const isPastSlot = isToday && slot.startTime <= currentTimeStr;
if (isPastSlot) return null;
return (
<SelectItem key={slot.startTime} value={slot.startTime}>
{slot.startTime}
</SelectItem>
);
})}
</SelectContent>
</Select>
</Field>
return (
<main className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
<div className="space-y-5">
<div className="grid gap-4 lg:grid-cols-[1fr_1fr]">
<section className="overflow-hidden rounded-2xl border border-border/70 bg-card p-5 sm:p-6">
<div className="space-y-4">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
Panel de reservas
</h1>
<p className="text-sm text-muted-foreground">
Gestioná turnos de {selectedComplex.complexName}
</p>
</div>
{headerPills}
</div>
</section>
<div className="space-y-4">
{urlCard}
{manualBookingDialog}
</div>
</div>
<section className="rounded-2xl border border-border/70 bg-card p-4 sm:p-5">
{dateSelector}
{bookingsQuery.isLoading && (
<p className="mt-4 text-sm text-muted-foreground">Cargando reservas...</p>
)}
{bookingsQuery.isError && (
<p className="mt-4 text-sm text-destructive">
{extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')}
</p>
)}
{!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
<p className="mt-4 text-sm text-muted-foreground">
No hay reservas para la fecha seleccionada.
</p>
)}
<div className="mt-4 space-y-6">
{groupedBookings.map(([date, bookings]) => (
<article key={date}>
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold capitalize">
{formatDateLabel(date, todayIso)}
</h2>
<div className="h-px flex-1 border-t border-dashed border-border/40" />
<span className="shrink-0 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{bookings.length}
</span>
</div>
{manualAvailabilityQuery.isLoading && (
<p className="text-sm text-muted-foreground">Cargando horarios disponibles...</p>
)}
<div className="mt-3 space-y-2">
{bookings
.slice()
.sort((a, b) => a.startTime.localeCompare(b.startTime))
.map((booking) => {
const effectiveStatus = getEffectiveStatus(booking);
const canManage =
booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
const isMutating = updateStatusMutation.isPending;
{manualAvailabilityQuery.isError && (
<p className="text-sm text-destructive">
{extractMessage(
manualAvailabilityQuery.error,
'No pudimos cargar disponibilidad para la reserva manual.'
)}
</p>
)}
const now = new Date();
const bookingStart = new Date(`${date}T${booking.startTime}:00`);
const bookingEnd = new Date(`${date}T${booking.endTime}:00`);
const isPast = bookingEnd < now;
const isNow = date === todayIso && bookingStart <= now && bookingEnd > now;
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
{createManualBookingMutation.isError && (
<p className="text-sm text-destructive">
{extractMessage(
createManualBookingMutation.error,
'No pudimos crear la reserva manual.'
)}
</p>
)}
</form>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Cancelar</Button>
</ResponsiveDialogClose>
<Button
type="submit"
form="manual-booking-form"
disabled={
!isValid ||
createManualBookingMutation.isPending ||
!selectedCourtId ||
!selectedStartTime
}
>
{createManualBookingMutation.isPending ? 'Guardando...' : 'Crear reserva'}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
</div>
</section>
<section className="rounded-xl border bg-card p-5 shadow-sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<Field>
<FieldLabel htmlFor="fromDate">Mostrar desde</FieldLabel>
<DatePicker
value={fromIsoDateLocal(fromDate)}
onChange={(date) => {
const isoDate = date ? toIsoDateLocal(date) : todayIso;
setFromDate(isoDate);
}}
disabled={(date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0);
return checkDate < today;
}}
placeholder="Selecciona una fecha"
/>
</Field>
</div>
{bookingsQuery.isLoading && (
<p className="mt-4 text-sm text-muted-foreground">Cargando reservas...</p>
)}
{bookingsQuery.isError && (
<p className="mt-4 text-sm text-destructive">
{extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')}
</p>
)}
{!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
<p className="mt-4 text-sm text-muted-foreground">
No hay reservas para la fecha seleccionada en adelante.
</p>
)}
<div className="mt-4 space-y-4">
{groupedBookings.map(([date, bookings]) => (
<article key={date} className="rounded-lg border bg-background p-3">
<h2 className="text-sm font-semibold capitalize">{formatDateLabel(date)}</h2>
<div className="mt-3 space-y-2">
{bookings.map((booking) => {
const effectiveStatus = getEffectiveStatus(booking);
const canManage =
booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
const isMutating = updateStatusMutation.isPending;
return (
<div
key={booking.id}
className="flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="text-sm font-medium">
{booking.startTime} - {booking.endTime} · {booking.courtName}
</p>
<p className="text-xs text-muted-foreground">
{booking.customerName} ({booking.customerPhone}) · Código{' '}
{booking.bookingCode}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${effectiveStatusClassName(effectiveStatus)}`}
return (
<div
key={booking.id}
className={[
'group flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-3.5 transition-all hover:bg-muted/50',
isPast && 'opacity-50',
isNow && 'border-l-2 border-l-emerald-500 bg-emerald-50/30',
].join(' ')}
>
{effectiveStatusLabel(effectiveStatus)}
</span>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex w-14 flex-col items-center">
<span className="text-sm font-medium tabular-nums">
{booking.startTime}
</span>
<span className="text-[11px] text-muted-foreground">
{booking.endTime}
</span>
</div>
{canManage && (
<>
<Button
type="button"
size="sm"
variant="outline"
disabled={isMutating}
onClick={() => {
updateStatusMutation.mutate({
bookingId: booking.id,
status: 'COMPLETED',
});
}}
>
Marcar cumplida
</Button>
<Button
type="button"
size="sm"
variant="destructive"
disabled={isMutating}
onClick={() => {
updateStatusMutation.mutate({
bookingId: booking.id,
status: 'CANCELLED',
});
}}
>
Cancelar
</Button>
</>
)}
</div>
</div>
);
})}
</div>
</article>
))}
</div>
<div className="flex flex-1 min-w-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{booking.courtName}</p>
<p className="truncate text-xs text-muted-foreground">
{booking.customerName}{' '}
<span className="text-muted-foreground/60">
· {booking.customerPhone}
</span>
</p>
</div>
</div>
</div>
{updateStatusMutation.isError && (
<p className="mt-4 text-sm text-destructive">
{extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')}
</p>
)}
</section>
<div className="flex shrink-0 items-center gap-2">
<StatusBadge
status={effectiveStatus}
label={effectiveStatusLabel(effectiveStatus)}
/>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{canManage && !isMutating && (
<>
<Button
type="button"
size="icon-xs"
variant="ghost"
title="Marcar cumplida"
onClick={() => {
updateStatusMutation.mutate({
bookingId: booking.id,
status: 'COMPLETED',
});
}}
>
<Check className="size-3.5" />
</Button>
<Button
type="button"
size="icon-xs"
variant="ghost"
title="Cancelar"
onClick={() => {
handleCancelBooking(booking);
}}
>
×
</Button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
</article>
))}
</div>
{updateStatusMutation.isError && (
<p className="mt-4 text-sm text-destructive">
{extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')}
</p>
)}
</section>
</div>
{bookingToCancel && (
<ResponsiveDialog
open={Boolean(bookingToCancel)}
onOpenChange={() => setBookingToCancel(null)}
>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Cancelar reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
¿Estás seguro de que quieres cancelar esta reserva? Esta acción no se puede
deshacer.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="mt-4 rounded-2xl border border-border/70 bg-background p-4">
<p className="font-medium">
{bookingToCancel.startTime} - {bookingToCancel.endTime} ·{' '}
{bookingToCancel.courtName}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{bookingToCancel.customerName} ({bookingToCancel.customerPhone})
</p>
</div>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline" disabled={updateStatusMutation.isPending}>
Mantener
</Button>
</ResponsiveDialogClose>
<Button
variant="destructive"
onClick={confirmCancelBooking}
disabled={updateStatusMutation.isPending}
>
{updateStatusMutation.isPending ? 'Cancelando...' : 'Sí, cancelar reserva'}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
)}
</main>
);
}

View File

@@ -1,3 +1,4 @@
import { FootballIcon } from '@/assets/icons/football';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -66,59 +67,189 @@ export function RootLayout() {
await navigate({ to: '/login' });
};
type NavigationItem = {
label: string;
to: '/' | '/profile' | '/complex/$slug/edit';
show: boolean;
params?: { slug: string };
};
const navigationItems: NavigationItem[] = [
{
label: 'Inicio',
to: '/',
show: true,
},
{
label: 'Configuración',
to: '/complex/$slug/edit',
params: currentComplexSlug ? { slug: currentComplexSlug } : undefined,
show: isAuthenticated && Boolean(currentComplexSlug),
},
{
label: 'Perfil',
to: '/profile',
show: isAuthenticated,
},
] as const;
return (
<div className="flex min-h-screen flex-col">
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-6">
<h1 className="text-sm font-semibold">{currentComplexName}</h1>
<nav className="hidden items-center gap-3 text-sm md:flex">
<Link
to="/"
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
Home
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-4">
<Link to="/" className="flex min-w-0 items-center gap-3">
<span className="relative flex size-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/20">
<FootballIcon className="size-5" />
<span className="absolute -right-0.5 -bottom-0.5 size-2 rounded-full border border-background bg-accent" />
</span>
<span className="min-w-0">
<span className="block text-[10px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
Playzer
</span>
<span className="block truncate text-sm font-semibold text-foreground">
{currentComplexName}
</span>
</span>
</Link>
{isAuthenticated && currentComplexSlug && (
<Link
to="/complex/$slug/edit"
params={{ slug: currentComplexSlug }}
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
Configuración
</Link>
<nav className="hidden items-center gap-1 lg:flex">
{navigationItems
.filter((item) => item.show)
.map((item) =>
item.params ? (
<Link
key={item.label}
to={item.to}
params={item.params}
className="rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
activeProps={{ className: 'bg-muted text-foreground font-medium' }}
>
{item.label}
</Link>
) : (
<Link
key={item.label}
to={item.to}
className="rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
activeProps={{ className: 'bg-muted text-foreground font-medium' }}
>
{item.label}
</Link>
)
)}
</nav>
</div>
<div className="flex items-center gap-2">
<ThemeSwitcher />
{!isAuthenticated && (
<Button asChild size="sm" variant="outline" className="hidden md:inline-flex">
<Link to="/login">Ingresar</Link>
</Button>
)}
</nav>
</div>
<div className="flex items-center gap-2">
<ThemeSwitcher />
{isAuthenticated && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-card px-2 py-1.5 transition-colors hover:bg-muted"
>
<UserAvatar
src={avatarUrl}
alt={displayName}
fallbackText={displayName}
size="sm"
/>
<span className="hidden max-w-32 truncate text-sm text-foreground sm:block">
{displayName}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Cambiar complejo
</DropdownMenuLabel>
{myComplexesQuery.data.map((complex) => (
<DropdownMenuItem
key={complex.id}
onSelect={async () => {
await apiClient.complexes.select({ complexId: complex.id });
setCurrentComplex(complex.complexSlug);
}}
>
{complex.complexSlug === complexSlug && <span className="mr-2"></span>}
{complex.complexName}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' });
}}
>
<UserRound className="size-4" />
Perfil
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut();
}}
>
<LogOut className="size-4" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{!isAuthenticated && (
<Button asChild size="sm" variant="outline" className="hidden md:inline-flex">
<Link to="/login">Login</Link>
</Button>
)}
{isAuthenticated && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
<Button
type="button"
className="hidden items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-muted md:inline-flex"
variant="outline"
size="icon-sm"
className="lg:hidden"
aria-label="Abrir menú"
>
<UserAvatar
src={avatarUrl}
alt={displayName}
fallbackText={displayName}
size="sm"
/>
<span className="max-w-32 truncate text-sm text-foreground">{displayName}</span>
</button>
<Menu className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuContent align="end" className="w-64 lg:hidden">
<DropdownMenuLabel>{currentComplexName}</DropdownMenuLabel>
<DropdownMenuSeparator />
{navigationItems
.filter((item) => item.show)
.map((item) =>
item.params ? (
<DropdownMenuItem
key={item.label}
onSelect={() => {
void navigate({ to: item.to, params: item.params });
}}
>
{item.label}
</DropdownMenuItem>
) : (
<DropdownMenuItem
key={item.label}
onSelect={() => {
void navigate({ to: item.to });
}}
>
{item.label}
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
<>
@@ -140,103 +271,41 @@ export function RootLayout() {
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' });
}}
>
<UserRound className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut();
}}
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
{isAuthenticated ? (
<>
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' });
}}
>
<UserRound className="size-4" />
Perfil
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut();
}}
>
<LogOut className="size-4" />
Cerrar sesión
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/login' });
}}
>
Ingresar
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
className="md:hidden"
aria-label="Abrir menú"
>
<Menu className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 md:hidden">
<DropdownMenuLabel>Navegación</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/' });
}}
>
Home
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/about' });
}}
>
About
</DropdownMenuItem>
{isAuthenticated && currentComplexSlug && (
<DropdownMenuItem
onSelect={() => {
void navigate({
to: '/complex/$slug/edit',
params: { slug: currentComplexSlug },
});
}}
>
Configuración
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isAuthenticated ? (
<>
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' });
}}
>
<UserRound className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut();
}}
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/login' });
}}
>
Login
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<div className="flex-1">
<Outlet />
</div>

View File

@@ -30,15 +30,15 @@ export function ThemeSwitcher() {
>
<DropdownMenuRadioItem value="light">
<Sun className="size-4" />
Light
Claro
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="size-4" />
Dark
Oscuro
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Laptop className="size-4" />
System
Sistema
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>

View File

@@ -78,82 +78,139 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
};
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
<p className="mb-4 text-sm text-muted-foreground">
Ingresá con tu cuenta para acceder a Home.
</p>
<main className="min-h-screen px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
<div className="mx-auto grid min-h-[calc(100vh-2rem)] w-full max-w-6xl items-center gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<section className="relative hidden overflow-hidden rounded-2xl border border-border/70 bg-card p-8 text-card-foreground shadow-sm lg:flex lg:min-h-[640px] lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(43,139,82,0.12),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(35,80,128,0.1),transparent_35%)]" />
<div className="relative space-y-8">
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
<span className="size-2 rounded-full bg-emerald-500" />
Gestión de reservas y complejos
</div>
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continuar con Google
</Button>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
<div className="max-w-xl space-y-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.34em] text-muted-foreground">
Playzer
</p>
<h1 className="text-4xl font-semibold tracking-tight text-foreground xl:text-5xl">
Un panel para operar el club con velocidad, claridad y presencia.
</h1>
<p className="max-w-lg text-base leading-7 text-muted-foreground">
Accedé a reservas, configuración y usuarios desde una interfaz más limpia, pensada
para trabajar rápido en escritorio y también en móvil.
</p>
</div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">O</span>
<div className="relative grid gap-3 sm:grid-cols-3">
{[
'Disponibilidad y reservas en un vistazo',
'Cambios de complejo sin perder contexto',
'Estados y acciones con feedback claro',
].map((item) => (
<div
key={item}
className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm text-muted-foreground backdrop-blur"
>
{item}
</div>
))}
</div>
</div>
</section>
<form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(errors.email)}
{...register('email')}
/>
<FieldError errors={[errors.email]} />
</Field>
<section className="w-full rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm sm:p-8">
<div className="mb-8 flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.34em] text-muted-foreground">
Acceso
</p>
<h1 className="text-3xl font-semibold tracking-tight">Ingresá a tu cuenta</h1>
<p className="max-w-sm text-sm text-muted-foreground">
Entrá con Google o con tu email y contraseña para continuar con tu complejo.
</p>
</div>
<div className="hidden rounded-2xl border border-border/70 bg-background p-3 text-right sm:block">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Estado</p>
<p className="mt-1 text-sm font-medium">Acceso seguro</p>
</div>
</div>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(errors.password)}
{...register('password')}
/>
<FieldError errors={[errors.password]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
<Button
type="button"
variant="outline"
className="w-full justify-center"
onClick={handleGoogleSignIn}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continuar con Google
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
<Link to="/reset-password" className="text-primary hover:underline">
¿Olvidaste tu contraseña?
</Link>
</p>
</section>
<div className="relative my-5">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/80" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">O continuá con email</span>
</div>
</div>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(errors.email)}
{...register('email')}
/>
<FieldError errors={[errors.email]} />
</Field>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(errors.password)}
{...register('password')}
/>
<FieldError errors={[errors.password]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
</Button>
</form>
<div className="mt-5 flex items-center justify-between gap-3 text-sm">
<p className="text-muted-foreground">¿Olvidaste tu contraseña?</p>
<Link to="/reset-password" className="font-medium text-primary hover:underline">
Recuperarla
</Link>
</div>
</section>
</div>
</main>
);
}

View File

@@ -1,7 +1,7 @@
export function OnboardingCheckEmailStep() {
return (
<p className="text-sm text-muted-foreground">
Revisa tu correo y abre el link de verificacion para continuar.
Revisa tu correo y abrí el link de verificación para continuar.
</p>
);
}

View File

@@ -7,12 +7,55 @@ type OnboardingLayoutProps = PropsWithChildren<{
export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
<p className="mb-4 text-sm text-muted-foreground">{description}</p>
{children}
</section>
<main className="min-h-screen px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
<div className="mx-auto grid min-h-[calc(100vh-2rem)] w-full max-w-6xl gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<aside className="hidden overflow-hidden rounded-2xl border border-border/70 bg-card p-8 shadow-sm lg:flex lg:flex-col lg:justify-between">
<div className="space-y-6">
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground">
<span className="size-2 rounded-full bg-primary" />
Alta guiada
</div>
<div className="space-y-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
Onboarding
</p>
<h1 className="max-w-md text-4xl font-semibold tracking-tight">
Un alta que guía, no que interrumpe.
</h1>
<p className="max-w-md text-base leading-7 text-muted-foreground">
Verificá email, completá los datos y dejá el complejo listo para operar sin pasos
confusos ni pantallas iguales entre .
</p>
</div>
</div>
<div className="grid gap-3">
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Paso 1</p>
<p className="mt-2 text-sm text-foreground">Ingresar nombre y correo</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Paso 2</p>
<p className="mt-2 text-sm text-foreground">Verificar email o código OTP</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Paso 3</p>
<p className="mt-2 text-sm text-foreground">Crear el complejo y empezar</p>
</div>
</div>
</aside>
<section className="w-full rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm sm:p-8">
<div className="mb-6 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
Inicio
</p>
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
<p className="max-w-lg text-sm text-muted-foreground">{description}</p>
</div>
{children}
</section>
</div>
</main>
);
}

View File

@@ -18,7 +18,7 @@ export function OnboardingStartStep({
onSubmit,
}: OnboardingStartStepProps) {
return (
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(form.formState.errors.fullName)}>
<FieldLabel htmlFor="onboard-fullname">Nombre</FieldLabel>
<Input
@@ -51,7 +51,7 @@ export function OnboardingStartStep({
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar código OTP'}
</Button>
</form>
);

View File

@@ -38,13 +38,13 @@ export function OnboardingVerifyOtpStep({
return (
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<p className="text-sm text-muted-foreground">
Ingresa el codigo de 6 digitos que enviamos a{' '}
Ingresa el código de 6 dígitos que enviamos a{' '}
<span className="font-medium text-foreground">{email ?? 'tu email'}</span>.
</p>
<Field data-invalid={Boolean(form.formState.errors.otp)}>
<div className="mb-2 flex items-center justify-between gap-3">
<FieldLabel htmlFor="onboard-otp">Codigo de verificacion</FieldLabel>
<FieldLabel htmlFor="onboard-otp">Código de verificación</FieldLabel>
<Button
type="button"
variant="outline"
@@ -59,7 +59,7 @@ export function OnboardingVerifyOtpStep({
? 'Reenviando...'
: resendCooldownSeconds > 0
? `Reenviar en ${resendCooldownSeconds}s`
: 'Reenviar codigo'}
: 'Reenviar código'}
</Button>
</div>
@@ -101,7 +101,7 @@ export function OnboardingVerifyOtpStep({
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar código'}
</Button>
</form>
);

View File

@@ -15,6 +15,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import type { UserProfileResponse } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Mail, ShieldCheck } from 'lucide-react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -100,13 +101,16 @@ export function ProfilePage() {
if (!isAuthenticated) {
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="text-2xl font-semibold">Perfil</h1>
<p className="mt-2 text-sm text-muted-foreground">
Necesitás iniciar sesión para ver tu perfil.
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-4 py-6">
<section className="w-full max-w-3xl rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
Perfil
</p>
<Button asChild className="mt-4">
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Necesitás iniciar sesión</h1>
<p className="mt-2 text-sm text-muted-foreground">
Ingresá para editar tu nombre, revisar tu correo y cambiar la contraseña.
</p>
<Button asChild className="mt-5">
<Link to="/login">Ir a Login</Link>
</Button>
</section>
@@ -115,130 +119,166 @@ export function ProfilePage() {
}
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6 py-8">
<div className="w-full max-w-3xl space-y-6">
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<div className="flex items-center gap-3">
<UserAvatar
src={profileQuery.data?.avatarUrl ?? avatarUrl}
alt={displayName}
fallbackText={displayName}
size="lg"
/>
<div>
<h1 className="text-2xl font-semibold">{displayName}</h1>
<p className="text-sm text-muted-foreground">
{profileQuery.data?.email ?? user?.email}
<main className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<section className="relative overflow-hidden rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(43,139,82,0.12),transparent_36%)]" />
<div className="relative space-y-6">
<div className="flex items-center gap-4">
<UserAvatar
src={profileQuery.data?.avatarUrl ?? avatarUrl}
alt={displayName}
fallbackText={displayName}
size="lg"
/>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
Perfil
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{displayName}</h1>
<div className="mt-3 flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
<Mail className="size-3.5" />
{profileQuery.data?.email ?? user?.email}
</span>
{profileQuery.data?.role && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
<ShieldCheck className="size-3.5" />
Rol {profileQuery.data.role}
</span>
)}
</div>
</div>
</div>
{profileQuery.isLoading && (
<p className="text-sm text-muted-foreground">Cargando perfil...</p>
)}
{profileQuery.isError && (
<p className="text-sm text-destructive">
No se pudo cargar el perfil desde el backend.
</p>
{profileQuery.data?.role && (
<p className="text-sm text-muted-foreground">Rol: {profileQuery.data.role}</p>
)}
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Cuenta</p>
<p className="mt-2 text-sm text-foreground">Actualizá datos personales y acceso.</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Seguridad
</p>
<p className="mt-2 text-sm text-foreground">Cambiá tu contraseña cuando quieras.</p>
</div>
</div>
</div>
{profileQuery.isLoading && (
<p className="mt-4 text-sm text-muted-foreground">Cargando perfil...</p>
)}
{profileQuery.isError && (
<p className="mt-4 text-sm text-destructive">
No se pudo cargar el perfil desde el backend.
</p>
)}
</section>
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-xl font-semibold mb-4">Editar Perfil</h2>
<Form {...updateProfileForm}>
<form onSubmit={updateProfileForm.handleSubmit(onUpdateProfile)} className="space-y-4">
<FormField
control={updateProfileForm.control}
name="fullName"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre completo</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={updateProfileMutation.isPending}>
{updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'}
</Button>
</form>
</Form>
{updateProfileMutation.isError && (
<p className="mt-4 text-sm text-destructive">
Error al actualizar el perfil: {updateProfileMutation.error?.message}
<div className="space-y-6">
<section className="rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-xl font-semibold">Editar perfil</h2>
<p className="mt-2 text-sm text-muted-foreground">
Mantené tu nombre visible y consistente para el resto del equipo.
</p>
)}
{updateProfileMutation.isSuccess && (
<p className="mt-4 text-sm text-green-600">Perfil actualizado exitosamente.</p>
)}
</section>
<Form {...updateProfileForm}>
<form
onSubmit={updateProfileForm.handleSubmit(onUpdateProfile)}
className="mt-5 space-y-4"
>
<FormField
control={updateProfileForm.control}
name="fullName"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre completo</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={updateProfileMutation.isPending}>
{updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'}
</Button>
</form>
</Form>
{updateProfileMutation.isError && (
<p className="mt-4 text-sm text-destructive">
Error al actualizar el perfil: {updateProfileMutation.error?.message}
</p>
)}
{updateProfileMutation.isSuccess && (
<p className="mt-4 text-sm text-emerald-700">Perfil actualizado exitosamente.</p>
)}
</section>
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-xl font-semibold mb-4">Cambiar Contraseña</h2>
<Form {...updatePasswordForm}>
<form
onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)}
className="space-y-4"
>
<FormField
control={updatePasswordForm.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Contraseña actual</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updatePasswordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Nueva contraseña</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updatePasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmar nueva contraseña</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={updatePasswordMutation.isPending}>
{updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'}
</Button>
</form>
</Form>
{updatePasswordMutation.isError && (
<p className="mt-4 text-sm text-destructive">
Error al cambiar la contraseña: {updatePasswordMutation.error?.message}
<section className="rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-xl font-semibold">Cambiar contraseña</h2>
<p className="mt-2 text-sm text-muted-foreground">
Usá una contraseña fuerte para proteger el acceso al panel.
</p>
)}
{updatePasswordMutation.isSuccess && (
<p className="mt-4 text-sm text-green-600">Contraseña cambiada exitosamente.</p>
)}
</section>
<Form {...updatePasswordForm}>
<form
onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)}
className="mt-5 space-y-4"
>
<FormField
control={updatePasswordForm.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Contraseña actual</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updatePasswordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Nueva contraseña</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updatePasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmar nueva contraseña</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={updatePasswordMutation.isPending}>
{updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'}
</Button>
</form>
</Form>
{updatePasswordMutation.isError && (
<p className="mt-4 text-sm text-destructive">
Error al cambiar la contraseña: {updatePasswordMutation.error?.message}
</p>
)}
{updatePasswordMutation.isSuccess && (
<p className="mt-4 text-sm text-emerald-700">Contraseña cambiada exitosamente.</p>
)}
</section>
</div>
</div>
</main>
);

View File

@@ -89,6 +89,15 @@ export function PublicBookingConfirmationPage({
<p className="mt-1 text-base font-medium sm:text-lg">
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
</p>
{confirmationQuery.data.price > 0 && (
<>
<p className="mt-4 text-sm text-muted-foreground">Precio</p>
<p className="mt-1 text-xl font-bold sm:text-2xl">
${confirmationQuery.data.price.toLocaleString('es-AR')}
</p>
</>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">

View File

@@ -1,21 +1,100 @@
import AvailabilityHeatMap from '@/components/ui/availability-heatmap';
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ApiClientError, apiClient } from '@/lib/api-client';
import { zodResolver } from '@hookform/resolvers/zod';
import {
BarbellIcon,
BasketballIcon,
PersonSimpleRunIcon,
PingPongIcon,
RacquetIcon,
SoccerBallIcon,
TennisBallIcon,
VolleyballIcon,
} from '@phosphor-icons/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { Dumbbell } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const sportConfig: Record<
string,
{ icon: typeof Dumbbell; color: string; bg: string; border: string; label: string }
> = {
tennis: {
icon: TennisBallIcon,
color: 'text-green-700 dark:text-green-400',
bg: 'bg-green-50 dark:bg-green-950/40',
border: 'border-green-200 dark:border-green-800',
label: 'Tenis',
},
futbol: {
icon: SoccerBallIcon,
color: 'text-emerald-700 dark:text-emerald-400',
bg: 'bg-emerald-50 dark:bg-emerald-950/40',
border: 'border-emerald-200 dark:border-emerald-800',
label: 'Fútbol',
},
paddle: {
icon: RacquetIcon,
color: 'text-amber-700 dark:text-amber-400',
bg: 'bg-amber-50 dark:bg-amber-950/40',
border: 'border-amber-200 dark:border-amber-800',
label: 'Pádel',
},
basketball: {
icon: BasketballIcon,
color: 'text-orange-700 dark:text-orange-400',
bg: 'bg-orange-50 dark:bg-orange-950/40',
border: 'border-orange-200 dark:border-orange-800',
label: 'Básquet',
},
volleyball: {
icon: VolleyballIcon,
color: 'text-sky-700 dark:text-sky-400',
bg: 'bg-sky-50 dark:bg-sky-950/40',
border: 'border-sky-200 dark:border-sky-800',
label: 'Vóley',
},
gym: {
icon: BarbellIcon,
color: 'text-slate-700 dark:text-slate-400',
bg: 'bg-slate-50 dark:bg-slate-950/40',
border: 'border-slate-200 dark:border-slate-800',
label: 'Gimnasio',
},
pickleball: {
icon: RacquetIcon,
color: 'text-lime-700 dark:text-lime-400',
bg: 'bg-lime-50 dark:bg-lime-950/40',
border: 'border-lime-200 dark:border-lime-800',
label: 'Pickleball',
},
pingpong: {
icon: PingPongIcon,
color: 'text-blue-700 dark:text-blue-400',
bg: 'bg-blue-50 dark:bg-blue-950/40',
border: 'border-blue-200 dark:border-blue-800',
label: 'Ping Pong',
},
default: {
icon: PersonSimpleRunIcon,
color: 'text-violet-700 dark:text-violet-400',
bg: 'bg-violet-50 dark:bg-violet-950/40',
border: 'border-violet-200 dark:border-violet-800',
label: 'Deporte',
},
};
function getSportConfig(slug: string) {
const key = Object.keys(sportConfig).find((k) => slug.toLowerCase().includes(k));
return sportConfig[key || 'default'];
}
const DAYS_STEP = 5;
const bookingFormSchema = z.object({
@@ -44,6 +123,7 @@ type SelectedSlot = {
sportName: string;
startTime: string;
endTime: string;
price?: { min: number; max: number };
};
function addDays(baseDate: Date, amount: number) {
@@ -85,6 +165,18 @@ function extractMessage(error: unknown, fallback: string) {
return fallback;
}
function formatSelectedDateLabel(dateIso: string) {
const [year, month, day] = dateIso.split('-').map(Number);
if (!year || !month || !day) return dateIso;
const date = new Date(year, month - 1, day);
return new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
day: '2-digit',
month: 'long',
}).format(date);
}
export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
const navigate = useNavigate();
const today = useMemo(() => new Date(), []);
@@ -120,6 +212,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
const availabilityQuery = useQuery({
queryKey: ['public-booking-availability', complexSlug, selectedDate, selectedSportId],
enabled: Boolean(selectedDate),
placeholderData: (previousData) => previousData,
queryFn: () =>
apiClient.publicBookings.getAvailability(complexSlug, {
date: selectedDate,
@@ -138,6 +231,11 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
return;
}
if (availability.sports.length === 1 && !selectedSportId) {
setSelectedSportId(availability.sports[0].id);
return;
}
if (selectedSportId && !availability.sports.some((sport) => sport.id === selectedSportId)) {
setSelectedSportId(undefined);
}
@@ -215,6 +313,13 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
.filter((court) => court.availableSlots.length > 0);
}, [availabilityQuery.data?.courts, selectedDate, todayIsoDate]);
const selectedSport = useMemo(() => {
return availabilityQuery.data?.sports.find((sport) => sport.id === selectedSportId) ?? null;
}, [availabilityQuery.data?.sports, selectedSportId]);
const selectedSportLabel = selectedSport ? getSportConfig(selectedSport.slug).label : null;
const selectedDateLabel = useMemo(() => formatSelectedDateLabel(selectedDate), [selectedDate]);
useEffect(() => {
if (!selectedSlot) return;
@@ -329,241 +434,325 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
};
return (
<main className="min-h-screen bg-linear-to-b from-background to-muted/40">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-4 py-4 sm:gap-6 sm:px-6 sm:py-8">
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-primary sm:text-3xl">
{availabilityQuery.data?.complexName}
</h1>
{availabilityQuery.data?.complexAddress && (
<p className="text-sm text-muted-foreground">
{availabilityQuery.data.complexAddress}
<main className="min-h-screen px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
<div className="mx-auto w-full max-w-7xl space-y-6">
<section className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div className="grid gap-6 p-6 lg:grid-cols-[1.1fr_0.9fr] lg:p-8">
<div className="space-y-6">
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-semibold text-primary-foreground">
PZ
</span>
Reserva oficial
</div>
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
{availabilityQuery.data?.complexSlug ?? complexSlug}
</p>
<h1 className="text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
{availabilityQuery.data?.complexName ?? 'Cargando complejo...'}
</h1>
{availabilityQuery.data?.complexAddress && (
<p className="max-w-2xl text-sm text-muted-foreground">
{availabilityQuery.data.complexAddress}
</p>
)}
</div>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
Elegí un día, encontrá el mejor horario disponible y confirmá en pocos pasos. La
pantalla prioriza disponibilidad, contexto y una lectura rápida desde el móvil.
</p>
)}
</div>
<div className="hidden gap-3 sm:grid sm:grid-cols-2">
<div className="hidden sm:block rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Fecha elegida
</p>
<p className="mt-2 text-sm font-medium capitalize text-foreground">
{selectedDateLabel}
</p>
</div>
<div className="hidden sm:block rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Deporte</p>
<p className="mt-2 text-sm font-medium text-foreground">
{selectedSportLabel ?? 'Sin selección'}
</p>
</div>
<div className="hidden sm:block rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Canchas visibles
</p>
<p className="mt-2 text-sm font-medium text-foreground">
{visibleCourts.length} disponibles
</p>
</div>
<div className="hidden sm:block rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Estado</p>
<p className="mt-2 text-sm font-medium text-foreground">
{selectedSlot ? 'Turno listo para confirmar' : 'Elegí un horario'}
</p>
</div>
</div>
</div>
<p className="mt-4 text-sm text-muted-foreground">
Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono.
</p>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="space-y-3">
<div>
<h2 className="text-sm font-medium">Dia</h2>
<p className="text-xs text-muted-foreground">Mostramos 5 dias por vez.</p>
</div>
{autoAdjustedDateNotice && (
<p className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{autoAdjustedDateNotice}
</p>
)}
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<section className="space-y-6">
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
<div className="space-y-2">
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Día
</h2>
<p className="text-sm text-muted-foreground">Mostramos 5 días por vez.</p>
</div>
{autoAdjustedDateNotice && (
<p className="mt-4 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-200">
{autoAdjustedDateNotice}
</p>
)}
<div className="mt-4 grid grid-cols-[2.5rem_repeat(5,minmax(0,1fr))_2.5rem] gap-2">
{canGoBack ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-full rounded-2xl"
onClick={goToPreviousFiveDays}
aria-label="Mostrar 5 días anteriores"
>
{'<'}
</Button>
) : (
<div />
)}
{dayOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setSelectedDate(option.value);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
}}
className={`rounded-2xl border px-2 py-3 text-center transition-all ${
selectedDate === option.value
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border/70 bg-background hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
}`}
>
<p className="text-[11px] font-semibold uppercase tracking-[0.16em]">
{option.weekday}
</p>
<p className="text-sm font-medium">{option.dayMonth}</p>
</button>
))}
<div className="grid grid-cols-[2.25rem_repeat(5,minmax(0,1fr))_2.25rem] gap-2">
{canGoBack ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-full"
onClick={goToPreviousFiveDays}
aria-label="Mostrar 5 dias anteriores"
className="h-full rounded-2xl"
onClick={goToNextFiveDays}
aria-label="Mostrar próximos 5 días"
>
{'<'}
{'>'}
</Button>
) : (
<div />
</div>
</section>
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
<div className="mb-4 space-y-2">
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Disponibilidad
</h2>
<p className="text-sm text-muted-foreground">Seleccioná cancha y horario.</p>
</div>
{availabilityQuery.data?.sportSelectionRequired && (
<div className="mb-5">
<h3 className="mb-3 text-sm font-medium text-foreground">Elegí tu deporte</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{availabilityQuery.data?.sports.map((sport) => {
const config = getSportConfig(sport.slug);
const Icon = config.icon;
const isSelected = selectedSportId === sport.id;
return (
<button
key={sport.id}
type="button"
onClick={() => {
setSelectedSportId(sport.id);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
}}
className={`flex flex-col items-center gap-2 rounded-2xl border p-4 text-center transition-all ${
isSelected
? `${config.border} ${config.bg} shadow-sm ring-2 ring-primary/20`
: 'border-border/70 bg-background hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
}`}
>
<Icon
className={`size-8 ${
isSelected ? config.color : 'text-muted-foreground'
}`}
/>
<span
className={`text-sm font-medium ${
isSelected ? 'text-foreground' : 'text-muted-foreground'
}`}
>
{config.label}
</span>
</button>
);
})}
</div>
</div>
)}
{dayOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setSelectedDate(option.value);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
}}
className={`rounded-xl border px-1 py-2 text-center transition-colors sm:px-2 ${
selectedDate === option.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background hover:bg-muted'
}`}
>
<p className="text-[11px] font-medium uppercase sm:text-xs">{option.weekday}</p>
<p className="text-xs sm:text-sm">{option.dayMonth}</p>
</button>
))}
<Button
type="button"
variant="outline"
size="icon"
className="h-full"
onClick={goToNextFiveDays}
aria-label="Mostrar proximos 5 dias"
>
{'>'}
</Button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium">Disponibilidad</h2>
<p className="text-xs text-muted-foreground">Selecciona cancha y horario.</p>
</div>
</div>
{availabilityQuery.data?.sportSelectionRequired && (
<div className="mb-4">
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select
value={selectedSportId ?? ''}
onValueChange={(value) => {
setSelectedSportId(value);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
}}
>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{availabilityQuery.data.sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
)}
{availabilityQuery.isLoading && (
<p className="text-sm text-muted-foreground">Buscando disponibilidad...</p>
)}
{availabilityQuery.isError && (
<p className="text-sm text-destructive">
{extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')}
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data?.sportSelectionRequired &&
!selectedSportId && (
<p className="text-sm text-muted-foreground">
Selecciona un deporte para ver los horarios disponibles.
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data &&
(!availabilityQuery.data.sportSelectionRequired || selectedSportId) &&
visibleCourts.length === 0 && (
<p className="text-sm text-muted-foreground">
No hay horarios disponibles para la fecha elegida.
</p>
)}
<div className="space-y-3">
{visibleCourts.map((court) => (
<article key={court.courtId} className="rounded-xl border bg-background p-3 sm:p-4">
<div className="mb-3 flex flex-col gap-1">
<p className="text-sm font-medium sm:text-base">{court.courtName}</p>
<p className="text-xs text-muted-foreground">
{court.sport.name} · Turnos de {court.slotDurationMinutes} min
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{court.availableSlots.map((slot) => {
const isSelected =
selectedSlot?.courtId === court.courtId &&
selectedSlot.startTime === slot.startTime;
return (
<button
key={`${court.courtId}-${slot.startTime}`}
type="button"
onClick={() => {
setSelectedSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
});
}}
className={`h-10 rounded-lg border text-sm transition-colors ${
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card hover:bg-muted'
}`}
>
{slot.startTime}
</button>
);
})}
</div>
</article>
))}
</div>
</section>
{selectedSlot && (
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<h2 className="text-sm font-medium">Confirmar turno</h2>
<p className="mt-1 text-xs text-muted-foreground">
{selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime} -{' '}
{selectedSlot.endTime}
</p>
<form className="mt-4 space-y-3" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Telefono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
{createBookingMutation.isError && (
<p className="text-sm text-destructive">
{extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')}
{availabilityQuery.isLoading && (
<p className="rounded-2xl border border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
Buscando disponibilidad...
</p>
)}
{navigationError && <p className="text-sm text-destructive">{navigationError}</p>}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{createBookingMutation.isPending ? 'Confirmando...' : 'Confirmar turno'}
</Button>
</form>
{availabilityQuery.isError && (
<p className="rounded-2xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')}
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data?.sportSelectionRequired &&
!selectedSportId && (
<p className="rounded-2xl border border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
Seleccioná un deporte para ver los horarios disponibles.
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data &&
(!availabilityQuery.data.sportSelectionRequired || selectedSportId) &&
visibleCourts.length === 0 && (
<p className="rounded-2xl border border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
No hay horarios disponibles para la fecha elegida.
</p>
)}
<AvailabilityHeatMap
courts={visibleCourts}
selectedSlot={selectedSlot}
onSelectSlot={setSelectedSlot}
/>
</section>
</section>
)}
<aside className="xl:sticky xl:top-24 h-fit space-y-6 self-start">
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Resumen
</h2>
<div className="mt-4 space-y-3">
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Día</p>
<p className="mt-1 text-sm font-medium capitalize text-foreground">
{selectedDateLabel}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Horario
</p>
<p className="mt-1 text-sm font-medium text-foreground">
{selectedSlot
? `${selectedSlot.startTime} - ${selectedSlot.endTime}`
: 'Todavía no elegiste un horario'}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Cancha
</p>
<p className="mt-1 text-sm font-medium text-foreground">
{selectedSlot?.courtName ?? 'Seleccioná una cancha disponible'}
</p>
</div>
{selectedSlot?.price && selectedSlot.price.min > 0 && (
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Precio
</p>
<p className="mt-1 text-base font-bold text-foreground">
${selectedSlot.price.min.toLocaleString('es-AR')}
{selectedSlot.price.min !== selectedSlot.price.max &&
` - $${selectedSlot.price.max.toLocaleString('es-AR')}`}
</p>
</div>
)}
</div>
{selectedSlot ? (
<form className="mt-5 space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4 text-sm text-foreground">
<p className="font-medium">Turno seleccionado</p>
<p className="mt-1 text-muted-foreground">
{selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime}{' '}
- {selectedSlot.endTime}
</p>
</div>
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
{createBookingMutation.isError && (
<p className="text-sm text-destructive">
{extractMessage(
createBookingMutation.error,
'No pudimos confirmar el turno.'
)}
</p>
)}
{navigationError && <p className="text-sm text-destructive">{navigationError}</p>}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{createBookingMutation.isPending ? 'Confirmando...' : 'Confirmar turno'}
</Button>
</form>
) : (
<div className="mt-5 rounded-2xl border border-dashed border-border/70 bg-background p-4 text-sm text-muted-foreground">
Elegí un horario disponible para completar la reserva.
</div>
)}
</section>
</aside>
</div>
</div>
</main>
);

View File

@@ -3,7 +3,7 @@ import { apiClient } from '@/lib/api-client';
import type { ComplexWithRole } from '@repo/api-contract';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { Building2, Loader2 } from 'lucide-react';
import { ArrowRight, Building2, Loader2 } from 'lucide-react';
export function SelectComplexPage() {
const navigate = useNavigate();
@@ -30,7 +30,7 @@ export function SelectComplexPage() {
if (complexesQuery.isLoading) {
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Loader2 className="h-9 w-9 animate-spin text-muted-foreground" />
</main>
);
}
@@ -61,38 +61,93 @@ export function SelectComplexPage() {
}
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<section className="w-full max-w-md space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Seleccioná un complejo</h1>
<p className="text-sm text-muted-foreground">
Elegí el complejo con el que vas a trabajar.
</p>
</div>
<main className="min-h-screen px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
<div className="mx-auto grid min-h-[calc(100vh-2rem)] w-full max-w-6xl gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<section className="hidden overflow-hidden rounded-2xl border border-border/70 bg-card p-8 shadow-sm lg:flex lg:flex-col lg:justify-between">
<div className="space-y-6">
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground">
<span className="size-2 rounded-full bg-primary" />
Cambio de contexto
</div>
<div className="space-y-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
Playzer
</p>
<h1 className="max-w-md text-4xl font-semibold tracking-tight">
Elegí el complejo con el que vas a operar.
</h1>
<p className="max-w-md text-base leading-7 text-muted-foreground">
Cada complejo tiene su propio estado, su lista de canchas y su panel de reservas.
Seleccioná uno para entrar con todo el contexto listo.
</p>
</div>
</div>
<div className="space-y-2">
{complexes.map((complex) => (
<button
key={complex.id}
type="button"
className="flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-accent"
onClick={() => handleSelect(complex)}
disabled={selectMutation.isPending}
>
<Building2 className="h-5 w-5 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">{complex.complexName}</p>
<p className="text-sm text-muted-foreground">
{complex.city ?? 'Sin ciudad'} · {complex.role}
</p>
</div>
{selectMutation.isPending && selectMutation.variables === complex.id && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</button>
))}
</div>
</section>
<div className="grid gap-3">
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Qué cambia
</p>
<p className="mt-2 text-sm text-foreground">
Reservas, usuarios, configuración y disponibilidad se actualizan según el complejo
seleccionado.
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Tip</p>
<p className="mt-2 text-sm text-foreground">
Si administrás varios complejos, el menú superior te deja cambiar sin perder el
hilo.
</p>
</div>
</div>
</section>
<section className="w-full rounded-2xl border border-border/70 bg-card p-6 shadow-sm sm:p-8">
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
Selección
</p>
<h1 className="text-3xl font-semibold tracking-tight">Seleccioná un complejo</h1>
<p className="max-w-md text-sm text-muted-foreground">
Elegí el espacio con el que vas a trabajar ahora mismo.
</p>
</div>
<div className="mt-6 space-y-3">
{complexes.map((complex) => (
<button
key={complex.id}
type="button"
className="flex w-full items-center gap-4 rounded-2xl border border-border/70 bg-background p-4 text-left transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm"
onClick={() => handleSelect(complex)}
disabled={selectMutation.isPending}
>
<div className="flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Building2 className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-foreground">{complex.complexName}</p>
<span className="rounded-full border border-border/70 bg-card px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{complex.role}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{complex.city ?? 'Sin ciudad'} · {complex.complexSlug}
</p>
</div>
{selectMutation.isPending && selectMutation.variables === complex.id && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{!selectMutation.isPending && (
<ArrowRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
))}
</div>
</section>
</div>
</main>
);
}

View File

@@ -42,89 +42,186 @@
--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);
--radius-xl: calc(var(--radius) * 1.2);
--radius-2xl: calc(var(--radius) * 1.5);
--radius-3xl: calc(var(--radius) * 1.8);
--radius-4xl: calc(var(--radius) * 2.1);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
/* Base colors (retaining the shadcn structure, but with a sports-club identity) */
--background: oklch(0.94 0.01 145);
--foreground: oklch(0.16 0.02 145);
--card: oklch(0.995 0.005 145);
--card-foreground: oklch(0.16 0.02 145);
--popover: oklch(0.995 0.005 145);
--popover-foreground: oklch(0.16 0.02 145);
--primary: oklch(0.42 0.11 145);
--primary-foreground: oklch(0.985 0.01 145);
--secondary: oklch(0.95 0.015 145);
--secondary-foreground: oklch(0.24 0.02 145);
--muted: oklch(0.95 0.01 145);
--muted-foreground: oklch(0.49 0.02 145);
--accent: oklch(0.93 0.05 82);
--accent-foreground: oklch(0.28 0.05 82);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.9 0.01 145);
--input: oklch(0.89 0.01 145);
--ring: oklch(0.55 0.12 145);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.5rem;
--sidebar: oklch(0.99 0.005 145);
--sidebar-foreground: oklch(0.16 0.02 145);
--sidebar-primary: oklch(0.42 0.11 145);
--sidebar-primary-foreground: oklch(0.985 0.01 145);
--sidebar-accent: oklch(0.93 0.05 82);
--sidebar-accent-foreground: oklch(0.28 0.05 82);
--sidebar-border: oklch(0.9 0.01 145);
--sidebar-ring: oklch(0.55 0.12 145);
/* Surface elevation (light mode: higher = lighter) */
--surface-base: oklch(1 0 0);
--surface-raised: oklch(0.99 0 0);
--surface-overlay: oklch(0.98 0 0);
/* Semantic status - Light mode (subtle, not overwhelming) */
--status-confirmed-bg: oklch(0.97 0.02 145);
--status-confirmed-border: oklch(0.88 0.03 145);
--status-confirmed-text: oklch(0.38 0.12 145);
--status-pending-bg: oklch(0.97 0.02 75);
--status-pending-border: oklch(0.88 0.03 75);
--status-pending-text: oklch(0.48 0.15 75);
--status-cancelled-bg: oklch(0.97 0.02 25);
--status-cancelled-border: oklch(0.88 0.03 25);
--status-cancelled-text: oklch(0.45 0.15 25);
--status-noshow-bg: oklch(0.97 0.02 50);
--status-noshow-border: oklch(0.88 0.03 50);
--status-noshow-text: oklch(0.45 0.12 50);
--status-completed-bg: oklch(0.97 0.02 220);
--status-completed-border: oklch(0.88 0.03 220);
--status-completed-text: oklch(0.38 0.1 220);
/* Borders */
--border-subtle: oklch(1 0 0 / 8%);
--border-default: oklch(1 0 0 / 12%);
--border-emphasis: oklch(1 0 0 / 20%);
/* Input */
--input-bg: oklch(0.97 0 0);
--input-border: oklch(0.89 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--background: oklch(0.14 0.015 145);
--foreground: oklch(0.98 0.01 145);
--card: oklch(0.19 0.02 145);
--card-foreground: oklch(0.98 0.01 145);
--popover: oklch(0.2 0.02 145);
--popover-foreground: oklch(0.98 0.01 145);
--primary: oklch(0.76 0.11 145);
--primary-foreground: oklch(0.15 0.02 145);
--secondary: oklch(0.24 0.02 145);
--secondary-foreground: oklch(0.98 0.01 145);
--muted: oklch(0.24 0.02 145);
--muted-foreground: oklch(0.72 0.02 145);
--accent: oklch(0.28 0.05 82);
--accent-foreground: oklch(0.98 0.01 82);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.72 0.08 145);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar: oklch(0.18 0.02 145);
--sidebar-foreground: oklch(0.98 0.01 145);
--sidebar-primary: oklch(0.76 0.11 145);
--sidebar-primary-foreground: oklch(0.15 0.02 145);
--sidebar-accent: oklch(0.28 0.05 82);
--sidebar-accent-foreground: oklch(0.98 0.01 82);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.72 0.08 145);
/* Surface elevation (dark mode: higher = lighter) */
--surface-base: oklch(0.145 0 0);
--surface-raised: oklch(0.18 0 0);
--surface-overlay: oklch(0.22 0 0);
/* Semantic status - Dark mode (desaturated, lower contrast) */
--status-confirmed-bg: oklch(0.25 0.06 145 / 40%);
--status-confirmed-border: oklch(0.35 0.06 145 / 60%);
--status-confirmed-text: oklch(0.7 0.08 145);
--status-pending-bg: oklch(0.25 0.06 75 / 40%);
--status-pending-border: oklch(0.35 0.06 75 / 60%);
--status-pending-text: oklch(0.75 0.08 75);
--status-cancelled-bg: oklch(0.25 0.06 25 / 40%);
--status-cancelled-border: oklch(0.35 0.06 25 / 60%);
--status-cancelled-text: oklch(0.75 0.08 25);
--status-noshow-bg: oklch(0.25 0.06 50 / 40%);
--status-noshow-border: oklch(0.35 0.06 50 / 60%);
--status-noshow-text: oklch(0.75 0.08 50);
--status-completed-bg: oklch(0.25 0.06 220 / 40%);
--status-completed-border: oklch(0.35 0.06 220 / 60%);
--status-completed-text: oklch(0.7 0.08 220);
/* Borders */
--border-subtle: oklch(1 0 0 / 6%);
--border-default: oklch(1 0 0 / 10%);
--border-emphasis: oklch(1 0 0 / 18%);
/* Input */
--input-bg: oklch(1 0 0 / 10%);
--input-border: oklch(1 0 0 / 15%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
font-synthesis-weight: none;
text-rendering: optimizeLegibility;
}
body {
@apply bg-background text-foreground antialiased;
background-image: radial-gradient(
circle at top left,
oklch(0.94 0.04 145 / 0.55),
transparent 32%
), radial-gradient(circle at top right, oklch(0.96 0.03 220 / 0.45), transparent 28%),
linear-gradient(
to bottom,
oklch(0.99 0.005 145),
oklch(0.985 0.01 145) 28%,
oklch(0.97 0.01 145)
);
background-attachment: fixed;
}
.dark body {
background-image: radial-gradient(
circle at top left,
oklch(0.32 0.08 145 / 0.35),
transparent 32%
), radial-gradient(circle at top right, oklch(0.26 0.05 220 / 0.2), transparent 28%),
linear-gradient(to bottom, oklch(0.15 0.015 145), oklch(0.14 0.015 145));
}
::selection {
background: color-mix(in oklab, var(--primary) 28%, transparent);
color: var(--foreground);
}
}

View File

@@ -47,10 +47,12 @@
"@better-auth/infra": "^0.2.4",
"@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-slot": "^1.2.4",
"@repo/api-contract": "workspace:*",
"@tabler/icons-react": "^3.41.1",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-query-devtools": "^5.96.1",
"@tanstack/react-router": "^1.168.10",
@@ -345,6 +347,8 @@
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.6.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.6.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-BjHNmJqqa42NqJSDPnXUfwUofWo8LJY7Ui2gqxN4DmAOb+H/gGKv+hln2Xq/1kSJXPW5AXMXuNiPDMpywvyIOw=="],
@@ -559,6 +563,10 @@
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tabler/icons": ["@tabler/icons@3.41.1", "", {}, "sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q=="],
"@tabler/icons-react": ["@tabler/icons-react@3.41.1", "", { "dependencies": { "@tabler/icons": "3.41.1" }, "peerDependencies": { "react": ">= 16" } }, "sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],

View File

@@ -21,6 +21,11 @@ export const publicBookingSlotSchema = z.object({
endTime: z.string().regex(TIME_REGEX),
})
export const priceRangeSchema = z.object({
min: z.number(),
max: z.number(),
})
export const publicAvailabilityCourtSchema = z.object({
courtId: z.uuid(),
courtName: z.string(),
@@ -28,6 +33,7 @@ export const publicAvailabilityCourtSchema = z.object({
slotDurationMinutes: z.int().positive(),
availabilityDay: dayOfWeekSchema,
availableSlots: z.array(publicBookingSlotSchema),
price: priceRangeSchema.optional(),
})
export const publicAvailabilityResponseSchema = z.object({
@@ -73,6 +79,7 @@ export const publicBookingSchema = z.object({
customerName: z.string(),
customerPhone: z.string(),
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
price: z.number(),
createdAt: z.string().datetime(),
})
@@ -86,12 +93,14 @@ export const publicBookingConfirmationSchema = z.object({
courtName: z.string(),
sport: publicBookingSportSchema,
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
price: z.number(),
createdAt: z.string().datetime(),
})
export type PublicAvailabilityQuery = z.infer<typeof publicAvailabilityQuerySchema>
export type PublicBookingSport = z.infer<typeof publicBookingSportSchema>
export type PublicBookingSlot = z.infer<typeof publicBookingSlotSchema>
export type PriceRange = z.infer<typeof priceRangeSchema>
export type PublicAvailabilityCourt = z.infer<typeof publicAvailabilityCourtSchema>
export type PublicAvailabilityResponse = z.infer<typeof publicAvailabilityResponseSchema>
export type CreatePublicBookingInput = z.infer<typeof createPublicBookingSchema>

10
skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"interface-design": {
"source": "dammyjay93/interface-design",
"sourceType": "github",
"computedHash": "d004c5e5e2e7132e1d64e9c2a339db7f7aaa69053a5c19cddc210dd35dd6fff5"
}
}
}