Compare commits
33 Commits
ui-redesig
...
4d3867614d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d3867614d | |||
|
|
c18db76c7a | ||
|
|
78df95e985 | ||
| 6738db8c60 | |||
|
|
8acb81e6b4 | ||
|
|
91125e82f5 | ||
|
|
e9bd759fa5 | ||
|
|
57416bf53a | ||
|
|
0b6041d146 | ||
|
|
900e6146e7 | ||
|
|
e905052cc1 | ||
|
|
c1d910708a | ||
| b8abf224cf | |||
|
|
b47f00651e | ||
| a9c74d2f9c | |||
|
|
d1b5bea1bc | ||
|
|
ab22ec86ec | ||
|
|
65295236c5 | ||
|
|
f104161bbd | ||
|
|
844dac206c | ||
| be48b4b295 | |||
|
|
1059d767d5 | ||
|
|
7be776f189 | ||
|
|
41a217e8a9 | ||
| bb48d9c164 | |||
|
|
01688cf042 | ||
|
|
b97eb9c8a4 | ||
|
|
c089215835 | ||
|
|
546a020003 | ||
| 38b2294aad | |||
|
|
c9ecdd2c77 | ||
|
|
21d0c27f4d | ||
|
|
20d6018836 |
391
.agents/skills/interface-design/SKILL.md
Normal 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
|
||||||
67
.agents/skills/interface-design/references/critique.md
Normal 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.
|
||||||
86
.agents/skills/interface-design/references/example.md
Normal 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.
|
||||||
235
.agents/skills/interface-design/references/principles.md
Normal 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.
|
||||||
48
.agents/skills/interface-design/references/validation.md
Normal 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?
|
||||||
18
Dockerfile
@@ -32,25 +32,19 @@ COPY packages/api-contract/package.json packages/api-contract/
|
|||||||
RUN bun install
|
RUN bun install
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 4: Prisma Generate
|
# Stage 4: Build Backend (prisma generate + tsc)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM deps-backend AS prisma
|
FROM deps-backend AS build-backend
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG DATABASE_URL
|
ARG DATABASE_URL
|
||||||
ENV DATABASE_URL=${DATABASE_URL}
|
ENV DATABASE_URL=${DATABASE_URL:-postgresql://dummy:dummy@localhost:5432/dummy}
|
||||||
COPY apps/backend ./apps/backend
|
COPY apps/backend ./apps/backend
|
||||||
RUN bun --cwd apps/backend prisma:generate
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Stage 5: Build Backend
|
|
||||||
# =============================================================================
|
|
||||||
FROM prisma AS build-backend
|
|
||||||
WORKDIR /app
|
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
|
RUN bun --cwd apps/backend prisma:generate
|
||||||
RUN bun --cwd apps/backend build
|
RUN bun --cwd apps/backend build
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 6: Runner - Production image
|
# Stage 5: Runner - Production image
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM oven/bun:1.3.11 AS runner
|
FROM oven/bun:1.3.11 AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -60,7 +54,7 @@ COPY --from=build-backend /app/node_modules ./node_modules
|
|||||||
COPY --from=build-frontend /app/apps/frontend/dist ./apps/backend/public
|
COPY --from=build-frontend /app/apps/frontend/dist ./apps/backend/public
|
||||||
COPY --from=build-backend /app/apps/backend ./apps/backend
|
COPY --from=build-backend /app/apps/backend ./apps/backend
|
||||||
COPY --from=build-backend /app/packages ./packages
|
COPY --from=build-backend /app/packages ./packages
|
||||||
COPY package.json ./
|
COPY package.json bun.lock ./
|
||||||
COPY entrypoint.sh /app/entrypoint.sh
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
[test]
|
[test]
|
||||||
preload = ["./test/support/prisma.mock.ts"]
|
preload = ["./test/support/prisma.mock.ts"]
|
||||||
|
coverageThreshold = 0.8
|
||||||
@@ -12,6 +12,7 @@ enum CourtBookingStatus {
|
|||||||
CONFIRMED
|
CONFIRMED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
COMPLETED
|
COMPLETED
|
||||||
|
NOSHOW
|
||||||
}
|
}
|
||||||
|
|
||||||
model Sport {
|
model Sport {
|
||||||
@@ -94,3 +95,20 @@ model CourtBooking {
|
|||||||
@@index([bookingDate])
|
@@index([bookingDate])
|
||||||
@@map("court_bookings")
|
@@map("court_bookings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CourtBookingLog {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
bookingCode String @map("booking_code") @db.VarChar(8)
|
||||||
|
courtId String @map("court_id") @db.Uuid
|
||||||
|
bookingDate DateTime @map("booking_date") @db.Date
|
||||||
|
startTime String @map("start_time") @db.VarChar(5)
|
||||||
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
|
previousStatus CourtBookingStatus @map("previous_status")
|
||||||
|
newStatus CourtBookingStatus @map("new_status")
|
||||||
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
|
changedAt DateTime @default(now()) @map("changed_at")
|
||||||
|
|
||||||
|
@@map("court_booking_logs")
|
||||||
|
@@index([courtId, newStatus])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "court_booking_logs" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"booking_code" UUID NOT NULL,
|
||||||
|
"court_id" UUID NOT NULL,
|
||||||
|
"booking_date" DATE NOT NULL,
|
||||||
|
"start_time" VARCHAR(5) NOT NULL,
|
||||||
|
"end_time" VARCHAR(5) NOT NULL,
|
||||||
|
"previous_status" "CourtBookingStatus" NOT NULL,
|
||||||
|
"new_status" "CourtBookingStatus" NOT NULL,
|
||||||
|
"changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "court_booking_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "court_booking_logs_court_id_new_status_idx" ON "court_booking_logs"("court_id", "new_status");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `customer_name` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `customer_phone` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "CourtBookingStatus" ADD VALUE 'NOSHOW';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_booking_logs" ADD COLUMN "customer_name" VARCHAR(120) NOT NULL,
|
||||||
|
ADD COLUMN "customer_phone" VARCHAR(30) NOT NULL;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Changed the type of `booking_code` on the `court_booking_logs` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_booking_logs" DROP COLUMN "booking_code",
|
||||||
|
ADD COLUMN "booking_code" VARCHAR(8) NOT NULL;
|
||||||
@@ -3,10 +3,15 @@ import type { AppEnv } from '@/types/hono';
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { requestId } from 'hono/request-id';
|
import { requestId } from 'hono/request-id';
|
||||||
|
import { errorHandler } from './lib/http/error-handler';
|
||||||
|
import { requestIdMiddleware } from './lib/http/request-id';
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
app.use('*', requestIdMiddleware);
|
||||||
|
app.use('*', errorHandler);
|
||||||
|
|
||||||
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CourtBooking = Prisma.CourtBookingModel
|
export type CourtBooking = Prisma.CourtBookingModel
|
||||||
|
/**
|
||||||
|
* Model CourtBookingLog
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
||||||
/**
|
/**
|
||||||
* Model OnboardingRequest
|
* Model OnboardingRequest
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CourtBooking = Prisma.CourtBookingModel
|
export type CourtBooking = Prisma.CourtBookingModel
|
||||||
|
/**
|
||||||
|
* Model CourtBookingLog
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
||||||
/**
|
/**
|
||||||
* Model OnboardingRequest
|
* Model OnboardingRequest
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export type DayOfWeek = (typeof DayOfWeek)[keyof typeof DayOfWeek]
|
|||||||
export const CourtBookingStatus = {
|
export const CourtBookingStatus = {
|
||||||
CONFIRMED: 'CONFIRMED',
|
CONFIRMED: 'CONFIRMED',
|
||||||
CANCELLED: 'CANCELLED',
|
CANCELLED: 'CANCELLED',
|
||||||
COMPLETED: 'COMPLETED'
|
COMPLETED: 'COMPLETED',
|
||||||
|
NOSHOW: 'NOSHOW'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]
|
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ export const ModelName = {
|
|||||||
CourtAvailability: 'CourtAvailability',
|
CourtAvailability: 'CourtAvailability',
|
||||||
CourtPriceRule: 'CourtPriceRule',
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
CourtBooking: 'CourtBooking',
|
CourtBooking: 'CourtBooking',
|
||||||
|
CourtBookingLog: 'CourtBookingLog',
|
||||||
OnboardingRequest: 'OnboardingRequest',
|
OnboardingRequest: 'OnboardingRequest',
|
||||||
PasswordResetRequest: 'PasswordResetRequest',
|
PasswordResetRequest: 'PasswordResetRequest',
|
||||||
Plan: 'Plan'
|
Plan: 'Plan'
|
||||||
@@ -414,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "onboardingRequest" | "passwordResetRequest" | "plan"
|
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "onboardingRequest" | "passwordResetRequest" | "plan"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
@@ -1306,6 +1307,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CourtBookingLog: {
|
||||||
|
payload: Prisma.$CourtBookingLogPayload<ExtArgs>
|
||||||
|
fields: Prisma.CourtBookingLogFieldRefs
|
||||||
|
operations: {
|
||||||
|
findUnique: {
|
||||||
|
args: Prisma.CourtBookingLogFindUniqueArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null
|
||||||
|
}
|
||||||
|
findUniqueOrThrow: {
|
||||||
|
args: Prisma.CourtBookingLogFindUniqueOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
findFirst: {
|
||||||
|
args: Prisma.CourtBookingLogFindFirstArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null
|
||||||
|
}
|
||||||
|
findFirstOrThrow: {
|
||||||
|
args: Prisma.CourtBookingLogFindFirstOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
findMany: {
|
||||||
|
args: Prisma.CourtBookingLogFindManyArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
||||||
|
}
|
||||||
|
create: {
|
||||||
|
args: Prisma.CourtBookingLogCreateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
createMany: {
|
||||||
|
args: Prisma.CourtBookingLogCreateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
createManyAndReturn: {
|
||||||
|
args: Prisma.CourtBookingLogCreateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
||||||
|
}
|
||||||
|
delete: {
|
||||||
|
args: Prisma.CourtBookingLogDeleteArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
update: {
|
||||||
|
args: Prisma.CourtBookingLogUpdateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
deleteMany: {
|
||||||
|
args: Prisma.CourtBookingLogDeleteManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateMany: {
|
||||||
|
args: Prisma.CourtBookingLogUpdateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateManyAndReturn: {
|
||||||
|
args: Prisma.CourtBookingLogUpdateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
||||||
|
}
|
||||||
|
upsert: {
|
||||||
|
args: Prisma.CourtBookingLogUpsertArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
aggregate: {
|
||||||
|
args: Prisma.CourtBookingLogAggregateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.AggregateCourtBookingLog>
|
||||||
|
}
|
||||||
|
groupBy: {
|
||||||
|
args: Prisma.CourtBookingLogGroupByArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogGroupByOutputType>[]
|
||||||
|
}
|
||||||
|
count: {
|
||||||
|
args: Prisma.CourtBookingLogCountArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogCountAggregateOutputType> | number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
OnboardingRequest: {
|
OnboardingRequest: {
|
||||||
payload: Prisma.$OnboardingRequestPayload<ExtArgs>
|
payload: Prisma.$OnboardingRequestPayload<ExtArgs>
|
||||||
fields: Prisma.OnboardingRequestFieldRefs
|
fields: Prisma.OnboardingRequestFieldRefs
|
||||||
@@ -1738,6 +1813,23 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtBookingLogScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
bookingCode: 'bookingCode',
|
||||||
|
courtId: 'courtId',
|
||||||
|
bookingDate: 'bookingDate',
|
||||||
|
startTime: 'startTime',
|
||||||
|
endTime: 'endTime',
|
||||||
|
previousStatus: 'previousStatus',
|
||||||
|
newStatus: 'newStatus',
|
||||||
|
customerName: 'customerName',
|
||||||
|
customerPhone: 'customerPhone',
|
||||||
|
changedAt: 'changedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const OnboardingRequestScalarFieldEnum = {
|
export const OnboardingRequestScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
@@ -2068,6 +2160,7 @@ export type GlobalOmitConfig = {
|
|||||||
courtAvailability?: Prisma.CourtAvailabilityOmit
|
courtAvailability?: Prisma.CourtAvailabilityOmit
|
||||||
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
||||||
courtBooking?: Prisma.CourtBookingOmit
|
courtBooking?: Prisma.CourtBookingOmit
|
||||||
|
courtBookingLog?: Prisma.CourtBookingLogOmit
|
||||||
onboardingRequest?: Prisma.OnboardingRequestOmit
|
onboardingRequest?: Prisma.OnboardingRequestOmit
|
||||||
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
||||||
plan?: Prisma.PlanOmit
|
plan?: Prisma.PlanOmit
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const ModelName = {
|
|||||||
CourtAvailability: 'CourtAvailability',
|
CourtAvailability: 'CourtAvailability',
|
||||||
CourtPriceRule: 'CourtPriceRule',
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
CourtBooking: 'CourtBooking',
|
CourtBooking: 'CourtBooking',
|
||||||
|
CourtBookingLog: 'CourtBookingLog',
|
||||||
OnboardingRequest: 'OnboardingRequest',
|
OnboardingRequest: 'OnboardingRequest',
|
||||||
PasswordResetRequest: 'PasswordResetRequest',
|
PasswordResetRequest: 'PasswordResetRequest',
|
||||||
Plan: 'Plan'
|
Plan: 'Plan'
|
||||||
@@ -255,6 +256,23 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtBookingLogScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
bookingCode: 'bookingCode',
|
||||||
|
courtId: 'courtId',
|
||||||
|
bookingDate: 'bookingDate',
|
||||||
|
startTime: 'startTime',
|
||||||
|
endTime: 'endTime',
|
||||||
|
previousStatus: 'previousStatus',
|
||||||
|
newStatus: 'newStatus',
|
||||||
|
customerName: 'customerName',
|
||||||
|
customerPhone: 'customerPhone',
|
||||||
|
changedAt: 'changedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const OnboardingRequestScalarFieldEnum = {
|
export const OnboardingRequestScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type * from './models/Court'
|
|||||||
export type * from './models/CourtAvailability'
|
export type * from './models/CourtAvailability'
|
||||||
export type * from './models/CourtPriceRule'
|
export type * from './models/CourtPriceRule'
|
||||||
export type * from './models/CourtBooking'
|
export type * from './models/CourtBooking'
|
||||||
|
export type * from './models/CourtBookingLog'
|
||||||
export type * from './models/OnboardingRequest'
|
export type * from './models/OnboardingRequest'
|
||||||
export type * from './models/PasswordResetRequest'
|
export type * from './models/PasswordResetRequest'
|
||||||
export type * from './models/Plan'
|
export type * from './models/Plan'
|
||||||
|
|||||||
58
apps/backend/src/lib/errors.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// lib/errors.ts
|
||||||
|
export type ValidationIssue = {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppError =
|
||||||
|
| {
|
||||||
|
type: 'validation';
|
||||||
|
message: string;
|
||||||
|
issues?: ValidationIssue[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'not_found';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'conflict';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'unauthorized';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'forbidden';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'unexpected';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Errors = {
|
||||||
|
validation(message: string, issues?: ValidationIssue[]): AppError {
|
||||||
|
return { type: 'validation', message, issues };
|
||||||
|
},
|
||||||
|
|
||||||
|
notFound(message = 'Resource not found'): AppError {
|
||||||
|
return { type: 'not_found', message };
|
||||||
|
},
|
||||||
|
|
||||||
|
conflict(message: string): AppError {
|
||||||
|
return { type: 'conflict', message };
|
||||||
|
},
|
||||||
|
|
||||||
|
unauthorized(message = 'Unauthorized'): AppError {
|
||||||
|
return { type: 'unauthorized', message };
|
||||||
|
},
|
||||||
|
|
||||||
|
forbidden(message = 'Forbidden'): AppError {
|
||||||
|
return { type: 'forbidden', message };
|
||||||
|
},
|
||||||
|
|
||||||
|
unexpected(message = 'Unexpected error'): AppError {
|
||||||
|
return { type: 'unexpected', message };
|
||||||
|
},
|
||||||
|
};
|
||||||
20
apps/backend/src/lib/http/error-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// http/error-handler.ts
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
import { unexpectedProblem } from './problem-builders';
|
||||||
|
|
||||||
|
export const errorHandler: MiddlewareHandler = async (c, next) => {
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (error) {
|
||||||
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
|
|
||||||
|
logger.error({
|
||||||
|
requestId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(unexpectedProblem({ instance }), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
20
apps/backend/src/lib/http/handle-result.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Result } from '@/lib/result';
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
|
import { mapAppErrorToProblem } from './problem-mapper';
|
||||||
|
|
||||||
|
export function handleResult<T>(
|
||||||
|
c: Context,
|
||||||
|
result: Result<T>,
|
||||||
|
successStatus: ContentfulStatusCode = 200
|
||||||
|
) {
|
||||||
|
if (!result.ok) {
|
||||||
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
|
const problem = mapAppErrorToProblem(result.error, instance);
|
||||||
|
|
||||||
|
return c.json(problem, problem.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result.value, successStatus);
|
||||||
|
}
|
||||||
29
apps/backend/src/lib/http/problem-builders.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// http/problem-builders.ts
|
||||||
|
import type { ProblemDetails } from './problem-details';
|
||||||
|
|
||||||
|
export function validationProblem(params: {
|
||||||
|
detail?: string;
|
||||||
|
instance?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}): ProblemDetails {
|
||||||
|
return {
|
||||||
|
type: 'https://api.myapp.dev/problems/validation',
|
||||||
|
title: 'Bad Request',
|
||||||
|
status: 400,
|
||||||
|
detail: params.detail ?? 'Invalid request data',
|
||||||
|
instance: params.instance,
|
||||||
|
errors: params.errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unexpectedProblem(params?: {
|
||||||
|
instance?: string;
|
||||||
|
}): ProblemDetails {
|
||||||
|
return {
|
||||||
|
type: 'https://api.myapp.dev/problems/unexpected',
|
||||||
|
title: 'Internal Server Error',
|
||||||
|
status: 500,
|
||||||
|
detail: 'Internal server error',
|
||||||
|
instance: params?.instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
apps/backend/src/lib/http/problem-details.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
|
|
||||||
|
export type ProblemDetails = {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
status: ContentfulStatusCode;
|
||||||
|
detail?: string;
|
||||||
|
instance?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
};
|
||||||
51
apps/backend/src/lib/http/problem-mapper.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { AppError } from '@/lib/errors';
|
||||||
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
|
import type { ProblemDetails } from './problem-details';
|
||||||
|
|
||||||
|
function statusFromError(error: AppError): ContentfulStatusCode {
|
||||||
|
switch (error.type) {
|
||||||
|
case 'validation':
|
||||||
|
return 400;
|
||||||
|
case 'unauthorized':
|
||||||
|
return 401;
|
||||||
|
case 'forbidden':
|
||||||
|
return 403;
|
||||||
|
case 'not_found':
|
||||||
|
return 404;
|
||||||
|
case 'conflict':
|
||||||
|
return 409;
|
||||||
|
case 'unexpected':
|
||||||
|
return 500;
|
||||||
|
default:
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromStatus(status: ContentfulStatusCode): string {
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
return 'Bad Request';
|
||||||
|
case 401:
|
||||||
|
return 'Unauthorized';
|
||||||
|
case 403:
|
||||||
|
return 'Forbidden';
|
||||||
|
case 404:
|
||||||
|
return 'Not Found';
|
||||||
|
case 409:
|
||||||
|
return 'Conflict';
|
||||||
|
default:
|
||||||
|
return 'Internal Server Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAppErrorToProblem(error: AppError, instance?: string): ProblemDetails {
|
||||||
|
const status = statusFromError(error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: `https://api.myapp.dev/problems/${error.type}`,
|
||||||
|
title: titleFromStatus(status),
|
||||||
|
status,
|
||||||
|
detail: status === 500 ? 'Internal server error' : error.message,
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
apps/backend/src/lib/http/request-id.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
Variables: {
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
|
c.set('requestId', requestId);
|
||||||
|
c.header('X-Request-Id', requestId);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
48
apps/backend/src/lib/http/validate.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import type { ZodSchema } from 'zod';
|
||||||
|
import { validationProblem } from './problem-builders';
|
||||||
|
import { zodIssuesToRecord } from './zod-issues';
|
||||||
|
|
||||||
|
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
|
||||||
|
|
||||||
|
function makeValidator<TSchema extends ZodSchema>(target: ValidationTarget, schema: TSchema) {
|
||||||
|
return zValidator(target, schema, (result, c) => {
|
||||||
|
if (result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
validationProblem({
|
||||||
|
detail: `Invalid ${target} data`,
|
||||||
|
instance,
|
||||||
|
errors: zodIssuesToRecord(result.error.issues),
|
||||||
|
}),
|
||||||
|
400
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validate = {
|
||||||
|
json<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
|
return makeValidator('json', schema);
|
||||||
|
},
|
||||||
|
|
||||||
|
query<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
|
return makeValidator('query', schema);
|
||||||
|
},
|
||||||
|
|
||||||
|
param<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
|
return makeValidator('param', schema);
|
||||||
|
},
|
||||||
|
|
||||||
|
header<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
|
return makeValidator('header', schema);
|
||||||
|
},
|
||||||
|
|
||||||
|
form<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
|
return makeValidator('form', schema);
|
||||||
|
},
|
||||||
|
};
|
||||||
16
apps/backend/src/lib/http/zod-issues.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
type IssueLike = {
|
||||||
|
path: PropertyKey[];
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
|
||||||
|
const out: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
const key = issue.path.length ? issue.path.join('.') : 'root';
|
||||||
|
out[key] ??= [];
|
||||||
|
out[key].push(issue.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
12
apps/backend/src/lib/result.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// lib/result.ts
|
||||||
|
import type { AppError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
|
|
||||||
|
export function ok<T>(value: T): Result<T> {
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function err<E>(error: E): Result<never, E> {
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
@@ -164,7 +164,7 @@ function mapBookingResponse(booking: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
court: {
|
court: {
|
||||||
@@ -494,34 +494,42 @@ export async function updateAdminBookingStatus(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await db.courtBooking.update({
|
if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') {
|
||||||
where: { id: booking.id },
|
throw new AdminBookingServiceError(
|
||||||
|
'Solo se pueden marcar como no show las reservas confirmadas.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.courtBookingLog.create({
|
||||||
data: {
|
data: {
|
||||||
status:
|
id: uuidv7(),
|
||||||
input.status === 'COMPLETED' ? CourtBookingStatus.COMPLETED : CourtBookingStatus.CANCELLED,
|
bookingCode: booking.bookingCode,
|
||||||
},
|
courtId: booking.court.id,
|
||||||
include: {
|
bookingDate: booking.bookingDate,
|
||||||
court: {
|
startTime: booking.startTime,
|
||||||
select: {
|
endTime: booking.endTime,
|
||||||
id: true,
|
customerName: booking.customerName,
|
||||||
name: true,
|
customerPhone: booking.customerPhone,
|
||||||
sport: {
|
previousStatus: booking.status,
|
||||||
select: {
|
newStatus: input.status,
|
||||||
id: true,
|
changedAt: new Date(),
|
||||||
name: true,
|
|
||||||
slug: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
complex: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
complexName: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapBookingResponse(updated);
|
if (input.status === 'COMPLETED' || input.status === 'NOSHOW') {
|
||||||
|
await db.courtBooking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: {
|
||||||
|
status: input.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// if the booking is cancelled we delete it to free up the slot, but we keep a log of it with the cancelled status
|
||||||
|
await db.courtBooking.delete({
|
||||||
|
where: { id: booking.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapBookingResponse({ ...booking, status: input.status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ function mapBookingResponse(input: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||||
court: {
|
court: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import { Prisma } from '@/generated/prisma/client';
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
|
import { handleResult } from '@/lib/http/handle-result';
|
||||||
import { createSport } from '@/modules/sport/services/sport.service';
|
import { createSport } from '@/modules/sport/services/sport.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
import type { CreateSportInput } from '@repo/api-contract';
|
import type { CreateSportInput } from '@repo/api-contract';
|
||||||
|
|
||||||
export async function createSportHandler(c: AppContext) {
|
export async function createSportHandler(c: AppContext) {
|
||||||
const payload = c.req.valid('json' as never) as CreateSportInput;
|
const payload = c.req.valid('json' as never) as CreateSportInput;
|
||||||
|
return handleResult(c, await createSport(payload), 201);
|
||||||
try {
|
|
||||||
const sport = await createSport(payload);
|
|
||||||
return c.json(sport, 201);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
return c.json({ message: 'No se pudo crear el deporte.' }, 409);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { handleResult } from '@/lib/http/handle-result';
|
||||||
import { listSports } from '@/modules/sport/services/sport.service';
|
import { listSports } from '@/modules/sport/services/sport.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
|
|
||||||
export async function listSportsHandler(c: AppContext) {
|
export async function listSportsHandler(c: AppContext) {
|
||||||
const sports = await listSports();
|
const sports = await listSports();
|
||||||
return c.json(sports);
|
return handleResult(c, sports);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Prisma } from '@/generated/prisma/client';
|
import { Prisma, Sport } from '@/generated/prisma/client';
|
||||||
|
import { Errors } from '@/lib/errors';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { Result, err, ok } from '@/lib/result';
|
||||||
import { v7 as uuidv7 } from 'uuid';
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
|
|
||||||
export type CreateSportInput = {
|
export type CreateSportInput = {
|
||||||
@@ -51,16 +53,25 @@ async function buildUniqueSlug(source: string, excludeSportId?: string): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSports() {
|
export async function listSports(): Promise<Result<Sport[]>> {
|
||||||
return db.sport.findMany({
|
const sports = await db.sport.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
|
return ok(sports);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSport(input: CreateSportInput) {
|
export async function createSport(input: CreateSportInput) {
|
||||||
const slug = await buildUniqueSlug(input.name);
|
const slug = await buildUniqueSlug(input.name);
|
||||||
|
|
||||||
return db.sport.create({
|
const existing = await db.sport.count({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing > 0) {
|
||||||
|
return err(Errors.conflict('Ya existe un deporte con ese nombre.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await db.sport.create({
|
||||||
data: {
|
data: {
|
||||||
id: uuidv7(),
|
id: uuidv7(),
|
||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
@@ -68,6 +79,8 @@ export async function createSport(input: CreateSportInput) {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return ok(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSportById(id: string) {
|
export async function getSportById(id: string) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { validate } from '@/lib/http/validate';
|
||||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||||
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
||||||
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
|
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
|
||||||
@@ -8,18 +9,17 @@ import { zValidator } from '@hono/zod-validator';
|
|||||||
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const sportRoutes = new Hono<AppEnv>();
|
export const sportRoutes = new Hono<AppEnv>();
|
||||||
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
||||||
|
|
||||||
sportRoutes.use('*', requireAuth);
|
sportRoutes.use('*', requireAuth);
|
||||||
|
|
||||||
sportRoutes.get('/', listSportsHandler);
|
sportRoutes.get('/', listSportsHandler);
|
||||||
sportRoutes.post('/', requireSuperAdmin, zValidator('json', createSportSchema), createSportHandler);
|
sportRoutes.post('/', requireSuperAdmin, validate.json(createSportSchema), createSportHandler);
|
||||||
sportRoutes.patch(
|
sportRoutes.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
requireSuperAdmin,
|
requireSuperAdmin,
|
||||||
zValidator('param', sportIdParamsSchema),
|
validate.param(sportIdParamsSchema),
|
||||||
zValidator('json', updateSportSchema),
|
validate.json(updateSportSchema),
|
||||||
updateSportHandler
|
updateSportHandler
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
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 { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
|
||||||
|
import type { InviteComplexUserResponse } from '@repo/api-contract';
|
||||||
|
|
||||||
const inviteComplexUserMock = mock(
|
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
|
||||||
async () => undefined as unknown as InviteComplexUserResponse
|
|
||||||
);
|
|
||||||
|
|
||||||
class MockComplexMembersError extends Error {
|
class MockComplexMembersError extends Error {
|
||||||
status: 400 | 403 | 404 | 409;
|
status: 400 | 403 | 404 | 409;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
|
||||||
import {
|
import {
|
||||||
ComplexMembersError,
|
ComplexMembersError,
|
||||||
inviteComplexUser,
|
inviteComplexUser,
|
||||||
} from '@/modules/complex/services/complex-members.service';
|
} from '@/modules/complex/services/complex-members.service';
|
||||||
|
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prismaMock._reset();
|
prismaMock._reset();
|
||||||
|
|||||||
95
apps/backend/test/sport/create-sport.handler.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { Errors } from '@/lib/errors';
|
||||||
|
import { err, ok } from '@/lib/result';
|
||||||
|
import type { CreateSportInput } from '@repo/api-contract';
|
||||||
|
|
||||||
|
const createSportMock = mock(async (_input: CreateSportInput) =>
|
||||||
|
ok({
|
||||||
|
id: 'sport-1',
|
||||||
|
name: 'Tenis',
|
||||||
|
slug: 'tenis',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-04-23T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mock.module('@/modules/sport/services/sport.service', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
createSport: createSportMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createSportHandler } = await import('@/modules/sport/handlers/create-sport.handler');
|
||||||
|
|
||||||
|
type HandlerContext = {
|
||||||
|
get: (key: 'requestId') => string | undefined;
|
||||||
|
req: {
|
||||||
|
valid: () => unknown;
|
||||||
|
};
|
||||||
|
json: ReturnType<typeof mock>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createContext(body: unknown) {
|
||||||
|
const json = mock((payload: unknown, init?: unknown) => ({
|
||||||
|
payload,
|
||||||
|
init,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
get: () => undefined,
|
||||||
|
req: {
|
||||||
|
valid: () => body,
|
||||||
|
},
|
||||||
|
json,
|
||||||
|
} as unknown as HandlerContext;
|
||||||
|
|
||||||
|
return { c, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createSportMock.mockReset();
|
||||||
|
mock.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 201 with the created sport', async () => {
|
||||||
|
const createdSport = {
|
||||||
|
id: 'sport-1',
|
||||||
|
name: 'Tenis',
|
||||||
|
slug: 'tenis',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-04-23T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
createSportMock.mockResolvedValue(ok(createdSport));
|
||||||
|
|
||||||
|
const payload: CreateSportInput = { name: ' Tenis ' };
|
||||||
|
const { c, json } = createContext(payload);
|
||||||
|
|
||||||
|
const response = await createSportHandler(c as never);
|
||||||
|
|
||||||
|
expect(createSportMock).toHaveBeenCalledWith(payload);
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual(createdSport);
|
||||||
|
expect(json.mock.calls[0]?.[1]).toBe(201);
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a conflict problem when the service rejects with a conflict error', async () => {
|
||||||
|
createSportMock.mockResolvedValue(err(Errors.conflict('Ya existe un deporte con ese nombre.')));
|
||||||
|
|
||||||
|
const payload: CreateSportInput = { name: 'Tenis' };
|
||||||
|
const { c, json } = createContext(payload);
|
||||||
|
|
||||||
|
const response = await createSportHandler(c as never);
|
||||||
|
|
||||||
|
expect(createSportMock).toHaveBeenCalledWith(payload);
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual({
|
||||||
|
type: 'https://api.myapp.dev/problems/conflict',
|
||||||
|
title: 'Conflict',
|
||||||
|
status: 409,
|
||||||
|
detail: 'Ya existe un deporte con ese nombre.',
|
||||||
|
});
|
||||||
|
expect(json.mock.calls[0]?.[1]).toBe(409);
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
199
apps/backend/test/sport/sport.service.test.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { createPrismaMock } from 'bun-mock-prisma';
|
||||||
|
|
||||||
|
import type { PrismaClient } from '@/generated/prisma/client';
|
||||||
|
import type { PrismaClientMock } from 'bun-mock-prisma';
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
||||||
|
const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
|
||||||
|
|
||||||
|
mock.module('@/lib/prisma', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
db: {
|
||||||
|
sport: prismaMock.sport,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('uuid', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
v7: uuidV7Mock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createSport, getSportById, listSports, updateSport } = await import(
|
||||||
|
'@/modules/sport/services/sport.service'
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock._reset();
|
||||||
|
mock.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listSports returns sports ordered by name', async () => {
|
||||||
|
const sports = [
|
||||||
|
{
|
||||||
|
id: 'sport-2',
|
||||||
|
name: 'Tenis',
|
||||||
|
slug: 'tenis',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-04-22T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-04-22T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sport-1',
|
||||||
|
name: 'Básquet',
|
||||||
|
slug: 'basquet',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-04-21T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-04-21T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
prismaMock.sport.findMany.mockResolvedValue(sports as never);
|
||||||
|
|
||||||
|
const result = await listSports();
|
||||||
|
|
||||||
|
expect(prismaMock.sport.findMany).toHaveBeenCalledWith({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: sports,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSportById returns the requested sport', async () => {
|
||||||
|
const sport = {
|
||||||
|
id: 'sport-1',
|
||||||
|
name: 'Tenis',
|
||||||
|
slug: 'tenis',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-04-22T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-04-22T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.sport.findUnique.mockResolvedValue(sport as never);
|
||||||
|
|
||||||
|
const result = await getSportById('sport-1');
|
||||||
|
|
||||||
|
expect(prismaMock.sport.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'sport-1' },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(sport);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createSport trims the name and creates a unique slug', async () => {
|
||||||
|
const createdSport = {
|
||||||
|
id: '00000000-0000-4000-8000-000000000001',
|
||||||
|
name: 'Fútbol 7!!',
|
||||||
|
slug: 'futbol-7',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-04-23T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.sport.findFirst.mockResolvedValue(null as never);
|
||||||
|
prismaMock.sport.count.mockResolvedValue(0 as never);
|
||||||
|
prismaMock.sport.create.mockResolvedValue(createdSport as never);
|
||||||
|
|
||||||
|
const result = await createSport({ name: ' Fútbol 7!! ' });
|
||||||
|
|
||||||
|
expect(uuidV7Mock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prismaMock.sport.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { slug: 'futbol-7' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(prismaMock.sport.count).toHaveBeenCalledWith({
|
||||||
|
where: { slug: 'futbol-7' },
|
||||||
|
});
|
||||||
|
expect(prismaMock.sport.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
id: '00000000-0000-4000-8000-000000000001',
|
||||||
|
name: 'Fútbol 7!!',
|
||||||
|
slug: 'futbol-7',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: createdSport,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createSport retries with a numeric suffix when the slug already exists', async () => {
|
||||||
|
const createdSport = {
|
||||||
|
id: '00000000-0000-4000-8000-000000000001',
|
||||||
|
name: 'Tenis',
|
||||||
|
slug: 'tenis-2',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-04-23T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.sport.findFirst
|
||||||
|
.mockResolvedValueOnce({ id: 'existing-sport' } as never)
|
||||||
|
.mockResolvedValueOnce(null as never);
|
||||||
|
prismaMock.sport.count.mockResolvedValue(0 as never);
|
||||||
|
prismaMock.sport.create.mockResolvedValue(createdSport as never);
|
||||||
|
|
||||||
|
const result = await createSport({ name: 'Tenis' });
|
||||||
|
|
||||||
|
expect(prismaMock.sport.findFirst).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { slug: 'tenis' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(prismaMock.sport.findFirst).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { slug: 'tenis-2' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(prismaMock.sport.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
id: '00000000-0000-4000-8000-000000000001',
|
||||||
|
name: 'Tenis',
|
||||||
|
slug: 'tenis-2',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: createdSport,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSport updates the name, slug and active flag', async () => {
|
||||||
|
const updatedSport = {
|
||||||
|
id: 'sport-1',
|
||||||
|
name: 'Básquet 3x3',
|
||||||
|
slug: 'basquet-3x3',
|
||||||
|
isActive: false,
|
||||||
|
createdAt: new Date('2026-04-21T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.sport.findFirst.mockResolvedValue(null as never);
|
||||||
|
prismaMock.sport.update.mockResolvedValue(updatedSport as never);
|
||||||
|
|
||||||
|
const result = await updateSport('sport-1', {
|
||||||
|
name: 'Básquet 3x3',
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prismaMock.sport.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
slug: 'basquet-3x3',
|
||||||
|
id: {
|
||||||
|
not: 'sport-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(prismaMock.sport.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'sport-1' },
|
||||||
|
data: {
|
||||||
|
name: 'Básquet 3x3',
|
||||||
|
slug: 'basquet-3x3',
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual(updatedSport);
|
||||||
|
});
|
||||||
@@ -5,14 +5,16 @@ import type { PrismaClient } from '@/generated/prisma/client';
|
|||||||
import type { PrismaClientMock } from 'bun-mock-prisma';
|
import type { PrismaClientMock } from 'bun-mock-prisma';
|
||||||
|
|
||||||
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
||||||
export const sendMailMock = mock(async (_input: {
|
export const sendMailMock = mock(
|
||||||
to: string;
|
async (_input: {
|
||||||
subject: string;
|
to: string;
|
||||||
html: string;
|
subject: string;
|
||||||
text: string;
|
html: string;
|
||||||
}) => undefined);
|
text: string;
|
||||||
export const transactionMock = mock(async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) =>
|
}) => undefined
|
||||||
fn(prismaMock)
|
);
|
||||||
|
export const transactionMock = mock(
|
||||||
|
async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) => fn(prismaMock)
|
||||||
);
|
);
|
||||||
export const dbMock = {
|
export const dbMock = {
|
||||||
complexUser: prismaMock.complexUser,
|
complexUser: prismaMock.complexUser,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="iimage/x-icon" href="/src/assets/playzer-favicon-transparent.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PlayZer</title>
|
<title>PlayZer</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body lass="bg-background text-foreground antialiased">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
.counter {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
flex: 1 1 0;
|
|
||||||
padding: 32px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#docs {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#spacer {
|
|
||||||
height: 88px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 0;
|
|
||||||
border-left-color: var(--border);
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
right: 0;
|
|
||||||
border-right-color: var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
BIN
apps/frontend/src/assets/playzer-favicon-512-transparent.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
apps/frontend/src/assets/playzer-favicon-transparent.ico
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/frontend/src/assets/playzer-favicon.ico
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/frontend/src/assets/playzer-icon-p-transparent.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
4
apps/frontend/src/assets/playzer-icon-p-transparent.svg
Normal file
|
After Width: | Height: | Size: 206 KiB |
3
apps/frontend/src/assets/playzer-icon-p.svg
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
apps/frontend/src/assets/playzer-logo-transparent.png
Normal file
|
After Width: | Height: | Size: 607 KiB |
4
apps/frontend/src/assets/playzer-logo-transparent.svg
Normal file
|
After Width: | Height: | Size: 810 KiB |
3
apps/frontend/src/assets/playzer-logo.svg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
15
apps/frontend/src/components/background.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function Background() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-[#040b11] to-[#09131c]" />
|
||||||
|
|
||||||
|
<div className="absolute left-0 top-0 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20 blur-3xl" />
|
||||||
|
<div className="absolute right-0 top-24 h-[360px] w-[360px] translate-x-1/3 rounded-full bg-reserved/15 blur-3xl" />
|
||||||
|
<div className="absolute bottom-0 left-1/2 h-[300px] w-[300px] -translate-x-1/2 translate-y-1/3 rounded-full bg-accent/10 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,oklch(1_0_0/0.055)_1px,transparent_1px),linear-gradient(to_bottom,oklch(1_0_0/0.055)_1px,transparent_1px)] bg-[size:64px_64px]" />
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,#09131c_78%)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
431
apps/frontend/src/components/header.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
Check,
|
||||||
|
Laptop,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
Moon,
|
||||||
|
Settings,
|
||||||
|
Sun,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import { UserAvatar } from '@/components/user-avatar';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
import { acceptStoredPendingInvite } from '@/lib/invitations';
|
||||||
|
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||||
|
import { useTheme } from '@/lib/theme';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore();
|
||||||
|
const processedInviteForUser = useRef<string | null>(null);
|
||||||
|
const myComplexesQuery = useQuery({
|
||||||
|
queryKey: ['my-complexes'],
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => apiClient.complexes.listMine(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
processedInviteForUser.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userKey = user?.email ?? displayName ?? 'authenticated-user';
|
||||||
|
|
||||||
|
if (processedInviteForUser.current === userKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedInviteForUser.current = userKey;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['my-complexes'] });
|
||||||
|
})();
|
||||||
|
}, [displayName, isAuthenticated, queryClient, user?.email]);
|
||||||
|
|
||||||
|
const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug;
|
||||||
|
const currentComplexName = useMemo(() => {
|
||||||
|
const complexes = myComplexesQuery.data ?? [];
|
||||||
|
if (complexes.length === 0) return 'Mi complejo';
|
||||||
|
|
||||||
|
const selected = complexes.find((complex) => complex.complexSlug === complexSlug);
|
||||||
|
|
||||||
|
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
|
||||||
|
}, [complexSlug, myComplexesQuery.data]);
|
||||||
|
|
||||||
|
const handleSelectComplex = async (complexId: string, nextSlug: string) => {
|
||||||
|
await apiClient.complexes.select({ complexId });
|
||||||
|
setCurrentComplex(nextSlug);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await signOut();
|
||||||
|
await navigate({ to: '/login' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenBookingCreate = async () => {
|
||||||
|
if (location.pathname !== '/') {
|
||||||
|
await navigate({ to: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.dispatchEvent(new Event('playzer:open-booking-create'));
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/60 dark:border-primary/20 dark:bg-[#030a10]/90 backdrop-blur-xl">
|
||||||
|
<div className="absolute inset-0 -z-10 bg-gradient-to-b from-background/85 to-background/45 dark:from-[#030a10]/95 dark:to-[#030a10]/70" />
|
||||||
|
|
||||||
|
<div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-3 sm:px-6 lg:px-8">
|
||||||
|
<Link to="/" className="group flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center shadow-primary/10 ">
|
||||||
|
<img src={PlayzerIcon} alt="Playzer" className="size-7" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="bg-gradient-to-r from-primary via-primary to-reserved bg-clip-text text-xl font-bold tracking-tight text-transparent">
|
||||||
|
Playzer
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
|
<Button
|
||||||
|
className="rounded-2xl px-5 shadow-lg shadow-primary/20"
|
||||||
|
onClick={() => {
|
||||||
|
void handleOpenBookingCreate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarDays className="mr-2 size-4" />
|
||||||
|
Reservar ahora
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hidden size-10 rounded-full ring-2 ring-transparent transition-all duration-200 hover:ring-primary/30 focus:outline-none md:flex"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={displayName}
|
||||||
|
fallbackText={displayName}
|
||||||
|
className="size-10 border border-border/60 shadow-lg shadow-black/10"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-64 rounded-2xl border-border/60 bg-popover/95 p-2 shadow-xl shadow-black/20 backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="px-2 py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserAvatar
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={displayName}
|
||||||
|
fallbackText={displayName}
|
||||||
|
className="size-9"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{user?.email}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{currentComplexName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="px-2 text-xs font-normal text-muted-foreground">
|
||||||
|
Cambiar complejo
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{myComplexesQuery.data.map((complex) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={complex.id}
|
||||||
|
className="cursor-pointer rounded-xl"
|
||||||
|
onSelect={() => {
|
||||||
|
void handleSelectComplex(complex.id, complex.complexSlug);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mr-2 flex size-4 items-center justify-center">
|
||||||
|
{complex.complexSlug === complexSlug && <Check className="size-4" />}
|
||||||
|
</span>
|
||||||
|
{complex.complexName}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuLabel className="px-2 text-xs font-normal text-muted-foreground">
|
||||||
|
Apariencia
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={theme}
|
||||||
|
onValueChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="light" className="cursor-pointer rounded-xl">
|
||||||
|
<Sun className="mr-2 size-4" />
|
||||||
|
Light
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark" className="cursor-pointer rounded-xl">
|
||||||
|
<Moon className="mr-2 size-4" />
|
||||||
|
Dark
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system" className="cursor-pointer rounded-xl">
|
||||||
|
<Laptop className="mr-2 size-4" />
|
||||||
|
System
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer rounded-xl"
|
||||||
|
onSelect={() => {
|
||||||
|
void navigate({ to: '/profile' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<User className="mr-2 size-4" />
|
||||||
|
Mi Perfil
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{currentComplexSlug && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer rounded-xl"
|
||||||
|
onSelect={() => {
|
||||||
|
void navigate({
|
||||||
|
to: '/complex/$slug/edit',
|
||||||
|
params: { slug: currentComplexSlug },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 size-4" />
|
||||||
|
Configuración
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer rounded-xl text-destructive focus:bg-destructive/10 focus:text-destructive"
|
||||||
|
onSelect={() => {
|
||||||
|
void handleSignOut();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 size-4" />
|
||||||
|
Cerrar sesión
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<Button asChild size="sm" variant="outline" className="hidden rounded-2xl md:inline-flex">
|
||||||
|
<Link to="/login">Login</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="rounded-2xl md:hidden">
|
||||||
|
{open ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="w-[88vw] border-border/60 bg-background/95 p-5 backdrop-blur-xl sm:max-w-sm"
|
||||||
|
>
|
||||||
|
<div className="mt-6 flex flex-col gap-6">
|
||||||
|
<div className="rounded-3xl border border-border/60 bg-card/70 p-4 shadow-xl shadow-black/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserAvatar
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={displayName}
|
||||||
|
fallbackText={displayName}
|
||||||
|
className="size-11"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{user?.email}</p>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{currentComplexName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="rounded-2xl shadow-lg shadow-primary/20"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
void handleOpenBookingCreate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarDays className="mr-2 size-4" />
|
||||||
|
Reservar ahora
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between px-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Cuenta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="justify-start rounded-2xl"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
void navigate({ to: '/login' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="py-1">
|
||||||
|
<p className="px-3 py-1 text-xs text-muted-foreground">Apariencia</p>
|
||||||
|
{[
|
||||||
|
{ value: 'light', label: 'Light', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||||
|
{ value: 'system', label: 'System', icon: Laptop },
|
||||||
|
].map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start rounded-2xl"
|
||||||
|
onClick={() => setTheme(item.value as 'light' | 'dark' | 'system')}
|
||||||
|
>
|
||||||
|
<span className="mr-2 flex size-4 items-center justify-center">
|
||||||
|
{theme === item.value ? (
|
||||||
|
<Check className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Icon className="size-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
||||||
|
<div className="py-1">
|
||||||
|
<p className="px-3 py-1 text-xs text-muted-foreground">Cambiar complejo</p>
|
||||||
|
{myComplexesQuery.data.map((complex) => (
|
||||||
|
<Button
|
||||||
|
key={complex.id}
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start rounded-2xl"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
void handleSelectComplex(complex.id, complex.complexSlug);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mr-2 flex size-4 items-center justify-center">
|
||||||
|
{complex.complexSlug === complexSlug && <Check className="size-4" />}
|
||||||
|
</span>
|
||||||
|
{complex.complexName}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="justify-start rounded-2xl"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
void navigate({ to: '/profile' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<User className="mr-2 size-4" />
|
||||||
|
Mi Perfil
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentComplexSlug && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="justify-start rounded-2xl"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
void navigate({
|
||||||
|
to: '/complex/$slug/edit',
|
||||||
|
params: { slug: currentComplexSlug },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 size-4" />
|
||||||
|
Configuración
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="justify-start rounded-2xl text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
void handleSignOut();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 size-4" />
|
||||||
|
Cerrar sesión
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/frontend/src/components/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Background } from '@/components/background';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { useLocation } from '@tanstack/react-router';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Header } from './header';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ children }: LayoutProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const location = useLocation();
|
||||||
|
const isMobileHome = isMobile && location.pathname === '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-dvh overflow-x-hidden bg-background text-foreground">
|
||||||
|
<Background />
|
||||||
|
{!isMobileHome && <Header />}
|
||||||
|
<main
|
||||||
|
className={
|
||||||
|
isMobileHome
|
||||||
|
? 'relative z-10 mx-auto w-full max-w-7xl'
|
||||||
|
: 'relative z-10 mx-auto w-full max-w-7xl px-3 py-6 sm:px-6 lg:px-8'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import { DayPicker } from 'react-day-picker';
|
|||||||
|
|
||||||
import { buttonVariants } from '@/components/ui/button';
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { es } from 'date-fns/locale';
|
||||||
|
|
||||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
|
locale={es}
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
className={cn('p-3', className)}
|
className={cn('p-3', className)}
|
||||||
classNames={{
|
classNames={{
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function DialogFooter({
|
|||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
|
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-green-50/70 p-4 sm:flex-row sm:justify-end dark:bg-muted/50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
128
apps/frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = 'right',
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||||
|
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn('flex flex-col gap-0.5 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn('font-heading text-base font-medium text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
1206
apps/frontend/src/components/ui/stepper.tsx
Normal file
@@ -1,5 +1,4 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
type UserAvatarProps = {
|
type UserAvatarProps = {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
@@ -22,25 +21,9 @@ export function UserAvatar({
|
|||||||
size = 'default',
|
size = 'default',
|
||||||
className,
|
className,
|
||||||
}: UserAvatarProps) {
|
}: UserAvatarProps) {
|
||||||
const [imageError, setImageError] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setImageError(false);
|
|
||||||
}, [src]);
|
|
||||||
|
|
||||||
const canShowImage = Boolean(src) && !imageError;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar size={size} className={className}>
|
<Avatar size={size} className={className}>
|
||||||
{canShowImage && (
|
<AvatarImage src={src ?? undefined} alt={alt} />
|
||||||
<AvatarImage
|
|
||||||
src={src ?? undefined}
|
|
||||||
alt={alt}
|
|
||||||
onError={() => {
|
|
||||||
setImageError(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<AvatarFallback>{getInitials(fallbackText)}</AvatarFallback>
|
<AvatarFallback>{getInitials(fallbackText)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
export function AboutPage() {
|
|
||||||
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">
|
|
||||||
<h2 className="text-2xl font-semibold">About</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Ruta de ejemplo funcionando con TanStack Router.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
506
apps/frontend/src/features/booking/booking-provider.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||||
|
import type { AdminBooking, ComplexWithRole, Court } from '@repo/api-contract';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import type {
|
||||||
|
BookingCourtSchedule,
|
||||||
|
BookingSlotDraft,
|
||||||
|
BookingStatusFilter,
|
||||||
|
BookingSummary,
|
||||||
|
BookingTimeRange,
|
||||||
|
BookingTimelineSegment,
|
||||||
|
BookingViewMode,
|
||||||
|
} from './booking.types';
|
||||||
|
import {
|
||||||
|
addDaysIso,
|
||||||
|
formatBookingDate,
|
||||||
|
getDayOfWeek,
|
||||||
|
getNowTime,
|
||||||
|
isTodayIso,
|
||||||
|
minutesToTime,
|
||||||
|
timeToMinutes,
|
||||||
|
toIsoDateLocal,
|
||||||
|
} from './lib/booking-time';
|
||||||
|
|
||||||
|
interface BookingProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
complex: ComplexWithRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateManualBookingPayload {
|
||||||
|
courtId: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
customerName: string;
|
||||||
|
customerPhone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingContextValue {
|
||||||
|
complex: ComplexWithRole;
|
||||||
|
courts: Court[];
|
||||||
|
bookings: AdminBooking[];
|
||||||
|
schedules: BookingCourtSchedule[];
|
||||||
|
selectedDate: string;
|
||||||
|
selectedSportId: string;
|
||||||
|
selectedStatus: BookingStatusFilter;
|
||||||
|
viewMode: BookingViewMode;
|
||||||
|
visibleTimeRange: BookingTimeRange;
|
||||||
|
timelineHours: string[];
|
||||||
|
summary: BookingSummary;
|
||||||
|
currentTime: string | null;
|
||||||
|
selectedSlot: BookingSlotDraft | null;
|
||||||
|
selectedSegment: BookingTimelineSegment | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
isCreateBookingOpen: boolean;
|
||||||
|
createBookingError: string | null;
|
||||||
|
isCreatingBooking: boolean;
|
||||||
|
bookingToolsOpen: boolean;
|
||||||
|
setSelectedDate: (date: string) => void;
|
||||||
|
moveSelectedDate: (amount: number) => void;
|
||||||
|
setSelectedSportId: (sportId: string) => void;
|
||||||
|
setSelectedStatus: (status: BookingStatusFilter) => void;
|
||||||
|
setViewMode: (mode: BookingViewMode) => void;
|
||||||
|
setVisibleTimeRange: (range: BookingTimeRange) => void;
|
||||||
|
openCreateBooking: (draft?: Partial<BookingSlotDraft>) => void;
|
||||||
|
closeCreateBooking: () => void;
|
||||||
|
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
|
||||||
|
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void;
|
||||||
|
exportDayReport: () => void;
|
||||||
|
openBookingTools: (segment?: BookingTimelineSegment) => void;
|
||||||
|
closeBookingTools: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookingContext = createContext<BookingContextValue | null>(null);
|
||||||
|
|
||||||
|
const DEFAULT_TIME_RANGE: BookingTimeRange = {
|
||||||
|
start: '08:00',
|
||||||
|
end: '22:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractMessage(error: unknown, fallback: string) {
|
||||||
|
if (error instanceof ApiClientError) {
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTimelineHours(range: BookingTimeRange) {
|
||||||
|
const start = timeToMinutes(range.start);
|
||||||
|
const end = timeToMinutes(range.end);
|
||||||
|
const hours: string[] = [];
|
||||||
|
|
||||||
|
for (let minute = start; minute <= end; minute += 60) {
|
||||||
|
hours.push(minutesToTime(minute));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveBooking(booking: AdminBooking) {
|
||||||
|
return (
|
||||||
|
booking.status === 'CONFIRMED' || booking.status === 'COMPLETED' || booking.status === 'NOSHOW'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) {
|
||||||
|
return bookings
|
||||||
|
.filter(
|
||||||
|
(booking) =>
|
||||||
|
booking.courtId === court.id && booking.date === selectedDate && isActiveBooking(booking)
|
||||||
|
)
|
||||||
|
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapsBooking(start: number, end: number, booking: AdminBooking) {
|
||||||
|
const bookingStart = timeToMinutes(booking.startTime);
|
||||||
|
const bookingEnd = timeToMinutes(booking.endTime);
|
||||||
|
|
||||||
|
return start < bookingEnd && end > bookingStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSegmentsForCourt(
|
||||||
|
court: Court,
|
||||||
|
bookings: AdminBooking[],
|
||||||
|
selectedDate: string,
|
||||||
|
visibleRange: BookingTimeRange
|
||||||
|
) {
|
||||||
|
const dayOfWeek = getDayOfWeek(selectedDate);
|
||||||
|
const rangeStart = timeToMinutes(visibleRange.start);
|
||||||
|
const rangeEnd = timeToMinutes(visibleRange.end);
|
||||||
|
const courtBookings = getCourtBookings(court, bookings, selectedDate);
|
||||||
|
const segments: BookingTimelineSegment[] = [];
|
||||||
|
const slotDuration = court.slotDurationMinutes;
|
||||||
|
|
||||||
|
const availabilityRanges = court.availability
|
||||||
|
.filter((range) => range.dayOfWeek === dayOfWeek)
|
||||||
|
.map((range) => ({
|
||||||
|
start: Math.max(timeToMinutes(range.startTime), rangeStart),
|
||||||
|
end: Math.min(timeToMinutes(range.endTime), rangeEnd),
|
||||||
|
}))
|
||||||
|
.filter((range) => range.start < range.end)
|
||||||
|
.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
for (const booking of courtBookings) {
|
||||||
|
const bookingStart = timeToMinutes(booking.startTime);
|
||||||
|
const bookingEnd = timeToMinutes(booking.endTime);
|
||||||
|
|
||||||
|
if (bookingEnd > rangeStart && bookingStart < rangeEnd) {
|
||||||
|
segments.push({
|
||||||
|
id: booking.id,
|
||||||
|
courtId: court.id,
|
||||||
|
status: 'reserved',
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
startMinutes: Math.max(bookingStart, rangeStart),
|
||||||
|
endMinutes: Math.min(bookingEnd, rangeEnd),
|
||||||
|
booking,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const availability of availabilityRanges) {
|
||||||
|
for (
|
||||||
|
let slotStart = availability.start;
|
||||||
|
slotStart + slotDuration <= availability.end;
|
||||||
|
slotStart += slotDuration
|
||||||
|
) {
|
||||||
|
const slotEnd = slotStart + slotDuration;
|
||||||
|
const isBooked = courtBookings.some((booking) =>
|
||||||
|
overlapsBooking(slotStart, slotEnd, booking)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isBooked) continue;
|
||||||
|
|
||||||
|
const slotStartTime = minutesToTime(slotStart);
|
||||||
|
const slotEndTime = minutesToTime(slotEnd);
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
id: `${court.id}-free-${slotStartTime}-${slotEndTime}`,
|
||||||
|
courtId: court.id,
|
||||||
|
status: 'free',
|
||||||
|
startTime: slotStartTime,
|
||||||
|
endTime: slotEndTime,
|
||||||
|
startMinutes: slotStart,
|
||||||
|
endMinutes: slotEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.sort((a, b) => a.startMinutes - b.startMinutes);
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
||||||
|
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
||||||
|
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
||||||
|
const maintenanceSlots = schedules.reduce(
|
||||||
|
(total, schedule) => total + schedule.metrics.maintenance,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
|
||||||
|
freeSlots,
|
||||||
|
reservedSlots,
|
||||||
|
maintenanceSlots,
|
||||||
|
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
||||||
|
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
||||||
|
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingProvider({ children, complex }: BookingProviderProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
|
||||||
|
const [selectedDate, setSelectedDate] = useState(todayIso);
|
||||||
|
const [selectedSportId, setSelectedSportId] = useState('all');
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<BookingStatusFilter>('all');
|
||||||
|
const [viewMode, setViewMode] = useState<BookingViewMode>('panel');
|
||||||
|
const [visibleTimeRange, setVisibleTimeRange] = useState(DEFAULT_TIME_RANGE);
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<BookingSlotDraft | null>(null);
|
||||||
|
const [isCreateBookingOpen, setIsCreateBookingOpen] = useState(false);
|
||||||
|
const [selectedSegment, setSelectedSegment] = useState<BookingTimelineSegment | null>(null);
|
||||||
|
const [bookingToolsOpen, setBookingToolsOpen] = useState(false);
|
||||||
|
|
||||||
|
const courtsQuery = useQuery({
|
||||||
|
queryKey: ['courts', complex.id],
|
||||||
|
queryFn: () => apiClient.courts.listByComplex(complex.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingsQuery = useQuery({
|
||||||
|
queryKey: ['admin-bookings', complex.id, selectedDate],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.adminBookings.listByComplex(complex.id, {
|
||||||
|
fromDate: selectedDate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookings = useMemo(
|
||||||
|
() => (bookingsQuery.data?.bookings ?? []).filter((booking) => booking.date === selectedDate),
|
||||||
|
[bookingsQuery.data?.bookings, selectedDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const courts = useMemo(
|
||||||
|
() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [],
|
||||||
|
[courtsQuery.data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredCourts = useMemo(() => {
|
||||||
|
if (selectedSportId === 'all') return courts;
|
||||||
|
return courts.filter((court) => court.sportId === selectedSportId);
|
||||||
|
}, [courts, selectedSportId]);
|
||||||
|
|
||||||
|
const schedules = useMemo<BookingCourtSchedule[]>(() => {
|
||||||
|
return filteredCourts
|
||||||
|
.map((court) => {
|
||||||
|
const segments = buildSegmentsForCourt(court, bookings, selectedDate, visibleTimeRange);
|
||||||
|
const metrics = {
|
||||||
|
free: segments.filter((segment) => segment.status === 'free').length,
|
||||||
|
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
||||||
|
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { court, segments, metrics };
|
||||||
|
})
|
||||||
|
.filter((schedule) => {
|
||||||
|
if (selectedStatus === 'all') return true;
|
||||||
|
return schedule.segments.some((segment) => segment.status === selectedStatus);
|
||||||
|
});
|
||||||
|
}, [bookings, filteredCourts, selectedDate, selectedStatus, visibleTimeRange]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => getSummary(schedules), [schedules]);
|
||||||
|
const timelineHours = useMemo(() => makeTimelineHours(visibleTimeRange), [visibleTimeRange]);
|
||||||
|
|
||||||
|
const [now, setNow] = useState(() => getNowTime());
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setNow(getNowTime());
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentTime = useMemo(() => {
|
||||||
|
if (!isTodayIso(selectedDate)) return null;
|
||||||
|
const nowMinutes = timeToMinutes(now);
|
||||||
|
const start = timeToMinutes(visibleTimeRange.start);
|
||||||
|
const end = timeToMinutes(visibleTimeRange.end);
|
||||||
|
|
||||||
|
if (nowMinutes < start || nowMinutes > end) return null;
|
||||||
|
return now;
|
||||||
|
}, [selectedDate, visibleTimeRange, now]);
|
||||||
|
|
||||||
|
const createBookingMutation = useMutation({
|
||||||
|
mutationFn: (payload: CreateManualBookingPayload) =>
|
||||||
|
apiClient.adminBookings.create(complex.id, {
|
||||||
|
date: payload.date,
|
||||||
|
courtId: payload.courtId,
|
||||||
|
startTime: payload.startTime,
|
||||||
|
customerName: payload.customerName,
|
||||||
|
customerPhone: payload.customerPhone,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['manual-booking-availability'] });
|
||||||
|
setIsCreateBookingOpen(false);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatusMutation = useMutation({
|
||||||
|
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW' }) =>
|
||||||
|
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreateBooking = useCallback(
|
||||||
|
(draft?: Partial<BookingSlotDraft>) => {
|
||||||
|
setSelectedSlot({
|
||||||
|
date: draft?.date ?? selectedDate,
|
||||||
|
courtId: draft?.courtId,
|
||||||
|
startTime: draft?.startTime,
|
||||||
|
sportId: draft?.sportId,
|
||||||
|
});
|
||||||
|
setIsCreateBookingOpen(true);
|
||||||
|
},
|
||||||
|
[selectedDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openBookingTools = useCallback(
|
||||||
|
(segment?: BookingTimelineSegment) => {
|
||||||
|
setSelectedSegment(segment ?? null);
|
||||||
|
setBookingToolsOpen(true);
|
||||||
|
console.log(JSON.stringify(segment, null, 2));
|
||||||
|
},
|
||||||
|
[selectedDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeBookingTools = () => setBookingToolsOpen(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpenCreateBooking = () => {
|
||||||
|
openCreateBooking();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('playzer:open-booking-create', handleOpenCreateBooking);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('playzer:open-booking-create', handleOpenCreateBooking);
|
||||||
|
};
|
||||||
|
}, [openCreateBooking]);
|
||||||
|
|
||||||
|
const closeCreateBooking = useCallback(() => {
|
||||||
|
setIsCreateBookingOpen(false);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
createBookingMutation.reset();
|
||||||
|
}, [createBookingMutation]);
|
||||||
|
|
||||||
|
const createBooking = useCallback(
|
||||||
|
async (payload: CreateManualBookingPayload) => {
|
||||||
|
await createBookingMutation.mutateAsync(payload);
|
||||||
|
},
|
||||||
|
[createBookingMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateBookingStatus = useCallback(
|
||||||
|
(bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => {
|
||||||
|
updateStatusMutation.mutate({ bookingId, status });
|
||||||
|
},
|
||||||
|
[updateStatusMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportDayReport = useCallback(() => {
|
||||||
|
const rows = [
|
||||||
|
['Fecha', 'Cancha', 'Deporte', 'Inicio', 'Fin', 'Cliente', 'Telefono', 'Estado'],
|
||||||
|
...bookings.map((booking) => [
|
||||||
|
booking.date,
|
||||||
|
booking.courtName,
|
||||||
|
booking.sport.name,
|
||||||
|
booking.startTime,
|
||||||
|
booking.endTime,
|
||||||
|
booking.customerName,
|
||||||
|
booking.customerPhone,
|
||||||
|
booking.status,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `reservas-${complex.complexSlug}-${selectedDate}.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [bookings, complex.complexSlug, selectedDate]);
|
||||||
|
|
||||||
|
const value = useMemo<BookingContextValue>(
|
||||||
|
() => ({
|
||||||
|
complex,
|
||||||
|
courts,
|
||||||
|
bookings,
|
||||||
|
schedules,
|
||||||
|
selectedDate,
|
||||||
|
selectedSportId,
|
||||||
|
selectedStatus,
|
||||||
|
viewMode,
|
||||||
|
visibleTimeRange,
|
||||||
|
timelineHours,
|
||||||
|
summary,
|
||||||
|
currentTime,
|
||||||
|
selectedSlot,
|
||||||
|
isLoading: courtsQuery.isLoading || bookingsQuery.isLoading,
|
||||||
|
isError: courtsQuery.isError || bookingsQuery.isError,
|
||||||
|
errorMessage:
|
||||||
|
courtsQuery.isError || bookingsQuery.isError
|
||||||
|
? extractMessage(
|
||||||
|
courtsQuery.error ?? bookingsQuery.error,
|
||||||
|
'No pudimos cargar el panel de reservas.'
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
isCreateBookingOpen,
|
||||||
|
createBookingError: createBookingMutation.isError
|
||||||
|
? extractMessage(createBookingMutation.error, 'No pudimos crear la reserva.')
|
||||||
|
: null,
|
||||||
|
isCreatingBooking: createBookingMutation.isPending,
|
||||||
|
setSelectedDate,
|
||||||
|
moveSelectedDate: (amount) => setSelectedDate((date) => addDaysIso(date, amount)),
|
||||||
|
setSelectedSportId,
|
||||||
|
setSelectedStatus,
|
||||||
|
setViewMode,
|
||||||
|
setVisibleTimeRange,
|
||||||
|
openCreateBooking,
|
||||||
|
closeCreateBooking,
|
||||||
|
createBooking,
|
||||||
|
updateBookingStatus,
|
||||||
|
exportDayReport,
|
||||||
|
bookingToolsOpen,
|
||||||
|
openBookingTools,
|
||||||
|
closeBookingTools,
|
||||||
|
selectedSegment,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
bookings,
|
||||||
|
bookingsQuery.error,
|
||||||
|
bookingsQuery.isError,
|
||||||
|
bookingsQuery.isLoading,
|
||||||
|
closeCreateBooking,
|
||||||
|
complex,
|
||||||
|
courts,
|
||||||
|
courtsQuery.error,
|
||||||
|
courtsQuery.isError,
|
||||||
|
courtsQuery.isLoading,
|
||||||
|
createBooking,
|
||||||
|
createBookingMutation.error,
|
||||||
|
createBookingMutation.isError,
|
||||||
|
createBookingMutation.isPending,
|
||||||
|
currentTime,
|
||||||
|
exportDayReport,
|
||||||
|
isCreateBookingOpen,
|
||||||
|
openCreateBooking,
|
||||||
|
schedules,
|
||||||
|
selectedDate,
|
||||||
|
selectedSlot,
|
||||||
|
selectedSportId,
|
||||||
|
selectedStatus,
|
||||||
|
summary,
|
||||||
|
timelineHours,
|
||||||
|
updateBookingStatus,
|
||||||
|
viewMode,
|
||||||
|
visibleTimeRange,
|
||||||
|
bookingToolsOpen,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <BookingContext.Provider value={value}>{children}</BookingContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBooking() {
|
||||||
|
const context = useContext(BookingContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useBooking must be used within BookingProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { formatBookingDate };
|
||||||
38
apps/frontend/src/features/booking/booking.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import type { ComplexWithRole } from '@repo/api-contract';
|
||||||
|
import { BookingProvider } from './booking-provider';
|
||||||
|
import { BookingCreateDialog } from './components/booking-create-dialog';
|
||||||
|
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
||||||
|
import { BookingHeader } from './components/booking-header';
|
||||||
|
import { BookingMobile } from './components/booking-mobile';
|
||||||
|
import { BookingTimeline } from './components/booking-timeline';
|
||||||
|
import { BookingToolbar } from './components/booking-toolbar';
|
||||||
|
import { BookingToolsDialog } from './components/booking-tools-dialog';
|
||||||
|
|
||||||
|
interface BookingProps {
|
||||||
|
complex: ComplexWithRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Booking({ complex }: BookingProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BookingProvider complex={complex}>
|
||||||
|
{isMobile ? (
|
||||||
|
<BookingMobile />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<BookingHeader />
|
||||||
|
<BookingToolbar />
|
||||||
|
<BookingTimeline />
|
||||||
|
<div className="grid gap-5 xl:grid-cols-4">
|
||||||
|
<BookingDaySummary />
|
||||||
|
<BookingQuickActions />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<BookingCreateDialog />
|
||||||
|
<BookingToolsDialog />
|
||||||
|
</BookingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
apps/frontend/src/features/booking/booking.types.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { AdminBooking, Court } from '@repo/api-contract';
|
||||||
|
|
||||||
|
export type BookingViewMode = 'panel' | 'status';
|
||||||
|
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
|
||||||
|
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
|
||||||
|
|
||||||
|
export interface BookingTimeRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingSummary {
|
||||||
|
totalReservations: number;
|
||||||
|
freeSlots: number;
|
||||||
|
reservedSlots: number;
|
||||||
|
maintenanceSlots: number;
|
||||||
|
freePercent: number;
|
||||||
|
reservedPercent: number;
|
||||||
|
maintenancePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingCourtMetrics {
|
||||||
|
free: number;
|
||||||
|
reserved: number;
|
||||||
|
maintenance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingTimelineSegment {
|
||||||
|
id: string;
|
||||||
|
courtId: string;
|
||||||
|
status: BookingSegmentStatus;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
booking?: AdminBooking;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingCourtSchedule {
|
||||||
|
court: Court;
|
||||||
|
segments: BookingTimelineSegment[];
|
||||||
|
metrics: BookingCourtMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingSlotDraft {
|
||||||
|
courtId?: string;
|
||||||
|
date: string;
|
||||||
|
startTime?: string;
|
||||||
|
sportId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
ResponsiveDialog,
|
||||||
|
ResponsiveDialogClose,
|
||||||
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
|
} from '@/components/ui/responsive-dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useBooking } from '../booking-provider';
|
||||||
|
import {
|
||||||
|
fromIsoDateLocal,
|
||||||
|
getDayOfWeek,
|
||||||
|
minutesToTime,
|
||||||
|
timeToMinutes,
|
||||||
|
toIsoDateLocal,
|
||||||
|
} from '../lib/booking-time';
|
||||||
|
|
||||||
|
const bookingFormSchema = z.object({
|
||||||
|
customerName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, 'Ingresa un nombre válido.')
|
||||||
|
.max(120, 'El nombre no puede superar los 120 caracteres.'),
|
||||||
|
customerPhone: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(6, 'Ingresa un telefono válido.')
|
||||||
|
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type BookingForm = z.infer<typeof bookingFormSchema>;
|
||||||
|
|
||||||
|
export function BookingCreateDialog() {
|
||||||
|
const {
|
||||||
|
courts,
|
||||||
|
bookings,
|
||||||
|
selectedDate,
|
||||||
|
selectedSlot,
|
||||||
|
isCreateBookingOpen,
|
||||||
|
createBookingError,
|
||||||
|
isCreatingBooking,
|
||||||
|
closeCreateBooking,
|
||||||
|
createBooking,
|
||||||
|
} = useBooking();
|
||||||
|
|
||||||
|
const [date, setDate] = useState(selectedSlot?.date ?? selectedDate);
|
||||||
|
const [sportId, setSportId] = useState(selectedSlot?.sportId ?? 'all');
|
||||||
|
const [courtId, setCourtId] = useState(selectedSlot?.courtId ?? '');
|
||||||
|
const [startTime, setStartTime] = useState(selectedSlot?.startTime ?? '');
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setFocus,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
} = useForm<BookingForm>({
|
||||||
|
resolver: zodResolver(bookingFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
customerName: '',
|
||||||
|
customerPhone: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreateBookingOpen) return;
|
||||||
|
|
||||||
|
setDate(selectedSlot?.date ?? selectedDate);
|
||||||
|
setSportId(selectedSlot?.sportId ?? 'all');
|
||||||
|
setCourtId(selectedSlot?.courtId ?? '');
|
||||||
|
setStartTime(selectedSlot?.startTime ?? '');
|
||||||
|
reset();
|
||||||
|
|
||||||
|
if (selectedSlot?.courtId && selectedSlot?.startTime) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setFocus('customerName');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isCreateBookingOpen, reset, selectedDate, selectedSlot, setFocus]);
|
||||||
|
|
||||||
|
const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()];
|
||||||
|
const filteredCourts = useMemo(() => {
|
||||||
|
if (sportId === 'all') return courts;
|
||||||
|
return courts.filter((court) => court.sportId === sportId);
|
||||||
|
}, [courts, sportId]);
|
||||||
|
const selectedCourt = courts.find((court) => court.id === courtId);
|
||||||
|
const availableStartTimes = useMemo(() => {
|
||||||
|
if (!selectedCourt) return [];
|
||||||
|
|
||||||
|
const times: string[] = [];
|
||||||
|
const dayOfWeek = getDayOfWeek(date);
|
||||||
|
const dayAvailability = selectedCourt.availability
|
||||||
|
.filter((range) => range.dayOfWeek === dayOfWeek)
|
||||||
|
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
|
||||||
|
const courtBookings = bookings.filter(
|
||||||
|
(booking) =>
|
||||||
|
booking.date === date &&
|
||||||
|
booking.courtId === selectedCourt.id &&
|
||||||
|
booking.status !== 'CANCELLED'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const range of dayAvailability) {
|
||||||
|
const start = timeToMinutes(range.startTime);
|
||||||
|
const end = timeToMinutes(range.endTime);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let minute = start;
|
||||||
|
minute + selectedCourt.slotDurationMinutes <= end;
|
||||||
|
minute += selectedCourt.slotDurationMinutes
|
||||||
|
) {
|
||||||
|
const slotEnd = minute + selectedCourt.slotDurationMinutes;
|
||||||
|
const isBooked = courtBookings.some((booking) => {
|
||||||
|
const bookingStart = timeToMinutes(booking.startTime);
|
||||||
|
const bookingEnd = timeToMinutes(booking.endTime);
|
||||||
|
return minute < bookingEnd && slotEnd > bookingStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isBooked) {
|
||||||
|
times.push(minutesToTime(minute));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return times;
|
||||||
|
}, [bookings, date, selectedCourt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startTime && !availableStartTimes.includes(startTime)) {
|
||||||
|
setStartTime('');
|
||||||
|
}
|
||||||
|
}, [availableStartTimes, startTime]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: BookingForm) => {
|
||||||
|
await createBooking({
|
||||||
|
courtId,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
customerName: values.customerName,
|
||||||
|
customerPhone: values.customerPhone,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={isCreateBookingOpen}
|
||||||
|
onOpenChange={(open) => !open && closeCreateBooking()}
|
||||||
|
>
|
||||||
|
<ResponsiveDialogContent
|
||||||
|
className="data-[variant=dialog]:max-w-lg"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Crea un turno para atención telefónica o mostrador.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
<form id="booking-create-form" className="grid gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Fecha</FieldLabel>
|
||||||
|
<DatePicker
|
||||||
|
value={fromIsoDateLocal(date)}
|
||||||
|
onChange={(nextDate) => {
|
||||||
|
setDate(nextDate ? toIsoDateLocal(nextDate) : selectedDate);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Deporte</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={sportId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSportId(value);
|
||||||
|
setCourtId('');
|
||||||
|
setStartTime('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
{sports.map((sport) => (
|
||||||
|
<SelectItem key={sport.id} value={sport.id}>
|
||||||
|
{sport.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Cancha</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={courtId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setCourtId(value);
|
||||||
|
setStartTime('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecciona una cancha" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{filteredCourts.map((court) => (
|
||||||
|
<SelectItem key={court.id} value={court.id}>
|
||||||
|
{court.name} · {court.sport.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Horario</FieldLabel>
|
||||||
|
<Select value={startTime} onValueChange={setStartTime}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecciona un horario" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableStartTimes.map((time) => (
|
||||||
|
<SelectItem key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter>
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline">Cancelar</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="booking-create-form"
|
||||||
|
disabled={!isValid || isCreatingBooking || !courtId || !startTime}
|
||||||
|
>
|
||||||
|
{isCreatingBooking ? 'Guardando...' : 'Crear reserva'}
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
|
||||||
|
import { useBooking } from '../booking-provider';
|
||||||
|
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
||||||
|
|
||||||
|
export function BookingDaySummary() {
|
||||||
|
const { selectedDate, summary } = useBooking();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-card/85 p-5 shadow-sm xl:col-span-3">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-base font-semibold">Resumen del día</h2>
|
||||||
|
<p className="mt-1 text-sm capitalize text-muted-foreground">
|
||||||
|
{formatBookingDate(selectedDate, { year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<SummaryCard
|
||||||
|
label="Total de bloques"
|
||||||
|
value={summary.totalReservations}
|
||||||
|
icon={CalendarDays}
|
||||||
|
className="border-border bg-background/45"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Libres"
|
||||||
|
value={summary.freeSlots}
|
||||||
|
detail={`${summary.freePercent}% del total`}
|
||||||
|
icon={UsersRound}
|
||||||
|
className="border-primary/30 bg-primary/10 text-primary"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Reservados"
|
||||||
|
value={summary.reservedSlots}
|
||||||
|
detail={`${summary.reservedPercent}% del total`}
|
||||||
|
icon={ShieldCheck}
|
||||||
|
className="border-reserved/35 bg-reserved/10 text-reserved"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Mantenimiento"
|
||||||
|
value={summary.maintenanceSlots}
|
||||||
|
detail={`${summary.maintenancePercent}% del total`}
|
||||||
|
icon={Drill}
|
||||||
|
className="border-maintenance/35 bg-maintenance/10 text-maintenance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
detail?: string;
|
||||||
|
icon: typeof CalendarDays;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCardProps) {
|
||||||
|
return (
|
||||||
|
<article className={`rounded-lg border p-4 ${className}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-transparent">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-2xl font-semibold leading-tight">{value}</p>
|
||||||
|
{detail && <p className="mt-1 truncate text-xs text-muted-foreground">{detail}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingQuickActions() {
|
||||||
|
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } =
|
||||||
|
useBooking();
|
||||||
|
const todayIso = toIsoDateLocal(new Date());
|
||||||
|
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
||||||
|
const isViewingMaintenance = selectedStatus === 'maintenance';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
||||||
|
<h2 className="text-base font-semibold">Acciones rápidas</h2>
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDate(todayIso);
|
||||||
|
setSelectedStatus(isViewingTodayReservations ? 'all' : 'reserved');
|
||||||
|
}}
|
||||||
|
aria-pressed={isViewingTodayReservations}
|
||||||
|
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-reserved/50 aria-pressed:bg-reserved/10"
|
||||||
|
>
|
||||||
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
|
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedStatus(isViewingMaintenance ? 'all' : 'maintenance')}
|
||||||
|
aria-pressed={isViewingMaintenance}
|
||||||
|
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-maintenance/50 aria-pressed:bg-maintenance/10"
|
||||||
|
>
|
||||||
|
<Drill className="size-4 text-maintenance" />
|
||||||
|
{isViewingMaintenance ? 'Ver todos los estados' : 'Ver mantenimiento'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={exportDayReport}
|
||||||
|
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Download className="size-4 text-primary" />
|
||||||
|
Exportar reporte
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useBooking } from '../booking-provider';
|
||||||
|
|
||||||
|
export function BookingHeader() {
|
||||||
|
const { complex } = useBooking();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||||
|
Panel de Reservas
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Gestiona las canchas de {complex.complexName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
1034
apps/frontend/src/features/booking/components/booking-mobile.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const items = [
|
||||||
|
{ label: 'Libre', className: 'bg-primary' },
|
||||||
|
{ label: 'Reservado', className: 'bg-reserved' },
|
||||||
|
{ label: 'Mantenimiento', className: 'bg-maintenance' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BookingStatusLegend() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-5 text-sm text-muted-foreground">
|
||||||
|
{items.map((item) => (
|
||||||
|
<span key={item.label} className="inline-flex items-center gap-2">
|
||||||
|
<span className={`size-3 rounded-full ${item.className}`} />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronLeft, ChevronRight, Dumbbell, Plus, UserX, Users } from 'lucide-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useBooking } from '../booking-provider';
|
||||||
|
import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types';
|
||||||
|
import { timeToMinutes } from '../lib/booking-time';
|
||||||
|
import { BookingStatusLegend } from './booking-status-legend';
|
||||||
|
|
||||||
|
const courtInfoWidth = 220;
|
||||||
|
const hourWidth = 104;
|
||||||
|
|
||||||
|
export function BookingTimeline() {
|
||||||
|
const {
|
||||||
|
schedules,
|
||||||
|
timelineHours,
|
||||||
|
visibleTimeRange,
|
||||||
|
currentTime,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
errorMessage,
|
||||||
|
} = useBooking();
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevCurrentTimeRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const rangeStart = timeToMinutes(visibleTimeRange.start);
|
||||||
|
const rangeEnd = timeToMinutes(visibleTimeRange.end);
|
||||||
|
const totalMinutes = rangeEnd - rangeStart;
|
||||||
|
const gridWidth = Math.max((timelineHours.length - 1) * hourWidth, 760);
|
||||||
|
const timelineMarks = timelineHours.map((hour) => ({
|
||||||
|
hour,
|
||||||
|
left: ((timeToMinutes(hour) - rangeStart) / totalMinutes) * 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTime || !scrollRef.current) return;
|
||||||
|
if (currentTime === prevCurrentTimeRef.current) return;
|
||||||
|
prevCurrentTimeRef.current = currentTime;
|
||||||
|
|
||||||
|
const container = scrollRef.current;
|
||||||
|
const indicatorLeft =
|
||||||
|
courtInfoWidth + ((timeToMinutes(currentTime) - rangeStart) / totalMinutes) * gridWidth;
|
||||||
|
const targetScroll = indicatorLeft - container.clientWidth / 2;
|
||||||
|
const clampedScroll = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(targetScroll, container.scrollWidth - container.clientWidth)
|
||||||
|
);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
container.scrollTo({ left: clampedScroll, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}, [currentTime, rangeStart, totalMinutes, gridWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden rounded-lg border bg-card/85 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b px-4 py-4">
|
||||||
|
<BookingStatusLegend />
|
||||||
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
|
<Button type="button" size="icon-sm" variant="ghost">
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="icon-sm" variant="ghost">
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="px-4 py-10 text-sm text-muted-foreground">Cargando reservas...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && !isLoading && (
|
||||||
|
<div className="px-4 py-10 text-sm text-destructive">{errorMessage}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && schedules.length === 0 && (
|
||||||
|
<div className="px-4 py-10 text-sm text-muted-foreground">
|
||||||
|
No hay canchas para los filtros seleccionados.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && schedules.length > 0 && (
|
||||||
|
<div ref={scrollRef} className="booking-scrollbar overflow-x-auto overflow-y-hidden">
|
||||||
|
<div className="relative min-w-full" style={{ width: courtInfoWidth + gridWidth }}>
|
||||||
|
<TimelineHeader
|
||||||
|
timelineMarks={timelineMarks}
|
||||||
|
courtInfoWidth={courtInfoWidth}
|
||||||
|
gridWidth={gridWidth}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="divide-y">
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<BookingCourtRow
|
||||||
|
key={schedule.court.id}
|
||||||
|
schedule={schedule}
|
||||||
|
courtInfoWidth={courtInfoWidth}
|
||||||
|
gridWidth={gridWidth}
|
||||||
|
rangeStart={rangeStart}
|
||||||
|
totalMinutes={totalMinutes}
|
||||||
|
timelineMarks={timelineMarks}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentTime && (
|
||||||
|
<CurrentTimeIndicator
|
||||||
|
time={currentTime}
|
||||||
|
courtInfoWidth={courtInfoWidth}
|
||||||
|
gridWidth={gridWidth}
|
||||||
|
rangeStart={rangeStart}
|
||||||
|
totalMinutes={totalMinutes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineHeaderProps {
|
||||||
|
timelineMarks: Array<{ hour: string; left: number }>;
|
||||||
|
courtInfoWidth: number;
|
||||||
|
gridWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineHeader({ timelineMarks, courtInfoWidth, gridWidth }: TimelineHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sticky top-0 z-20 grid border-b bg-card/95 z-auto"
|
||||||
|
style={{ gridTemplateColumns: `${courtInfoWidth}px ${gridWidth}px` }}
|
||||||
|
>
|
||||||
|
<div className="sticky left-0 z-30 border-r bg-card/95 px-4 py-3 shadow-[8px_0_18px_-18px_oklch(0_0_0/0.45)]" />
|
||||||
|
<div className="relative h-12">
|
||||||
|
{timelineMarks.map((mark) => (
|
||||||
|
<div
|
||||||
|
key={mark.hour}
|
||||||
|
className="absolute top-0 flex h-full items-center border-l px-3 text-sm text-muted-foreground first:border-l-0"
|
||||||
|
style={{ left: `${mark.left}%` }}
|
||||||
|
>
|
||||||
|
{mark.hour}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingCourtRowProps {
|
||||||
|
schedule: BookingCourtSchedule;
|
||||||
|
courtInfoWidth: number;
|
||||||
|
gridWidth: number;
|
||||||
|
rangeStart: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
timelineMarks: Array<{ hour: string; left: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingCourtRow({
|
||||||
|
schedule,
|
||||||
|
courtInfoWidth,
|
||||||
|
gridWidth,
|
||||||
|
rangeStart,
|
||||||
|
totalMinutes,
|
||||||
|
timelineMarks,
|
||||||
|
}: BookingCourtRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid min-h-[96px]"
|
||||||
|
style={{ gridTemplateColumns: `${courtInfoWidth}px ${gridWidth}px` }}
|
||||||
|
>
|
||||||
|
<BookingCourtInfo schedule={schedule} />
|
||||||
|
<div className="relative bg-background/20 py-4">
|
||||||
|
<div className="absolute inset-y-0 left-0 right-0 opacity-70">
|
||||||
|
{timelineMarks.map((mark) => (
|
||||||
|
<span
|
||||||
|
key={mark.hour}
|
||||||
|
className="absolute top-0 h-full border-l first:border-l-0"
|
||||||
|
style={{ left: `${mark.left}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-16">
|
||||||
|
{schedule.segments.map((segment) => (
|
||||||
|
<BookingSlotBlock
|
||||||
|
key={segment.id}
|
||||||
|
segment={segment}
|
||||||
|
schedule={schedule}
|
||||||
|
rangeStart={rangeStart}
|
||||||
|
totalMinutes={totalMinutes}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
|
||||||
|
return (
|
||||||
|
<div className="sticky left-0 z-10 flex items-center gap-3 border-r bg-card/95 px-4 py-4 shadow-[8px_0_18px_-18px_oklch(0_0_0/0.45)]">
|
||||||
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-primary/40 bg-primary/15 text-primary">
|
||||||
|
<Dumbbell className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-base font-semibold">{schedule.court.name}</h2>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{schedule.court.sport.name}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
|
{schedule.metrics.free}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2 rounded-full bg-reserved" />
|
||||||
|
{schedule.metrics.reserved}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2 rounded-full bg-maintenance" />
|
||||||
|
{schedule.metrics.maintenance}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingSlotBlockProps {
|
||||||
|
segment: BookingTimelineSegment;
|
||||||
|
schedule: BookingCourtSchedule;
|
||||||
|
rangeStart: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: BookingSlotBlockProps) {
|
||||||
|
const { openCreateBooking, updateBookingStatus, openBookingTools } = useBooking();
|
||||||
|
const left = ((segment.startMinutes - rangeStart) / totalMinutes) * 100;
|
||||||
|
const width = ((segment.endMinutes - segment.startMinutes) / totalMinutes) * 100;
|
||||||
|
const isFree = segment.status === 'free';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 flex h-16 min-w-0 flex-col justify-center overflow-hidden rounded-md border px-2 text-left text-xs shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
segment.status === 'free' &&
|
||||||
|
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
||||||
|
segment.status === 'reserved' &&
|
||||||
|
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
||||||
|
segment.status === 'maintenance' &&
|
||||||
|
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
|
||||||
|
segment.booking?.status === 'COMPLETED' &&
|
||||||
|
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
||||||
|
segment.booking?.status === 'NOSHOW' &&
|
||||||
|
'border-amber-500/60 bg-amber-500/50 text-amber-950 dark:text-amber-200'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `calc(${left}% + 4px)`,
|
||||||
|
width: `calc(${width}% - 8px)`,
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (isFree) {
|
||||||
|
openCreateBooking({
|
||||||
|
courtId: schedule.court.id,
|
||||||
|
startTime: segment.startTime,
|
||||||
|
sportId: schedule.court.sportId,
|
||||||
|
});
|
||||||
|
} else if (segment.booking?.status === 'NOSHOW') {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
if (event.shiftKey && segment.booking?.status === 'CONFIRMED') {
|
||||||
|
updateBookingStatus(segment.booking.id, 'COMPLETED');
|
||||||
|
} else {
|
||||||
|
openBookingTools(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
segment.booking
|
||||||
|
? `${segment.booking.customerName} · ${segment.startTime} - ${segment.endTime}`
|
||||||
|
: `${segment.startTime} - ${segment.endTime}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isFree ? (
|
||||||
|
<span className="flex items-center justify-center gap-1 text-sm font-medium">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
<span className="hidden sm:inline">Reservar</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{segment.startTime} - {segment.endTime}
|
||||||
|
</span>
|
||||||
|
{segment.booking?.status === 'NOSHOW' && (
|
||||||
|
<span title="No Show - El cliente no se presentó">
|
||||||
|
<UserX className="size-3 shrink-0" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
||||||
|
<Users className="size-3" />
|
||||||
|
{segment.booking?.customerName ?? 'Mantenimiento'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrentTimeIndicatorProps {
|
||||||
|
time: string;
|
||||||
|
courtInfoWidth: number;
|
||||||
|
gridWidth: number;
|
||||||
|
rangeStart: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrentTimeIndicator({
|
||||||
|
time,
|
||||||
|
courtInfoWidth,
|
||||||
|
gridWidth,
|
||||||
|
rangeStart,
|
||||||
|
totalMinutes,
|
||||||
|
}: CurrentTimeIndicatorProps) {
|
||||||
|
const left = courtInfoWidth + ((timeToMinutes(time) - rangeStart) / totalMinutes) * gridWidth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute bottom-0 top-12 z-[5]" style={{ left }}>
|
||||||
|
<div className="absolute left-0 top-0 -translate-x-1/2 -translate-y-1/2 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground shadow-sm">
|
||||||
|
{time}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 left-0 top-0 w-px bg-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
|
||||||
|
import { useBooking } from '../booking-provider';
|
||||||
|
import type { BookingStatusFilter } from '../booking.types';
|
||||||
|
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
||||||
|
|
||||||
|
const timeRangeOptions = [
|
||||||
|
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
||||||
|
{ label: '06:00 - 00:00', start: '06:00', end: '23:59' },
|
||||||
|
{ label: '08:00 - 14:00', start: '08:00', end: '14:00' },
|
||||||
|
{ label: '14:00 - 22:00', start: '14:00', end: '22:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
|
||||||
|
{ value: 'all', label: 'Todos' },
|
||||||
|
{ value: 'free', label: 'Libre' },
|
||||||
|
{ value: 'reserved', label: 'Reservado' },
|
||||||
|
{ value: 'maintenance', label: 'Mantenimiento' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BookingToolbar() {
|
||||||
|
const {
|
||||||
|
courts,
|
||||||
|
selectedDate,
|
||||||
|
selectedSportId,
|
||||||
|
selectedStatus,
|
||||||
|
visibleTimeRange,
|
||||||
|
setSelectedDate,
|
||||||
|
moveSelectedDate,
|
||||||
|
setSelectedSportId,
|
||||||
|
setSelectedStatus,
|
||||||
|
setVisibleTimeRange,
|
||||||
|
} = useBooking();
|
||||||
|
|
||||||
|
const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()];
|
||||||
|
const activeRangeValue = `${visibleTimeRange.start}-${visibleTimeRange.end}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr_auto] xl:items-end">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Deporte</FieldLabel>
|
||||||
|
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
{sports.map((sport) => (
|
||||||
|
<SelectItem key={sport.id} value={sport.id}>
|
||||||
|
{sport.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Estado</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedStatus}
|
||||||
|
onValueChange={(value) => setSelectedStatus(value as BookingStatusFilter)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Fecha</FieldLabel>
|
||||||
|
<div className="grid grid-cols-[auto_1fr_auto] gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => moveSelectedDate(-1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<DatePicker
|
||||||
|
value={fromIsoDateLocal(selectedDate)}
|
||||||
|
onChange={(date) =>
|
||||||
|
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="icon" onClick={() => moveSelectedDate(1)}>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Hora</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={activeRangeValue}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const option = timeRangeOptions.find((item) => `${item.start}-${item.end}` === value);
|
||||||
|
if (option) {
|
||||||
|
setVisibleTimeRange({ start: option.start, end: option.end });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<Clock className="mr-2 size-4 text-muted-foreground" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeRangeOptions.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={`${option.start}-${option.end}`}
|
||||||
|
value={`${option.start}-${option.end}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button variant="outline" className="xl:self-end">
|
||||||
|
<SlidersHorizontal className="size-4" />
|
||||||
|
Filtros avanzados
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
ResponsiveDialog,
|
||||||
|
ResponsiveDialogClose,
|
||||||
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
|
} from '@/components/ui/responsive-dialog';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
BadgeCheck,
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
MessageCircle,
|
||||||
|
Phone,
|
||||||
|
User,
|
||||||
|
Wrench,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useBooking } from '../booking-provider';
|
||||||
|
|
||||||
|
function formatDate(date: string | undefined) {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
const formatted = new Intl.DateTimeFormat('es-AR', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
}).format(new Date(`${date}T00:00:00`));
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
.split(' ')
|
||||||
|
.map((word, index) => {
|
||||||
|
if (index === 0 || index === 3) {
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||||
|
CONFIRMED: {
|
||||||
|
label: 'Reservada',
|
||||||
|
className: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||||||
|
},
|
||||||
|
COMPLETED: {
|
||||||
|
label: 'Completada',
|
||||||
|
className: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
label: 'Cancelada',
|
||||||
|
className: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||||
|
},
|
||||||
|
NOSHOW: {
|
||||||
|
label: 'No show',
|
||||||
|
className: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BookingToolsDialog() {
|
||||||
|
const { selectedSegment, bookingToolsOpen, closeBookingTools, updateBookingStatus } =
|
||||||
|
useBooking();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const booking = selectedSegment?.booking;
|
||||||
|
|
||||||
|
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
|
||||||
|
label: booking?.status,
|
||||||
|
className: 'bg-slate-500/15 text-slate-300 border-slate-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendWhatsappReminder = () => {
|
||||||
|
const phone = booking?.customerPhone;
|
||||||
|
if (!phone) return;
|
||||||
|
|
||||||
|
const message = `
|
||||||
|
Hola ${booking?.customerName}. \nTe recordamos que tenés una reserva para el día *${formatDate(booking?.date)} de ${booking?.startTime} a ${booking?.endTime} en la cancha ${booking?.courtName} (${booking?.sport?.name})*. ¡Te esperamos! \nSi no vas a poder asistir, por favor avisanos para que otros clientes puedan reservar. Gracias.\n\n*Equipo de ${booking?.complexName}*`;
|
||||||
|
|
||||||
|
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatus = (nextStatus: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => {
|
||||||
|
if (!booking?.id) return;
|
||||||
|
updateBookingStatus(booking.id, nextStatus);
|
||||||
|
closeBookingTools();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={bookingToolsOpen}
|
||||||
|
onOpenChange={(open) => !open && closeBookingTools()}
|
||||||
|
>
|
||||||
|
<ResponsiveDialogContent
|
||||||
|
className="data-[variant=drawer]:!h-[92dvh] data-[variant=drawer]:!max-h-[92dvh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<MobileBookingToolsSheet
|
||||||
|
status={status}
|
||||||
|
onComplete={() => updateStatus('COMPLETED')}
|
||||||
|
onCancel={() => updateStatus('CANCELLED')}
|
||||||
|
onReminder={sendWhatsappReminder}
|
||||||
|
onNoShow={() => updateStatus('NOSHOW')}
|
||||||
|
/>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
|
||||||
|
<ResponsiveDialogContent
|
||||||
|
className="data-[variant=dialog]:max-w-5xl data-[variant=drawer]:max-h-[92dvh] data-[variant=drawer]:overflow-y-auto data-[variant=drawer]:px-4 data-[variant=drawer]:pb-5"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<ResponsiveDialogHeader className="data-[variant=drawer]:px-0 data-[variant=drawer]:text-left">
|
||||||
|
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Administra la reserva y su estado.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
<section className="mx-0 px-0 pt-3 pb-3 sm:-mx-6 sm:px-6 sm:pb-5">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-0">
|
||||||
|
<DetailItem
|
||||||
|
icon={<MapPin className="h-5 w-5" />}
|
||||||
|
label="Cancha"
|
||||||
|
value={selectedSegment?.booking?.courtName ?? '-'}
|
||||||
|
helper={selectedSegment?.booking?.sport?.name}
|
||||||
|
withDivider
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<BadgeCheck className="h-5 w-5" />}
|
||||||
|
label="Estado actual"
|
||||||
|
value={status.label}
|
||||||
|
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
|
||||||
|
withDivider
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<User className="h-5 w-5" />}
|
||||||
|
label="Cliente"
|
||||||
|
value={selectedSegment?.booking?.customerName ?? '-'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
label="Fecha"
|
||||||
|
value={`${formatDate(selectedSegment?.booking?.date)}`}
|
||||||
|
withDivider
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
label="Hora"
|
||||||
|
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
|
||||||
|
withDivider
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Phone className="h-5 w-5" />}
|
||||||
|
label="Teléfono"
|
||||||
|
value={selectedSegment?.booking?.customerPhone ?? '-'}
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator className="dark:bg-slate-800 bg-slate-300" />
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Elegí qué hacer según lo que ocurrió con el cliente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<ActionButton
|
||||||
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
|
title="Completar reserva"
|
||||||
|
description="El cliente se presentó para usar la cancha."
|
||||||
|
className="border-emerald-500/40 bg-emerald-500/10 text-lg text-emerald-600 hover:bg-emerald-500/15 hover:text-emerald-800"
|
||||||
|
onClick={() => {
|
||||||
|
updateStatus('COMPLETED');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<XCircle className="h-5 w-5" />}
|
||||||
|
title="Cancelar reserva"
|
||||||
|
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
|
||||||
|
className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800"
|
||||||
|
onClick={() => {
|
||||||
|
updateStatus('CANCELLED');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<MessageCircle className="h-5 w-5" />}
|
||||||
|
title="Enviar recordatorio"
|
||||||
|
description="Enviar un mensaje por WhatsApp al cliente."
|
||||||
|
className="border-sky-500/40 bg-sky-500/10 text-lg text-sky-600 hover:bg-sky-500/15 hover:text-sky-800"
|
||||||
|
onClick={sendWhatsappReminder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<AlertTriangle className="h-5 w-5" />}
|
||||||
|
title="Marcar como No Show"
|
||||||
|
description="El cliente no vino y no canceló."
|
||||||
|
className="border-amber-500/40 bg-amber-500/10 text-lg text-amber-600 hover:bg-amber-500/15 hover:text-amber-800"
|
||||||
|
onClick={() => {
|
||||||
|
updateStatus('NOSHOW');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter className="data-[variant=drawer]:px-0">
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline" className="data-[variant=drawer]:h-11">
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileBookingToolsSheet({
|
||||||
|
status,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
onReminder,
|
||||||
|
onNoShow,
|
||||||
|
}: {
|
||||||
|
status: { label: string | undefined; className: string };
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onReminder: () => void;
|
||||||
|
onNoShow: () => void;
|
||||||
|
}) {
|
||||||
|
const { selectedSegment } = useBooking();
|
||||||
|
const booking = selectedSegment?.booking;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col pt-3">
|
||||||
|
<ResponsiveDialogHeader className="shrink-0 px-4 pb-3 text-left">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<ResponsiveDialogTitle className="text-lg">Reserva</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
{booking?.courtName ?? '-'} · {booking?.startTime ?? '--:--'} a{' '}
|
||||||
|
{booking?.endTime ?? '--:--'}
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-normal',
|
||||||
|
status.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.label ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
||||||
|
<section className="rounded-lg border border-border/70 bg-card/75 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||||
|
<MapPin className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">Cancha</p>
|
||||||
|
<p className="truncate text-xl font-semibold">{booking?.courtName ?? '-'}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{booking?.sport?.name ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-3 grid gap-2">
|
||||||
|
<MobileDetailRow
|
||||||
|
icon={<User className="size-5" />}
|
||||||
|
label="Cliente"
|
||||||
|
value={booking?.customerName ?? '-'}
|
||||||
|
/>
|
||||||
|
<MobileDetailRow
|
||||||
|
icon={<Phone className="size-5" />}
|
||||||
|
label="Teléfono"
|
||||||
|
value={booking?.customerPhone ?? '-'}
|
||||||
|
/>
|
||||||
|
<MobileDetailRow
|
||||||
|
icon={<CalendarDays className="size-5" />}
|
||||||
|
label="Fecha"
|
||||||
|
value={formatDate(booking?.date) || '-'}
|
||||||
|
/>
|
||||||
|
<MobileDetailRow
|
||||||
|
icon={<Clock className="size-5" />}
|
||||||
|
label="Horario"
|
||||||
|
value={`${booking?.startTime ?? '--:--'} a ${booking?.endTime ?? '--:--'}`}
|
||||||
|
/>
|
||||||
|
<MobileDetailRow
|
||||||
|
icon={<Wrench className="size-5" />}
|
||||||
|
label="Estado"
|
||||||
|
value={status.label ?? '-'}
|
||||||
|
valueClassName="uppercase text-primary"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium">Acciones</h3>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<MobileActionButton
|
||||||
|
icon={<CheckCircle2 className="size-5" />}
|
||||||
|
label="Completar"
|
||||||
|
className="border-primary/45 bg-primary/12 text-primary"
|
||||||
|
onClick={onComplete}
|
||||||
|
/>
|
||||||
|
<MobileActionButton
|
||||||
|
icon={<MessageCircle className="size-5" />}
|
||||||
|
label="Recordar"
|
||||||
|
className="border-reserved/45 bg-reserved/12 text-reserved"
|
||||||
|
onClick={onReminder}
|
||||||
|
/>
|
||||||
|
<MobileActionButton
|
||||||
|
icon={<XCircle className="size-5" />}
|
||||||
|
label="Cancelar"
|
||||||
|
className="border-destructive/45 bg-destructive/12 text-destructive"
|
||||||
|
onClick={onCancel}
|
||||||
|
/>
|
||||||
|
<MobileActionButton
|
||||||
|
icon={<AlertTriangle className="size-5" />}
|
||||||
|
label="No show"
|
||||||
|
className="border-warning/50 bg-warning/12 text-warning"
|
||||||
|
onClick={onNoShow}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ResponsiveDialogFooter className="px-0 pb-0 pt-3">
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline" className="h-11 w-full rounded-lg">
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileDetailRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClassName,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-secondary/35 p-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-secondary text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn('truncate text-base font-semibold', valueClassName)}>{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileActionButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className={cn('h-14 justify-start rounded-lg px-3 text-sm font-semibold', className)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
helper,
|
||||||
|
valueClassName,
|
||||||
|
withDivider,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
helper?: string;
|
||||||
|
valueClassName?: string;
|
||||||
|
withDivider?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-start gap-4 rounded-lg bg-secondary/45 p-3 sm:bg-transparent sm:px-5 sm:py-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{withDivider && (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute
|
||||||
|
right-0
|
||||||
|
top-1/2
|
||||||
|
hidden
|
||||||
|
h-16
|
||||||
|
w-px
|
||||||
|
-translate-y-1/2
|
||||||
|
dark:bg-slate-800
|
||||||
|
bg-slate-300
|
||||||
|
sm:block
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-300/80 text-slate-800 dark:bg-slate-800/80 dark:text-slate-300">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-slate-400">{label}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-lg leading-tight font-semibold tracking-normal text-gray-600 sm:text-[22px] dark:text-white',
|
||||||
|
valueClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{helper && <p className="mt-1 text-sm text-slate-500">{helper}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
className: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className={`h-auto min-h-[76px] justify-start rounded-lg p-4 text-left ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="mt-0.5">{icon}</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{title}</p>
|
||||||
|
<p className="mt-1 whitespace-normal text-sm font-normal leading-relaxed dark:text-slate-200 text-slate-500">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/frontend/src/features/booking/lib/booking-time.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { DayOfWeek } from '@repo/api-contract';
|
||||||
|
|
||||||
|
export function toIsoDateLocal(date: Date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromIsoDateLocal(dateIso: string): Date | undefined {
|
||||||
|
const [year, month, day] = dateIso.split('-').map(Number);
|
||||||
|
if (!year || !month || !day) return undefined;
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeToMinutes(time: string) {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesToTime(minutes: number) {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainder = minutes % 60;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDaysIso(dateIso: string, amount: number) {
|
||||||
|
const date = fromIsoDateLocal(dateIso) ?? new Date();
|
||||||
|
date.setDate(date.getDate() + amount);
|
||||||
|
return toIsoDateLocal(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTodayIso(dateIso: string) {
|
||||||
|
return dateIso === toIsoDateLocal(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBookingDate(dateIso: string, options?: Intl.DateTimeFormatOptions) {
|
||||||
|
const date = fromIsoDateLocal(dateIso);
|
||||||
|
if (!date) return dateIso;
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('es-AR', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: options?.year,
|
||||||
|
...options,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayOfWeek(dateIso: string): DayOfWeek {
|
||||||
|
const date = fromIsoDateLocal(dateIso) ?? new Date();
|
||||||
|
const day = date.getDay();
|
||||||
|
|
||||||
|
if (day === 0) return 'SUNDAY';
|
||||||
|
if (day === 1) return 'MONDAY';
|
||||||
|
if (day === 2) return 'TUESDAY';
|
||||||
|
if (day === 3) return 'WEDNESDAY';
|
||||||
|
if (day === 4) return 'THURSDAY';
|
||||||
|
if (day === 5) return 'FRIDAY';
|
||||||
|
return 'SATURDAY';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNowTime() {
|
||||||
|
const now = new Date();
|
||||||
|
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-6xl space-y-6 px-4 py-4 sm:px-6">
|
<main className="mx-auto w-full max-w-6xl space-y-6 py-4">
|
||||||
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
|
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">
|
||||||
{complexQuery.data?.complexName ?? 'Configurar canchas'}
|
{complexQuery.data?.complexName ?? 'Configurar canchas'}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 py-4 sm:px-6">
|
<div className="mx-auto w-full max-w-6xl py-4">
|
||||||
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
|
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">
|
||||||
<Settings className="mb-1 mr-2 inline size-6" />
|
<Settings className="mb-1 mr-2 inline size-6" />
|
||||||
@@ -54,7 +54,7 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-6 lg:grid-cols-[200px_1fr]">
|
<div className="mt-6 grid gap-5 lg:grid-cols-[200px_1fr] lg:gap-6">
|
||||||
<nav className="flex flex-row gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
<nav className="flex flex-row gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function ComplexCourtsSection({ complexId }: ComplexCourtsSectionProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mt-6 rounded-xl border bg-card p-5">
|
<section className="mt-6 rounded-xl border bg-card p-3 sm:p-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin className="size-5 text-muted-foreground" />
|
<MapPin className="size-5 text-muted-foreground" />
|
||||||
<h3 className="text-lg font-medium">Canchas</h3>
|
<h3 className="text-lg font-medium">Canchas</h3>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function CourtFormSection({
|
|||||||
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha';
|
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5 mb-4">
|
<section className="mb-4 rounded-xl border bg-card p-3 text-card-foreground shadow-sm sm:p-5">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Configura nombre, deporte, duración, precio base y horarios. El backend ya contempla precios
|
Configura nombre, deporte, duración, precio base y horarios. El backend ya contempla precios
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function CourtListSection({
|
|||||||
onEditCourt,
|
onEditCourt,
|
||||||
}: CourtListSectionProps) {
|
}: CourtListSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
|
<section className="rounded-xl border bg-card p-3 text-card-foreground shadow-sm sm:p-5">
|
||||||
<h3 className="text-lg font-semibold">Canchas configuradas</h3>
|
<h3 className="text-lg font-semibold">Canchas configuradas</h3>
|
||||||
|
|
||||||
{isLoading && <p className="mt-3 text-sm text-muted-foreground">Cargando canchas...</p>}
|
{isLoading && <p className="mt-3 text-sm text-muted-foreground">Cargando canchas...</p>}
|
||||||
@@ -37,7 +37,7 @@ export function CourtListSection({
|
|||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{courts.map((court) => (
|
{courts.map((court) => (
|
||||||
<article key={court.id} className="rounded-lg border p-4">
|
<article key={court.id} className="rounded-lg border p-3 sm:p-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{court.name}</p>
|
<p className="font-medium">{court.name}</p>
|
||||||
|
|||||||
@@ -1,62 +1,9 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Booking } from '@/features/booking/booking';
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
ResponsiveDialog,
|
|
||||||
ResponsiveDialogClose,
|
|
||||||
ResponsiveDialogContent,
|
|
||||||
ResponsiveDialogDescription,
|
|
||||||
ResponsiveDialogFooter,
|
|
||||||
ResponsiveDialogHeader,
|
|
||||||
ResponsiveDialogTitle,
|
|
||||||
ResponsiveDialogTrigger,
|
|
||||||
} from '@/components/ui/responsive-dialog';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||||
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { AdminBooking } from '@repo/api-contract';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Check, Copy } from 'lucide-react';
|
import { useEffect } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const manualBookingSchema = z.object({
|
|
||||||
customerName: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(2, 'Ingresa un nombre válido.')
|
|
||||||
.max(120, 'El nombre no puede superar los 120 caracteres.'),
|
|
||||||
customerPhone: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(6, 'Ingresa un telefono válido.')
|
|
||||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ManualBookingForm = z.infer<typeof manualBookingSchema>;
|
|
||||||
|
|
||||||
function toIsoDateLocal(date: Date) {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromIsoDateLocal(dateIso: string): Date | undefined {
|
|
||||||
const [year, month, day] = dateIso.split('-').map(Number);
|
|
||||||
if (!year || !month || !day) return undefined;
|
|
||||||
return new Date(year, month - 1, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractMessage(error: unknown, fallback: string) {
|
function extractMessage(error: unknown, fallback: string) {
|
||||||
if (error instanceof ApiClientError) {
|
if (error instanceof ApiClientError) {
|
||||||
@@ -66,71 +13,9 @@ function extractMessage(error: unknown, fallback: string) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateLabel(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEffectiveStatus(
|
|
||||||
booking: AdminBooking
|
|
||||||
): 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' {
|
|
||||||
if (booking.status !== 'CONFIRMED') {
|
|
||||||
return booking.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if booking is past end time and still confirmed
|
|
||||||
const now = new Date();
|
|
||||||
const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`);
|
|
||||||
|
|
||||||
if (now > bookingDateTime) {
|
|
||||||
return 'NO_SHOW';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'CONFIRMED';
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') {
|
|
||||||
if (status === 'NO_SHOW') return 'No show';
|
|
||||||
if (status === 'COMPLETED') return 'Cumplida';
|
|
||||||
if (status === 'CANCELLED') return 'Cancelada';
|
|
||||||
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() {
|
export function HomePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
|
|
||||||
|
|
||||||
const [fromDate, setFromDate] = useState(todayIso);
|
|
||||||
const [manualDate, setManualDate] = useState(todayIso);
|
|
||||||
const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
|
|
||||||
const [selectedCourtId, setSelectedCourtId] = useState<string>('');
|
|
||||||
const [selectedStartTime, setSelectedStartTime] = useState<string>('');
|
|
||||||
const [isManualBookingOpen, setIsManualBookingOpen] = useState(false);
|
|
||||||
const [urlCopied, setUrlCopied] = useState(false);
|
|
||||||
|
|
||||||
const currentComplexQuery = useQuery({
|
const currentComplexQuery = useQuery({
|
||||||
queryKey: ['current-complex'],
|
queryKey: ['current-complex'],
|
||||||
@@ -152,6 +37,7 @@ export function HomePage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin-bookings'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-bookings'] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
@@ -191,7 +77,6 @@ export function HomePage() {
|
|||||||
if (!selectedComplex?.id) return;
|
if (!selectedComplex?.id) return;
|
||||||
|
|
||||||
const channel = `complex-${selectedComplex.id}`;
|
const channel = `complex-${selectedComplex.id}`;
|
||||||
|
|
||||||
let eventSource: EventSource | null = null;
|
let eventSource: EventSource | null = null;
|
||||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
@@ -199,27 +84,32 @@ export function HomePage() {
|
|||||||
let lastEventId: string | null = null;
|
let lastEventId: string | null = null;
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollInterval) return;
|
||||||
|
|
||||||
|
pollInterval = setInterval(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex.id] });
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reconnectAttempts >= maxReconnectAttempts) {
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
||||||
console.log('[SSE] Max reconnect attempts reached, switching to polling');
|
|
||||||
startPolling();
|
startPolling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/events/${encodeURIComponent(channel)}`;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||||
|
const url = `${baseUrl}/api/events/${encodeURIComponent(channel)}`;
|
||||||
|
|
||||||
eventSource = new EventSource(url);
|
eventSource = new EventSource(url);
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
if (eventSource?.readyState === EventSource.CLOSED) {
|
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
console.log(
|
|
||||||
`[SSE] Connection closed, retry ${reconnectAttempts}/${maxReconnectAttempts}`
|
|
||||||
);
|
|
||||||
reconnectTimeout = setTimeout(() => {
|
reconnectTimeout = setTimeout(() => {
|
||||||
connect();
|
connect();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -239,15 +129,6 @@ export function HomePage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const startPolling = () => {
|
|
||||||
if (pollInterval) return;
|
|
||||||
|
|
||||||
console.log('[SSE] Starting polling fallback');
|
|
||||||
pollInterval = setInterval(() => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex.id] });
|
|
||||||
}, 10000);
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -261,526 +142,21 @@ export function HomePage() {
|
|||||||
};
|
};
|
||||||
}, [selectedComplex?.id, queryClient]);
|
}, [selectedComplex?.id, queryClient]);
|
||||||
|
|
||||||
const bookingsQuery = useQuery({
|
|
||||||
queryKey: ['admin-bookings', selectedComplex?.id, fromDate],
|
|
||||||
enabled: Boolean(selectedComplex?.id),
|
|
||||||
queryFn: () =>
|
|
||||||
apiClient.adminBookings.listByComplex(selectedComplex?.id as string, {
|
|
||||||
fromDate,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const manualAvailabilityQuery = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
'manual-booking-availability',
|
|
||||||
selectedComplex?.complexSlug,
|
|
||||||
manualDate,
|
|
||||||
selectedSportId,
|
|
||||||
],
|
|
||||||
enabled: Boolean(selectedComplex?.complexSlug && manualDate),
|
|
||||||
queryFn: () =>
|
|
||||||
apiClient.publicBookings.getAvailability(selectedComplex?.complexSlug as string, {
|
|
||||||
date: manualDate,
|
|
||||||
...(selectedSportId ? { sportId: selectedSportId } : {}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const availability = manualAvailabilityQuery.data;
|
|
||||||
|
|
||||||
if (!availability) return;
|
|
||||||
|
|
||||||
if (!availability.sportSelectionRequired) {
|
|
||||||
if (availability.sports[0] && !selectedSportId) {
|
|
||||||
setSelectedSportId(availability.sports[0].id);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
selectedSportId &&
|
|
||||||
!availability.sports.some((sport) => sport.id === selectedSportId)
|
|
||||||
) {
|
|
||||||
setSelectedSportId(undefined);
|
|
||||||
}
|
|
||||||
}, [manualAvailabilityQuery.data, selectedSportId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!manualAvailabilityQuery.data) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
selectedCourtId &&
|
|
||||||
!manualAvailabilityQuery.data.courts.some((court) => court.courtId === selectedCourtId)
|
|
||||||
) {
|
|
||||||
setSelectedCourtId('');
|
|
||||||
setSelectedStartTime('');
|
|
||||||
}
|
|
||||||
}, [manualAvailabilityQuery.data, selectedCourtId]);
|
|
||||||
|
|
||||||
const selectedCourt = useMemo(() => {
|
|
||||||
return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId);
|
|
||||||
}, [manualAvailabilityQuery.data?.courts, selectedCourtId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCourt) {
|
|
||||||
setSelectedStartTime('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) {
|
|
||||||
setSelectedStartTime('');
|
|
||||||
}
|
|
||||||
}, [selectedCourt, selectedStartTime]);
|
|
||||||
|
|
||||||
const groupedBookings = useMemo(() => {
|
|
||||||
const groups = new Map<string, AdminBooking[]>();
|
|
||||||
|
|
||||||
for (const booking of bookingsQuery.data?.bookings ?? []) {
|
|
||||||
const current = groups.get(booking.date) ?? [];
|
|
||||||
current.push(booking);
|
|
||||||
groups.set(booking.date, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...groups.entries()];
|
|
||||||
}, [bookingsQuery.data?.bookings]);
|
|
||||||
|
|
||||||
const updateStatusMutation = useMutation({
|
|
||||||
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) =>
|
|
||||||
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
|
|
||||||
onSuccess: () => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { errors, isValid },
|
|
||||||
} = useForm<ManualBookingForm>({
|
|
||||||
resolver: zodResolver(manualBookingSchema),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: {
|
|
||||||
customerName: '',
|
|
||||||
customerPhone: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createManualBookingMutation = useMutation({
|
|
||||||
mutationFn: async (values: ManualBookingForm) => {
|
|
||||||
if (!selectedComplex?.id) {
|
|
||||||
throw new Error('No se encontró el complejo actual.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedCourtId || !selectedStartTime) {
|
|
||||||
throw new Error('Debes seleccionar cancha y horario.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiClient.adminBookings.create(selectedComplex.id, {
|
|
||||||
date: manualDate,
|
|
||||||
courtId: selectedCourtId,
|
|
||||||
startTime: selectedStartTime,
|
|
||||||
customerName: values.customerName,
|
|
||||||
customerPhone: values.customerPhone,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
reset();
|
|
||||||
setSelectedCourtId('');
|
|
||||||
setSelectedStartTime('');
|
|
||||||
setIsManualBookingOpen(false);
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmitManualBooking = async (values: ManualBookingForm) => {
|
|
||||||
await createManualBookingMutation.mutateAsync(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
const publicBookingUrl = selectedComplex
|
|
||||||
? `${window.location.origin}/${selectedComplex.complexSlug}/booking`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
await navigator.clipboard.writeText(publicBookingUrl);
|
|
||||||
setUrlCopied(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setUrlCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (myComplexesQuery.isLoading) {
|
if (myComplexesQuery.isLoading) {
|
||||||
return (
|
return <p className="text-sm text-muted-foreground">Cargando panel...</p>;
|
||||||
<main className="mx-auto w-full max-w-6xl px-6 py-8">
|
|
||||||
<p className="text-sm text-muted-foreground">Cargando panel...</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (myComplexesQuery.isError) {
|
if (myComplexesQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-6xl px-6 py-8">
|
<p className="text-sm text-destructive">
|
||||||
<p className="text-sm text-destructive">
|
{extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')}
|
||||||
{extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')}
|
</p>
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedComplex) {
|
if (!selectedComplex) {
|
||||||
return (
|
return <p className="text-sm text-muted-foreground">No tienes complejos asignados todavía.</p>;
|
||||||
<main className="mx-auto w-full max-w-6xl px-6 py-8">
|
|
||||||
<p className="text-sm text-muted-foreground">No tienes complejos asignados todavía.</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <Booking complex={selectedComplex} />;
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<ResponsiveDialog
|
|
||||||
open={isManualBookingOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsManualBookingOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
reset();
|
|
||||||
setManualDate(todayIso);
|
|
||||||
setSelectedSportId(undefined);
|
|
||||||
setSelectedCourtId('');
|
|
||||||
setSelectedStartTime('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{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)}`}
|
|
||||||
>
|
|
||||||
{effectiveStatusLabel(effectiveStatus)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{updateStatusMutation.isError && (
|
|
||||||
<p className="mt-4 text-sm text-destructive">
|
|
||||||
{extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { UserAvatar } from '@/components/user-avatar';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { useAuth } from '@/lib/auth';
|
|
||||||
import { acceptStoredPendingInvite } from '@/lib/invitations';
|
|
||||||
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
|
||||||
import { LogOut, Menu, UserRound } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
|
||||||
import { ThemeSwitcher } from './theme-switcher';
|
|
||||||
|
|
||||||
export function RootLayout() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth();
|
|
||||||
const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore();
|
|
||||||
const processedInviteForUser = useRef<string | null>(null);
|
|
||||||
const myComplexesQuery = useQuery({
|
|
||||||
queryKey: ['my-complexes'],
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
queryFn: () => apiClient.complexes.listMine(),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
processedInviteForUser.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userKey = user?.email ?? displayName ?? 'authenticated-user';
|
|
||||||
|
|
||||||
if (processedInviteForUser.current === userKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedInviteForUser.current = userKey;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['my-complexes'] });
|
|
||||||
})();
|
|
||||||
}, [displayName, isAuthenticated, queryClient, user?.email]);
|
|
||||||
|
|
||||||
const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug;
|
|
||||||
|
|
||||||
const currentComplexName = useMemo(() => {
|
|
||||||
const complexes = myComplexesQuery.data ?? [];
|
|
||||||
if (complexes.length === 0) return 'Mi complejo';
|
|
||||||
|
|
||||||
const selected = complexes.find((complex) => complex.complexSlug === complexSlug);
|
|
||||||
|
|
||||||
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
|
|
||||||
}, [complexSlug, myComplexesQuery.data]);
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
|
||||||
await signOut();
|
|
||||||
await navigate({ to: '/login' });
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ThemeSwitcher />
|
|
||||||
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<Button asChild size="sm" variant="outline" className="hidden md:inline-flex">
|
|
||||||
<Link to="/login">Login</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hidden items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-muted md:inline-flex"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={displayName}
|
|
||||||
fallbackText={displayName}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<span className="max-w-32 truncate text-sm text-foreground">{displayName}</span>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-52">
|
|
||||||
<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" />
|
|
||||||
Profile
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onSelect={() => {
|
|
||||||
void handleSignOut();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogOut className="size-4" />
|
|
||||||
Sign out
|
|
||||||
</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>
|
|
||||||
</header>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -78,7 +79,15 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full flex-col items-center justify-center gap-6 px-3 sm:px-6">
|
||||||
|
<Link to="/" className="group flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center">
|
||||||
|
<img src={PlayzerIcon} alt="Playzer" className="size-7" />
|
||||||
|
</div>
|
||||||
|
<span className="bg-gradient-to-r from-primary via-primary to-reserved bg-clip-text text-xl font-bold tracking-tight text-transparent">
|
||||||
|
Playzer
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<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>
|
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Compass, Home, Sparkles } from 'lucide-react';
|
|||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<main className="relative isolate mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center overflow-hidden px-6 py-10">
|
<main className="relative isolate mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center overflow-hidden px-3 py-10 sm:px-6">
|
||||||
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.14),transparent_45%),radial-gradient(circle_at_80%_10%,rgba(244,114,182,0.12),transparent_40%),radial-gradient(circle_at_50%_90%,rgba(59,130,246,0.10),transparent_40%)]" />
|
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.14),transparent_45%),radial-gradient(circle_at_80%_10%,rgba(244,114,182,0.12),transparent_40%),radial-gradient(circle_at_50%_90%,rgba(59,130,246,0.10),transparent_40%)]" />
|
||||||
|
|
||||||
<section className="w-full max-w-2xl rounded-2xl border bg-card/80 p-8 text-card-foreground shadow-xl backdrop-blur">
|
<section className="w-full max-w-2xl rounded-2xl border bg-card/80 p-8 text-card-foreground shadow-xl backdrop-blur">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type OnboardingLayoutProps = PropsWithChildren<{
|
|||||||
|
|
||||||
export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) {
|
export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
|
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-3 sm:px-6">
|
||||||
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<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>
|
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{description}</p>
|
<p className="mb-4 text-sm text-muted-foreground">{description}</p>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function CreateComplexPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<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">Crear tu complejo</h1>
|
<h1 className="mb-2 text-2xl font-semibold">Crear tu complejo</h1>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function OnboardLoginPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<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">
|
<h1 className="mb-2 text-2xl font-semibold">
|
||||||
{mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'}
|
{mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function VerifyEmailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<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">Verificá tu email</h1>
|
<h1 className="mb-2 text-2xl font-semibold">Verificá tu email</h1>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function ResetPasswordPage() {
|
|||||||
|
|
||||||
if (successMessage) {
|
if (successMessage) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 text-2xl">✓</div>
|
<div className="mb-4 text-2xl">✓</div>
|
||||||
@@ -162,7 +162,7 @@ export function ResetPasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<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">
|
<h1 className="mb-2 text-2xl font-semibold">
|
||||||
{step === 'email' ? 'Restablecer contraseña' : 'Ingresá el código'}
|
{step === 'email' ? 'Restablecer contraseña' : 'Ingresá el código'}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
|
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-3 sm:px-6">
|
||||||
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<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>
|
<h1 className="text-2xl font-semibold">Perfil</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
@@ -115,7 +115,7 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6 py-8">
|
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-3 py-8 sm:px-6">
|
||||||
<div className="w-full max-w-3xl space-y-6">
|
<div className="w-full max-w-3xl space-y-6">
|
||||||
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function PublicBookingConfirmationPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-linear-to-b from-background via-background to-primary/5">
|
<main className="min-h-screen bg-linear-to-b from-background via-background to-primary/5">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-4 py-8 sm:px-6">
|
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-3 py-8 sm:px-6">
|
||||||
<section className="w-full rounded-3xl border bg-card p-5 shadow-lg sm:p-8">
|
<section className="w-full rounded-3xl border bg-card p-5 shadow-lg sm:p-8">
|
||||||
{confirmationQuery.isLoading && (
|
{confirmationQuery.isLoading && (
|
||||||
<p className="text-sm text-muted-foreground">Cargando confirmacion...</p>
|
<p className="text-sm text-muted-foreground">Cargando confirmacion...</p>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function SelectComplexPage() {
|
|||||||
|
|
||||||
if (complexesQuery.isLoading) {
|
if (complexesQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -37,7 +37,7 @@ export function SelectComplexPage() {
|
|||||||
|
|
||||||
if (complexesQuery.isError) {
|
if (complexesQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-destructive">Error al cargar tus complejos.</p>
|
<p className="text-destructive">Error al cargar tus complejos.</p>
|
||||||
<Button variant="link" className="mt-2" onClick={() => complexesQuery.refetch()}>
|
<Button variant="link" className="mt-2" onClick={() => complexesQuery.refetch()}>
|
||||||
@@ -52,7 +52,7 @@ export function SelectComplexPage() {
|
|||||||
|
|
||||||
if (complexes.length === 0) {
|
if (complexes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-muted-foreground">No tenés complejos asociados.</p>
|
<p className="text-muted-foreground">No tenés complejos asociados.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +61,7 @@ export function SelectComplexPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<section className="w-full max-w-md space-y-6">
|
<section className="w-full max-w-md space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-semibold">Seleccioná un complejo</h1>
|
<h1 className="text-2xl font-semibold">Seleccioná un complejo</h1>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AlertTriangle, Home, RefreshCw, Route } from 'lucide-react';
|
|||||||
|
|
||||||
export function ServerErrorPage({ error, reset }: ErrorComponentProps) {
|
export function ServerErrorPage({ error, reset }: ErrorComponentProps) {
|
||||||
return (
|
return (
|
||||||
<main className="relative isolate mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center overflow-hidden px-6 py-10">
|
<main className="relative isolate mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center overflow-hidden px-3 py-10 sm:px-6">
|
||||||
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_15%_10%,rgba(248,113,113,0.14),transparent_42%),radial-gradient(circle_at_80%_20%,rgba(251,191,36,0.12),transparent_38%),radial-gradient(circle_at_60%_90%,rgba(59,130,246,0.10),transparent_42%)]" />
|
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_15%_10%,rgba(248,113,113,0.14),transparent_42%),radial-gradient(circle_at_80%_20%,rgba(251,191,36,0.12),transparent_38%),radial-gradient(circle_at_60%_90%,rgba(59,130,246,0.10),transparent_42%)]" />
|
||||||
|
|
||||||
<section className="w-full max-w-2xl rounded-2xl border bg-card/80 p-8 text-card-foreground shadow-xl backdrop-blur">
|
<section className="w-full max-w-2xl rounded-2xl border bg-card/80 p-8 text-card-foreground shadow-xl backdrop-blur">
|
||||||
|
|||||||
@@ -1,44 +1,80 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@fontsource-variable/geist";
|
/* @import "@fontsource-variable/inter"; */
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@source "../../../apps/**/*.{ts,tsx}";
|
||||||
|
@source "../../../components/**/*.{ts,tsx}";
|
||||||
|
@source "../**/*.{ts,tsx}";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--font-sans: "Geist Variable", sans-serif;
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
|
||||||
|
--color-reserved: var(--reserved);
|
||||||
|
--color-reserved-foreground: var(--reserved-foreground);
|
||||||
|
|
||||||
|
--color-maintenance: var(--maintenance);
|
||||||
|
--color-maintenance-foreground: var(--maintenance-foreground);
|
||||||
|
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -46,85 +82,208 @@
|
|||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
|
||||||
|
--font-sans: "Inter Variable", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.985 0.004 240);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.18 0.012 245);
|
||||||
|
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.18 0.012 245);
|
||||||
|
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.18 0.012 245);
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary: oklch(0.63 0.19 158);
|
||||||
--secondary: oklch(0.97 0 0);
|
--primary-foreground: oklch(0.985 0.004 160);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
--secondary: oklch(0.94 0.01 240);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--secondary-foreground: oklch(0.28 0.018 245);
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--muted: oklch(0.95 0.008 240);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--muted-foreground: oklch(0.52 0.018 245);
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
--accent: oklch(0.93 0.035 158);
|
||||||
--ring: oklch(0.708 0 0);
|
--accent-foreground: oklch(0.34 0.12 158);
|
||||||
--chart-1: oklch(0.87 0 0);
|
|
||||||
--chart-2: oklch(0.556 0 0);
|
--destructive: oklch(0.62 0.22 25);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--destructive-foreground: oklch(0.98 0.01 25);
|
||||||
--chart-4: oklch(0.371 0 0);
|
|
||||||
--chart-5: oklch(0.269 0 0);
|
--success: oklch(0.63 0.19 158);
|
||||||
--radius: 0.625rem;
|
--success-foreground: oklch(0.985 0.004 160);
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--reserved: oklch(0.58 0.19 255);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--reserved-foreground: oklch(0.985 0.01 255);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--maintenance: oklch(0.64 0.22 25);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--maintenance-foreground: oklch(0.985 0.01 25);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--warning: oklch(0.78 0.16 75);
|
||||||
|
--warning-foreground: oklch(0.18 0.04 75);
|
||||||
|
|
||||||
|
--border: oklch(0.86 0.01 245 / 65%);
|
||||||
|
--input: oklch(0.86 0.01 245 / 75%);
|
||||||
|
--ring: oklch(0.63 0.19 158);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.63 0.19 158);
|
||||||
|
--chart-2: oklch(0.58 0.19 255);
|
||||||
|
--chart-3: oklch(0.64 0.22 25);
|
||||||
|
--chart-4: oklch(0.78 0.16 75);
|
||||||
|
--chart-5: oklch(0.68 0.16 205);
|
||||||
|
|
||||||
|
--radius: 0.875rem;
|
||||||
|
|
||||||
|
--sidebar: oklch(0.98 0.004 240);
|
||||||
|
--sidebar-foreground: oklch(0.18 0.012 245);
|
||||||
|
--sidebar-primary: oklch(0.63 0.19 158);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.004 160);
|
||||||
|
--sidebar-accent: oklch(0.94 0.01 240);
|
||||||
|
--sidebar-accent-foreground: oklch(0.28 0.018 245);
|
||||||
|
--sidebar-border: oklch(0.86 0.01 245 / 65%);
|
||||||
|
--sidebar-ring: oklch(0.63 0.19 158);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: #081118;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.96 0.006 245);
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card: oklch(0.19 0.022 240);
|
||||||
--popover: oklch(0.205 0 0);
|
--card-foreground: oklch(0.96 0.006 245);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
--popover: oklch(0.18 0.022 240);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--popover-foreground: oklch(0.96 0.006 245);
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--primary: oklch(0.67 0.21 158);
|
||||||
--muted: oklch(0.269 0 0);
|
--primary-foreground: oklch(0.12 0.02 158);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
--secondary: oklch(0.235 0.024 240);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.9 0.008 245);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
--muted: oklch(0.24 0.022 240);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--muted-foreground: oklch(0.68 0.018 245);
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.87 0 0);
|
--accent: oklch(0.28 0.06 158);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--accent-foreground: oklch(0.9 0.04 158);
|
||||||
--chart-3: oklch(0.439 0 0);
|
|
||||||
--chart-4: oklch(0.371 0 0);
|
--destructive: oklch(0.66 0.23 25);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--destructive-foreground: oklch(0.98 0.01 25);
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--success: oklch(0.67 0.21 158);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--success-foreground: oklch(0.12 0.02 158);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--reserved: oklch(0.61 0.2 255);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--reserved-foreground: oklch(0.97 0.012 255);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--maintenance: oklch(0.66 0.23 25);
|
||||||
|
--maintenance-foreground: oklch(0.98 0.01 25);
|
||||||
|
|
||||||
|
--warning: oklch(0.78 0.17 75);
|
||||||
|
--warning-foreground: oklch(0.18 0.04 75);
|
||||||
|
|
||||||
|
--border: oklch(1 0 0 / 11%);
|
||||||
|
--input: oklch(1 0 0 / 14%);
|
||||||
|
--ring: oklch(0.67 0.21 158);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.67 0.21 158);
|
||||||
|
--chart-2: oklch(0.61 0.2 255);
|
||||||
|
--chart-3: oklch(0.66 0.23 25);
|
||||||
|
--chart-4: oklch(0.78 0.17 75);
|
||||||
|
--chart-5: oklch(0.7 0.16 205);
|
||||||
|
|
||||||
|
--sidebar: oklch(0.165 0.02 240);
|
||||||
|
--sidebar-foreground: oklch(0.96 0.006 245);
|
||||||
|
--sidebar-primary: oklch(0.67 0.21 158);
|
||||||
|
--sidebar-primary-foreground: oklch(0.12 0.02 158);
|
||||||
|
--sidebar-accent: oklch(0.235 0.024 240);
|
||||||
|
--sidebar-accent-foreground: oklch(0.9 0.008 245);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 11%);
|
||||||
|
--sidebar-ring: oklch(0.67 0.21 158);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground antialiased;
|
||||||
}
|
}
|
||||||
html {
|
|
||||||
@apply font-sans;
|
button:not(:disabled),
|
||||||
|
[role="button"]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.booking-scrollbar {
|
||||||
|
scrollbar-color: oklch(0.67 0.21 158 / 62%) oklch(1 0 0 / 8%);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-scrollbar::-webkit-scrollbar {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: oklch(1 0 0 / 6%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(90deg, oklch(0.67 0.21 158 / 72%), oklch(0.61 0.2 255 / 58%));
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(90deg, oklch(0.67 0.21 158 / 88%), oklch(0.61 0.2 255 / 72%));
|
||||||
|
border: 3px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light {
|
||||||
|
background: #eaf2f4;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light .public-booking-frame {
|
||||||
|
background: radial-gradient(circle at 18% 10%, rgba(16, 185, 129, 0.18), transparent 28%),
|
||||||
|
radial-gradient(circle at 82% 18%, rgba(59, 130, 246, 0.14), transparent 30%),
|
||||||
|
linear-gradient(180deg, #f8fbfc 0%, #edf5f7 52%, #e5eef2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light .public-booking-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
border-color: rgba(15, 23, 42, 0.13);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 48px rgba(15, 23, 42, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light [class*="border-white"] {
|
||||||
|
border-color: rgba(15, 23, 42, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light [class*="bg-white"] {
|
||||||
|
background-color: rgba(15, 23, 42, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light [class*="text-white"] {
|
||||||
|
color: rgba(15, 23, 42, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light .text-emerald-400,
|
||||||
|
.public-booking-light .text-cyan-300 {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light .bg-emerald-500 {
|
||||||
|
background-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-booking-light .text-red-300 {
|
||||||
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
apps/frontend/src/lib/compose-refs.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
type PossibleRef<T> = React.Ref<T> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a given ref to a given value
|
||||||
|
* This utility takes care of different types of refs: callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function setRef<T>(ref: PossibleRef<T>, value: T) {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
return ref(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ref !== null && ref !== undefined) {
|
||||||
|
ref.current = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility to compose multiple refs together
|
||||||
|
* Accepts callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||||
|
return (node) => {
|
||||||
|
let hasCleanup = false;
|
||||||
|
const cleanups = refs.map((ref) => {
|
||||||
|
const cleanup = setRef(ref, node);
|
||||||
|
if (!hasCleanup && typeof cleanup === 'function') {
|
||||||
|
hasCleanup = true;
|
||||||
|
}
|
||||||
|
return cleanup;
|
||||||
|
});
|
||||||
|
|
||||||
|
// React <19 will log an error to the console if a callback ref returns a
|
||||||
|
// value. We don't use ref cleanups internally so this will only happen if a
|
||||||
|
// user's ref callback returns a value, which we only expect if they are
|
||||||
|
// using the cleanup functionality added in React 19.
|
||||||
|
if (hasCleanup) {
|
||||||
|
return () => {
|
||||||
|
for (let i = 0; i < cleanups.length; i++) {
|
||||||
|
const cleanup = cleanups[i];
|
||||||
|
if (typeof cleanup === 'function') {
|
||||||
|
cleanup();
|
||||||
|
} else {
|
||||||
|
setRef(refs[i], null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom hook that composes multiple refs
|
||||||
|
* Accepts callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||||
|
return React.useCallback(composeRefs(...refs), refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { composeRefs, useComposedRefs };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -14,6 +15,7 @@ type ThemeContextValue = {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
resolvedTheme: ResolvedTheme;
|
resolvedTheme: ResolvedTheme;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
|
toggleTheme: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = 'theme';
|
const THEME_STORAGE_KEY = 'theme';
|
||||||
@@ -27,6 +29,18 @@ function applyResolvedTheme(theme: ResolvedTheme) {
|
|||||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEditableElement(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
|
||||||
|
const tagName = target.tagName.toLowerCase();
|
||||||
|
return (
|
||||||
|
tagName === 'input' ||
|
||||||
|
tagName === 'textarea' ||
|
||||||
|
tagName === 'select' ||
|
||||||
|
target.isContentEditable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: PropsWithChildren) {
|
export function ThemeProvider({ children }: PropsWithChildren) {
|
||||||
const [theme, setThemeState] = useState<Theme>('system');
|
const [theme, setThemeState] = useState<Theme>('system');
|
||||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light');
|
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light');
|
||||||
@@ -54,18 +68,37 @@ export function ThemeProvider({ children }: PropsWithChildren) {
|
|||||||
return () => mediaQuery.removeEventListener('change', updateTheme);
|
return () => mediaQuery.removeEventListener('change', updateTheme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const setTheme = (nextTheme: Theme) => {
|
const setTheme = useCallback((nextTheme: Theme) => {
|
||||||
setThemeState(nextTheme);
|
setThemeState(nextTheme);
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||||
|
}, [resolvedTheme, setTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (isEditableElement(event.target)) return;
|
||||||
|
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
|
||||||
|
if (event.key.toLowerCase() !== 'd') return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
toggleTheme();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [toggleTheme]);
|
||||||
|
|
||||||
const value = useMemo<ThemeContextValue>(
|
const value = useMemo<ThemeContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
theme,
|
theme,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
setTheme,
|
setTheme,
|
||||||
|
toggleTheme,
|
||||||
}),
|
}),
|
||||||
[theme, resolvedTheme]
|
[theme, resolvedTheme, setTheme, toggleTheme]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function AppRouter() {
|
|||||||
|
|
||||||
if (auth.loading) {
|
if (auth.loading) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
|
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-3 sm:px-6">
|
||||||
<p className="text-sm text-muted-foreground">Validando sesión...</p>
|
<p className="text-sm text-muted-foreground">Validando sesión...</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
|
|||||||
import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex'
|
import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex'
|
||||||
import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete'
|
import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete'
|
||||||
import { Route as AppProfileRouteImport } from './routes/_app/profile'
|
import { Route as AppProfileRouteImport } from './routes/_app/profile'
|
||||||
import { Route as AppAboutRouteImport } from './routes/_app/about'
|
|
||||||
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
|
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
|
||||||
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
|
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
|
||||||
import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index'
|
import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index'
|
||||||
@@ -94,11 +93,6 @@ const AppProfileRoute = AppProfileRouteImport.update({
|
|||||||
path: '/profile',
|
path: '/profile',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AppAboutRoute = AppAboutRouteImport.update({
|
|
||||||
id: '/about',
|
|
||||||
path: '/about',
|
|
||||||
getParentRoute: () => AppRouteRoute,
|
|
||||||
} as any)
|
|
||||||
const ComplexSlugBookingRoute = ComplexSlugBookingRouteImport.update({
|
const ComplexSlugBookingRoute = ComplexSlugBookingRouteImport.update({
|
||||||
id: '/$complexSlug/booking',
|
id: '/$complexSlug/booking',
|
||||||
path: '/$complexSlug/booking',
|
path: '/$complexSlug/booking',
|
||||||
@@ -140,7 +134,6 @@ export interface FileRoutesByFullPath {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/select-complex': typeof SelectComplexRoute
|
'/select-complex': typeof SelectComplexRoute
|
||||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||||
'/about': typeof AppAboutRoute
|
|
||||||
'/profile': typeof AppProfileRoute
|
'/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/complete': typeof OnboardCompleteRoute
|
||||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||||
@@ -158,7 +151,6 @@ export interface FileRoutesByTo {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/select-complex': typeof SelectComplexRoute
|
'/select-complex': typeof SelectComplexRoute
|
||||||
'/about': typeof AppAboutRoute
|
|
||||||
'/profile': typeof AppProfileRoute
|
'/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/complete': typeof OnboardCompleteRoute
|
||||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||||
@@ -180,7 +172,6 @@ export interface FileRoutesById {
|
|||||||
'/select-complex': typeof SelectComplexRoute
|
'/select-complex': typeof SelectComplexRoute
|
||||||
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
|
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
|
||||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||||
'/_app/about': typeof AppAboutRoute
|
|
||||||
'/_app/profile': typeof AppProfileRoute
|
'/_app/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/complete': typeof OnboardCompleteRoute
|
||||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||||
@@ -203,7 +194,6 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/select-complex'
|
| '/select-complex'
|
||||||
| '/$complexSlug/booking'
|
| '/$complexSlug/booking'
|
||||||
| '/about'
|
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/complete'
|
||||||
| '/onboard/create-complex'
|
| '/onboard/create-complex'
|
||||||
@@ -221,7 +211,6 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/select-complex'
|
| '/select-complex'
|
||||||
| '/about'
|
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/complete'
|
||||||
| '/onboard/create-complex'
|
| '/onboard/create-complex'
|
||||||
@@ -242,7 +231,6 @@ export interface FileRouteTypes {
|
|||||||
| '/select-complex'
|
| '/select-complex'
|
||||||
| '/_app/_authenticated'
|
| '/_app/_authenticated'
|
||||||
| '/$complexSlug/booking'
|
| '/$complexSlug/booking'
|
||||||
| '/_app/about'
|
|
||||||
| '/_app/profile'
|
| '/_app/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/complete'
|
||||||
| '/onboard/create-complex'
|
| '/onboard/create-complex'
|
||||||
@@ -359,13 +347,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppProfileRouteImport
|
preLoaderRoute: typeof AppProfileRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
'/_app/about': {
|
|
||||||
id: '/_app/about'
|
|
||||||
path: '/about'
|
|
||||||
fullPath: '/about'
|
|
||||||
preLoaderRoute: typeof AppAboutRouteImport
|
|
||||||
parentRoute: typeof AppRouteRoute
|
|
||||||
}
|
|
||||||
'/$complexSlug/booking': {
|
'/$complexSlug/booking': {
|
||||||
id: '/$complexSlug/booking'
|
id: '/$complexSlug/booking'
|
||||||
path: '/$complexSlug/booking'
|
path: '/$complexSlug/booking'
|
||||||
@@ -428,13 +409,11 @@ const AppAuthenticatedRouteRouteWithChildren =
|
|||||||
|
|
||||||
interface AppRouteRouteChildren {
|
interface AppRouteRouteChildren {
|
||||||
AppAuthenticatedRouteRoute: typeof AppAuthenticatedRouteRouteWithChildren
|
AppAuthenticatedRouteRoute: typeof AppAuthenticatedRouteRouteWithChildren
|
||||||
AppAboutRoute: typeof AppAboutRoute
|
|
||||||
AppProfileRoute: typeof AppProfileRoute
|
AppProfileRoute: typeof AppProfileRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
||||||
AppAuthenticatedRouteRoute: AppAuthenticatedRouteRouteWithChildren,
|
AppAuthenticatedRouteRoute: AppAuthenticatedRouteRouteWithChildren,
|
||||||
AppAboutRoute: AppAboutRoute,
|
|
||||||
AppProfileRoute: AppProfileRoute,
|
AppProfileRoute: AppProfileRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { Background } from '@/components/background';
|
||||||
|
import { Layout } from '@/components/layout';
|
||||||
import { NotFoundPage } from '@/features/not-found/not-found-page';
|
import { NotFoundPage } from '@/features/not-found/not-found-page';
|
||||||
import { ServerErrorPage } from '@/features/server-error/server-error-page';
|
import { ServerErrorPage } from '@/features/server-error/server-error-page';
|
||||||
import type { ApiClient } from '@/lib/api-client';
|
import type { ApiClient } from '@/lib/api-client';
|
||||||
import type { AuthContextValue } from '@/lib/auth';
|
import type { AuthContextValue } from '@/lib/auth';
|
||||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
|
import { Outlet, createRootRouteWithContext, useLocation } from '@tanstack/react-router';
|
||||||
|
|
||||||
type RouterContext = {
|
type RouterContext = {
|
||||||
auth: AuthContextValue;
|
auth: AuthContextValue;
|
||||||
@@ -10,7 +12,37 @@ type RouterContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: Outlet,
|
component: RootRoute,
|
||||||
errorComponent: ServerErrorPage,
|
errorComponent: ServerErrorPage,
|
||||||
notFoundComponent: NotFoundPage,
|
notFoundComponent: NotFoundPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AUTH_PAGE_REGEX =
|
||||||
|
/^\/(login|select-complex|reset-password|onboard|invite|auth-callback)(?:\/|$)/;
|
||||||
|
|
||||||
|
function RootRoute() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isPublicBookingRoute = /^\/[^/]+\/booking(?:\/|$)/.test(location.pathname);
|
||||||
|
const isAuthPage = AUTH_PAGE_REGEX.test(location.pathname);
|
||||||
|
|
||||||
|
if (isPublicBookingRoute) {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthPage) {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-dvh overflow-x-hidden bg-background text-foreground">
|
||||||
|
<Background />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { AboutPage } from '@/features/about/about-page';
|
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/about')({
|
|
||||||
component: AboutPage,
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { RootLayout } from '@/features/layout/root-layout';
|
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { Outlet } from '@tanstack/react-router';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app')({
|
export const Route = createFileRoute('/_app')({
|
||||||
component: RootLayout,
|
component: Outlet,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function AuthCallbackPage() {
|
|||||||
}, [isAuthenticated, navigate]);
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||||
<p className="text-sm text-muted-foreground">Completando sesión...</p>
|
<p className="text-sm text-muted-foreground">Completando sesión...</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||