fix(portal): prefix unused totalPending param with underscore
Silence @typescript-eslint/no-unused-vars for totalPending in PaymentModal — it is accepted as a prop for API compatibility but the modal computes selectedTotal from selected invoices instead. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -111,6 +111,8 @@ jobs:
|
|||||||
name: Build & Push Docker Images
|
name: Build & Push Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build, e2e]
|
needs: [build, e2e]
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -244,8 +246,8 @@ jobs:
|
|||||||
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
|
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
|
||||||
|
|
||||||
# Wait for rollout
|
# Wait for rollout
|
||||||
kubectl rollout status deployment/api -n groombook-dev --timeout=120s
|
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
||||||
kubectl rollout status deployment/web -n groombook-dev --timeout=120s
|
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
||||||
|
|
||||||
echo "Deployment complete."
|
echo "Deployment complete."
|
||||||
|
|
||||||
@@ -268,3 +270,71 @@ jobs:
|
|||||||
'Ready for UAT validation.'
|
'Ready for UAT validation.'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cd:
|
||||||
|
name: Update Infra Image Tags
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [docker]
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Generate infra repo token
|
||||||
|
id: infra-token
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
app_id: ${{ vars.GH_APP_ID }}
|
||||||
|
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Clone groombook/infra
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||||
|
|
||||||
|
- name: Install yq
|
||||||
|
run: |
|
||||||
|
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||||
|
sudo chmod +x /usr/local/bin/yq
|
||||||
|
|
||||||
|
- name: Update dev overlay image tags
|
||||||
|
env:
|
||||||
|
TAG: ${{ needs.docker.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||||
|
fi
|
||||||
|
echo "Updating dev overlay image tags to: $TAG"
|
||||||
|
cd /tmp/infra
|
||||||
|
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
|
||||||
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
|
git -C /tmp/infra diff --stat
|
||||||
|
|
||||||
|
- name: Create PR on groombook/infra
|
||||||
|
env:
|
||||||
|
TAG: ${{ needs.docker.outputs.tag }}
|
||||||
|
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /tmp/infra
|
||||||
|
git config user.name "groombook-engineer[bot]"
|
||||||
|
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||||
|
git checkout -b "chore/update-image-tags-${TAG}"
|
||||||
|
git add apps/groombook/overlays/dev/
|
||||||
|
git commit -m "chore: update image tags to ${TAG}"
|
||||||
|
|
||||||
|
git push -u origin "chore/update-image-tags-${TAG}"
|
||||||
|
|
||||||
|
# Create PR with auto-merge
|
||||||
|
PR_URL=$(gh pr create \
|
||||||
|
--repo groombook/infra \
|
||||||
|
--base main \
|
||||||
|
--head "chore/update-image-tags-${TAG}" \
|
||||||
|
--title "chore: deploy ${TAG} to dev" \
|
||||||
|
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||||
|
gh pr merge "$PR_URL" --auto --merge
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Release Helm Chart
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'charts/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout groombook
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Checkout groombook.github.io
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: groombook/groombook.github.io
|
||||||
|
path: gh-pages
|
||||||
|
token: ${{ secrets.CHART_REPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install Helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
|
||||||
|
- name: Update Helm dependencies
|
||||||
|
run: helm dependency update charts/groombook
|
||||||
|
|
||||||
|
- name: Package chart
|
||||||
|
run: |
|
||||||
|
mkdir -p gh-pages/charts
|
||||||
|
helm package charts/groombook -d gh-pages/charts
|
||||||
|
|
||||||
|
- name: Update repo index
|
||||||
|
run: |
|
||||||
|
if [ -f gh-pages/charts/index.yaml ]; then
|
||||||
|
helm repo index gh-pages/charts --merge gh-pages/charts/index.yaml --url https://groombook.github.io/charts
|
||||||
|
else
|
||||||
|
helm repo index gh-pages/charts --url https://groombook.github.io/charts
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push to groombook.github.io
|
||||||
|
run: |
|
||||||
|
cd gh-pages
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add charts/
|
||||||
|
git diff --staged --quiet && echo 'No chart changes' && exit 0
|
||||||
|
git commit -m "Update Helm chart repository"
|
||||||
|
git push
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Promote to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Image tag to promote (e.g. 2026.03.28-f1b85bf)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
promote:
|
||||||
|
name: Promote to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Generate infra repo token
|
||||||
|
id: infra-token
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
app_id: ${{ vars.GH_APP_ID }}
|
||||||
|
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Clone groombook/infra
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||||
|
|
||||||
|
- name: Install yq
|
||||||
|
run: |
|
||||||
|
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||||
|
sudo chmod +x /usr/local/bin/yq
|
||||||
|
|
||||||
|
- name: Update prod overlay image tags
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
run: |
|
||||||
|
cd /tmp/infra
|
||||||
|
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
|
||||||
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
git -C /tmp/infra diff --stat
|
||||||
|
|
||||||
|
- name: Create PR on groombook/infra
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||||
|
run: |
|
||||||
|
cd /tmp/infra
|
||||||
|
git config user.name "groombook-engineer[bot]"
|
||||||
|
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||||
|
git checkout -b "release/promote-prod-${TAG}"
|
||||||
|
git add apps/groombook/overlays/prod/
|
||||||
|
git commit -m "release: promote ${TAG} to production"
|
||||||
|
git push -u origin "release/promote-prod-${TAG}"
|
||||||
|
gh pr create \
|
||||||
|
--repo groombook/infra \
|
||||||
|
--base main \
|
||||||
|
--head "release/promote-prod-${TAG}" \
|
||||||
|
--title "release: promote ${TAG} to production" \
|
||||||
|
--body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
@@ -214,4 +214,4 @@ All PRs require CI to pass before merge. See [CONTRIBUTING.md](./CONTRIBUTING.md
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
AGPL-3.0
|
||||||
|
|||||||
@@ -12,18 +12,17 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.800.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.800.0",
|
||||||
"@groombook/db": "workspace:*",
|
"@groombook/db": "workspace:*",
|
||||||
"@groombook/types": "workspace:*",
|
"@groombook/types": "workspace:*",
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@hono/zod-validator": "^0.4.3",
|
"@hono/zod-validator": "^0.7.6",
|
||||||
|
"better-auth": "^1.5.6",
|
||||||
"hono": "^4.6.17",
|
"hono": "^4.6.17",
|
||||||
"jose": "^5.9.6",
|
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"openid-client": "^6.1.7",
|
"zod": "^4.3.6"
|
||||||
"zod": "^3.24.1",
|
|
||||||
"@aws-sdk/client-s3": "^3.800.0",
|
|
||||||
"@aws-sdk/s3-request-presigner": "^3.800.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
@@ -35,5 +34,6 @@
|
|||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import type { StaffRow } from "../middleware/rbac.js";
|
|||||||
const MANAGER: StaffRow = {
|
const MANAGER: StaffRow = {
|
||||||
id: "staff-manager-id",
|
id: "staff-manager-id",
|
||||||
oidcSub: "oidc-manager-sub",
|
oidcSub: "oidc-manager-sub",
|
||||||
|
userId: null,
|
||||||
role: "manager",
|
role: "manager",
|
||||||
|
isSuperUser: true,
|
||||||
name: "Manager McManager",
|
name: "Manager McManager",
|
||||||
email: "manager@example.com",
|
email: "manager@example.com",
|
||||||
active: true,
|
active: true,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { JwtPayload } from "../middleware/auth.js";
|
|
||||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||||
import { buildStaff } from "@groombook/db/factories";
|
import { buildStaff } from "@groombook/db/factories";
|
||||||
|
|
||||||
@@ -167,7 +166,7 @@ function createApp(
|
|||||||
if (!staffRow) {
|
if (!staffRow) {
|
||||||
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
|
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
|
||||||
}
|
}
|
||||||
c.set("jwtPayload", { sub: staffRow.oidcSub } as JwtPayload);
|
c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string });
|
||||||
c.set("staff", staffRow as unknown as StaffRow);
|
c.set("staff", staffRow as unknown as StaffRow);
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|||||||
const MANAGER: StaffRow = {
|
const MANAGER: StaffRow = {
|
||||||
id: "staff-manager-id",
|
id: "staff-manager-id",
|
||||||
oidcSub: "oidc-manager-sub",
|
oidcSub: "oidc-manager-sub",
|
||||||
|
userId: null,
|
||||||
role: "manager",
|
role: "manager",
|
||||||
|
isSuperUser: true,
|
||||||
name: "Manager McManager",
|
name: "Manager McManager",
|
||||||
email: "manager@example.com",
|
email: "manager@example.com",
|
||||||
active: true,
|
active: true,
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|||||||
const MANAGER: StaffRow = {
|
const MANAGER: StaffRow = {
|
||||||
id: "staff-manager-id",
|
id: "staff-manager-id",
|
||||||
oidcSub: "oidc-manager-sub",
|
oidcSub: "oidc-manager-sub",
|
||||||
|
userId: "ba-user-manager",
|
||||||
role: "manager",
|
role: "manager",
|
||||||
|
isSuperUser: true,
|
||||||
name: "Manager McManager",
|
name: "Manager McManager",
|
||||||
email: "manager@example.com",
|
email: "manager@example.com",
|
||||||
active: true,
|
active: true,
|
||||||
@@ -21,6 +23,7 @@ const RECEPTIONIST: StaffRow = {
|
|||||||
...MANAGER,
|
...MANAGER,
|
||||||
id: "staff-receptionist-id",
|
id: "staff-receptionist-id",
|
||||||
oidcSub: "oidc-receptionist-sub",
|
oidcSub: "oidc-receptionist-sub",
|
||||||
|
userId: "ba-user-receptionist",
|
||||||
role: "receptionist",
|
role: "receptionist",
|
||||||
name: "Receptionist Rita",
|
name: "Receptionist Rita",
|
||||||
email: "receptionist@example.com",
|
email: "receptionist@example.com",
|
||||||
@@ -30,6 +33,7 @@ const GROOMER: StaffRow = {
|
|||||||
...MANAGER,
|
...MANAGER,
|
||||||
id: "staff-groomer-id",
|
id: "staff-groomer-id",
|
||||||
oidcSub: "oidc-groomer-sub",
|
oidcSub: "oidc-groomer-sub",
|
||||||
|
userId: "ba-user-groomer",
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
name: "Groomer Gary",
|
name: "Groomer Gary",
|
||||||
email: "groomer@example.com",
|
email: "groomer@example.com",
|
||||||
@@ -89,7 +93,7 @@ function buildApp(
|
|||||||
) {
|
) {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
c.set("jwtPayload", { sub: staffLookupResult?.oidcSub ?? "unknown-sub" });
|
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
app.use("*", middleware);
|
app.use("*", middleware);
|
||||||
@@ -106,7 +110,7 @@ function buildWithStaff(
|
|||||||
) {
|
) {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
|
c.set("jwtPayload", { sub: staffRow.userId ?? "" });
|
||||||
c.set("staff", staffRow);
|
c.set("staff", staffRow);
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
@@ -165,7 +169,7 @@ describe("resolveStaffMiddleware", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request("/test", {
|
const res = await app.request("/test", {
|
||||||
headers: { "X-Dev-User-Id": GROOMER.oidcSub! },
|
headers: { "X-Dev-User-Id": GROOMER.id },
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(capturedStaff!.role).toBe("groomer");
|
expect(capturedStaff!.role).toBe("groomer");
|
||||||
|
|||||||
+31
-4
@@ -2,6 +2,7 @@ import { serve } from "@hono/node-server";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
|
import { auth } from "./lib/auth.js";
|
||||||
import { clientsRouter } from "./routes/clients.js";
|
import { clientsRouter } from "./routes/clients.js";
|
||||||
import { petsRouter } from "./routes/pets.js";
|
import { petsRouter } from "./routes/pets.js";
|
||||||
import { servicesRouter } from "./routes/services.js";
|
import { servicesRouter } from "./routes/services.js";
|
||||||
@@ -18,9 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js";
|
|||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { searchRouter } from "./routes/search.js";
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { calendarRouter } from "./routes/calendar.js";
|
import { calendarRouter } from "./routes/calendar.js";
|
||||||
import { getDb, businessSettings } from "@groombook/db";
|
import { setupRouter } from "./routes/setup.js";
|
||||||
|
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
|
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
@@ -65,15 +67,37 @@ app.get("/api/branding", async (c) => {
|
|||||||
|
|
||||||
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
||||||
app.route("/api/calendar", calendarRouter);
|
app.route("/api/calendar", calendarRouter);
|
||||||
|
|
||||||
|
// Public setup status — no auth required, must be registered before auth middleware
|
||||||
|
app.get("/api/setup/status", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const [superUser] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.limit(1);
|
||||||
|
return c.json({ needsSetup: !superUser });
|
||||||
|
});
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
const api = app.basePath("/api");
|
const api = app.basePath("/api");
|
||||||
api.use("*", authMiddleware);
|
api.use("*", authMiddleware);
|
||||||
api.use("*", resolveStaffMiddleware);
|
api.use("*", resolveStaffMiddleware);
|
||||||
|
|
||||||
|
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||||
|
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||||
|
const authRouter = new Hono();
|
||||||
|
authRouter.all("/*", (c) => auth.handler(c.req.raw));
|
||||||
|
api.route("/auth", authRouter);
|
||||||
|
|
||||||
// ── Role guards ────────────────────────────────────────────────────────────────
|
// ── Role guards ────────────────────────────────────────────────────────────────
|
||||||
// Manager-only: staff, admin settings, reports, invoices, impersonation
|
// Manager-only: admin settings, reports, invoices, impersonation
|
||||||
api.use("/staff/*", requireRole("manager"));
|
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
|
||||||
|
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
||||||
|
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
||||||
|
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/*", requireRole("manager"));
|
api.use("/admin/*", requireRole("manager"));
|
||||||
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager"));
|
api.use("/invoices/*", requireRole("manager"));
|
||||||
api.use("/impersonation/*", requireRole("manager"));
|
api.use("/impersonation/*", requireRole("manager"));
|
||||||
@@ -113,6 +137,9 @@ api.on(
|
|||||||
);
|
);
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware
|
||||||
|
api.route("/setup", setupRouter);
|
||||||
|
|
||||||
api.route("/clients", clientsRouter);
|
api.route("/clients", clientsRouter);
|
||||||
api.route("/pets", petsRouter);
|
api.route("/pets", petsRouter);
|
||||||
api.route("/services", servicesRouter);
|
api.route("/services", servicesRouter);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
|
import { getDb } from "@groombook/db";
|
||||||
|
|
||||||
|
const OIDC_ISSUER = process.env.OIDC_ISSUER;
|
||||||
|
const OIDC_INTERNAL_BASE = process.env.OIDC_INTERNAL_BASE; // e.g. http://authentik-server.auth.svc.cluster.local
|
||||||
|
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
|
||||||
|
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
|
||||||
|
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||||
|
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
|
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
|
||||||
|
throw new Error(
|
||||||
|
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: drizzleAdapter(getDb(), {
|
||||||
|
provider: "pg",
|
||||||
|
}),
|
||||||
|
secret: BETTER_AUTH_SECRET,
|
||||||
|
baseURL: BETTER_AUTH_URL,
|
||||||
|
plugins: [
|
||||||
|
genericOAuth({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
providerId: "authentik",
|
||||||
|
clientId: OIDC_CLIENT_ID ?? "",
|
||||||
|
clientSecret: OIDC_CLIENT_SECRET ?? "",
|
||||||
|
// When OIDC_INTERNAL_BASE is set, use explicit URLs to avoid hairpin NAT:
|
||||||
|
// - authorizationUrl: external (browser redirect, no server-side fetch)
|
||||||
|
// - tokenUrl/userInfoUrl: internal (server-to-server, avoids hairpin)
|
||||||
|
// When not set, fall back to discoveryUrl for local dev.
|
||||||
|
...(OIDC_INTERNAL_BASE
|
||||||
|
? {
|
||||||
|
authorizationUrl: `${new URL(OIDC_ISSUER!).origin}/application/o/authorize/`,
|
||||||
|
tokenUrl: `${OIDC_INTERNAL_BASE}/application/o/token/`,
|
||||||
|
userInfoUrl: `${OIDC_INTERNAL_BASE}/application/o/userinfo/`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
discoveryUrl: OIDC_ISSUER
|
||||||
|
? `${OIDC_ISSUER}/.well-known/openid-configuration`
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
scopes: ["openid", "profile", "email"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
updateAge: 60 * 60 * 24, // 1 day
|
||||||
|
cookieCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxAge: 5 * 60, // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
|
});
|
||||||
@@ -1,34 +1,18 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
import { auth } from "../lib/auth.js";
|
||||||
|
|
||||||
// Authentik OIDC configuration — loaded from env at startup
|
export interface AuthUser {
|
||||||
const OIDC_ISSUER = process.env.OIDC_ISSUER;
|
id: string;
|
||||||
const OIDC_AUDIENCE = process.env.OIDC_AUDIENCE;
|
email: string;
|
||||||
|
name: string;
|
||||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
|
||||||
|
|
||||||
function getJwks() {
|
|
||||||
if (!OIDC_ISSUER) throw new Error("OIDC_ISSUER is not set");
|
|
||||||
if (!jwks) {
|
|
||||||
jwks = createRemoteJWKSet(
|
|
||||||
new URL(`${OIDC_ISSUER}/application/o/groombook/jwks/`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return jwks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
// Guard: refuse to start with AUTH_DISABLED in production.
|
||||||
sub: string;
|
|
||||||
email?: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: refuse to start with AUTH_DISABLED in production (fixes #22).
|
|
||||||
if (process.env.AUTH_DISABLED === "true") {
|
if (process.env.AUTH_DISABLED === "true") {
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
console.error(
|
console.error(
|
||||||
"[FATAL] AUTH_DISABLED=true is not allowed in production. " +
|
"[FATAL] AUTH_DISABLED=true is not allowed in production. " +
|
||||||
"Remove AUTH_DISABLED from your environment and configure OIDC_ISSUER."
|
"Remove AUTH_DISABLED from your environment and configure Better-Auth."
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -39,30 +23,33 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
if (process.env.AUTH_DISABLED === "true") {
|
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
|
||||||
const devUserId = c.req.header("X-Dev-User-Id");
|
if (c.req.path.startsWith("/api/auth/")) {
|
||||||
const sub = devUserId ?? "dev-user";
|
|
||||||
c.set("jwtPayload", { sub } as JwtPayload);
|
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorization = c.req.header("Authorization");
|
if (process.env.AUTH_DISABLED === "true") {
|
||||||
if (!authorization?.startsWith("Bearer ")) {
|
const devUserId = c.req.header("X-Dev-User-Id");
|
||||||
|
const sub = devUserId ?? "dev-user";
|
||||||
|
c.set("jwtPayload", { sub } as { sub: string });
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: c.req.raw.headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authorization.slice(7);
|
// Set jwtPayload with sub = Better-Auth user ID for backward compat with resolveStaffMiddleware
|
||||||
|
c.set("jwtPayload", {
|
||||||
try {
|
sub: session.user.id,
|
||||||
const { payload } = await jwtVerify(token, getJwks(), {
|
email: session.user.email,
|
||||||
issuer: OIDC_ISSUER,
|
name: session.user.name,
|
||||||
audience: OIDC_AUDIENCE,
|
});
|
||||||
});
|
await next();
|
||||||
|
|
||||||
c.set("jwtPayload", payload as JwtPayload);
|
|
||||||
await next();
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "Invalid or expired token" }, 401);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { eq, getDb, staff } from "@groombook/db";
|
import { eq, getDb, staff } from "@groombook/db";
|
||||||
import type { JwtPayload } from "./auth.js";
|
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
|
|
||||||
export interface AppEnv {
|
export interface AppEnv {
|
||||||
Variables: {
|
Variables: {
|
||||||
jwtPayload: JwtPayload;
|
jwtPayload: { sub: string; email?: string; name?: string };
|
||||||
staff: StaffRow;
|
staff: StaffRow;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -16,13 +15,19 @@ export interface AppEnv {
|
|||||||
* Resolves the authenticated staff record from the DB and stores it in context.
|
* Resolves the authenticated staff record from the DB and stores it in context.
|
||||||
* Must be applied after authMiddleware on all protected routes.
|
* Must be applied after authMiddleware on all protected routes.
|
||||||
*
|
*
|
||||||
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (treated
|
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (Better-Auth
|
||||||
* as oidcSub), or falls back to the first manager in the DB.
|
* user ID), or falls back to the first manager in the DB.
|
||||||
*/
|
*/
|
||||||
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||||
c,
|
c,
|
||||||
next
|
next
|
||||||
) => {
|
) => {
|
||||||
|
// Better-Auth's own routes handle their own auth — skip staff resolution
|
||||||
|
if (c.req.path.startsWith("/api/auth/")) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
if (process.env.AUTH_DISABLED === "true") {
|
if (process.env.AUTH_DISABLED === "true") {
|
||||||
@@ -37,38 +42,59 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
||||||
}
|
}
|
||||||
c.set("staff", manager);
|
c.set("staff", { ...manager, isSuperUser: true });
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Treat X-Dev-User-Id as the oidcSub
|
// Treat X-Dev-User-Id as the Better-Auth user ID first
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, devUserId));
|
.where(eq(staff.userId, devUserId));
|
||||||
if (!row) {
|
if (row) {
|
||||||
|
c.set("staff", { ...row, isSuperUser: true });
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback: if userId is null, treat X-Dev-User-Id as staff.id (dev login
|
||||||
|
// may send the primary key for staff records that predate the userId field)
|
||||||
|
const [fallbackRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, devUserId));
|
||||||
|
if (!fallbackRow) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
|
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
c.set("staff", row);
|
c.set("staff", { ...fallbackRow, isSuperUser: true });
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwt = c.get("jwtPayload");
|
const jwt = c.get("jwtPayload");
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.userId, jwt.sub));
|
||||||
|
if (row) {
|
||||||
|
c.set("staff", row);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback: staff records that predate the userId field may still have oidcSub
|
||||||
|
const [fallbackRow] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, jwt.sub));
|
.where(eq(staff.oidcSub, jwt.sub));
|
||||||
if (!row) {
|
if (!fallbackRow) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
c.set("staff", row);
|
c.set("staff", fallbackRow);
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,3 +125,58 @@ export function requireRole(
|
|||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that allows access if the staff member has any of the allowed roles OR is a super user.
|
||||||
|
* Use for routes where managers OR super-users should have access.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||||
|
*/
|
||||||
|
export function requireRoleOrSuperUser(
|
||||||
|
...allowedRoles: StaffRole[]
|
||||||
|
): MiddlewareHandler<AppEnv> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
if (!staffRow) {
|
||||||
|
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||||
|
}
|
||||||
|
const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role);
|
||||||
|
if (hasAllowedRole || staffRow.isSuperUser) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: staffRow.isSuperUser
|
||||||
|
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||||
|
: "Forbidden: super user privileges required",
|
||||||
|
},
|
||||||
|
403
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that enforces the staff member is a super user.
|
||||||
|
* Must be applied after resolveStaffMiddleware and (typically) after requireRole.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* api.use("/staff/*", requireRole("manager"));
|
||||||
|
* api.use("/staff/*", requireSuperUser());
|
||||||
|
*/
|
||||||
|
export function requireSuperUser(): MiddlewareHandler<AppEnv> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
if (!staffRow) {
|
||||||
|
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||||
|
}
|
||||||
|
if (!staffRow.isSuperUser) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "Forbidden: super user privileges required" },
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ devRouter.get("/users", async (c) => {
|
|||||||
const staffList = await db
|
const staffList = await db
|
||||||
.select({
|
.select({
|
||||||
id: staff.id,
|
id: staff.id,
|
||||||
|
userId: staff.userId,
|
||||||
name: staff.name,
|
name: staff.name,
|
||||||
email: staff.email,
|
email: staff.email,
|
||||||
role: staff.role,
|
role: staff.role,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||||
|
|
||||||
export const groomingLogsRouter = new Hono();
|
export const groomingLogsRouter = new Hono();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
+137
-57
@@ -1,11 +1,135 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
|
import { and, eq, inArray } from "@groombook/db";
|
||||||
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const portalRouter = new Hono<AppEnv>();
|
export const portalRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
|
.limit(1);
|
||||||
|
if (!session || session.expiresAt <= new Date()) return null;
|
||||||
|
return session.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
portalRouter.get("/me", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
|
if (!client) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.get("/services", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||||
|
return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes })));
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.get("/appointments", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const allAppts = await db
|
||||||
|
.select({
|
||||||
|
id: appointments.id,
|
||||||
|
startTime: appointments.startTime,
|
||||||
|
endTime: appointments.endTime,
|
||||||
|
status: appointments.status,
|
||||||
|
confirmationStatus: appointments.confirmationStatus,
|
||||||
|
customerNotes: appointments.customerNotes,
|
||||||
|
notes: appointments.notes,
|
||||||
|
petId: appointments.petId,
|
||||||
|
serviceId: appointments.serviceId,
|
||||||
|
staffId: appointments.staffId,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.clientId, clientId))
|
||||||
|
.orderBy(appointments.startTime);
|
||||||
|
|
||||||
|
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
|
||||||
|
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||||
|
|
||||||
|
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||||
|
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
||||||
|
|
||||||
|
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||||
|
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||||
|
|
||||||
|
const appts = allAppts.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
startTime: a.startTime,
|
||||||
|
endTime: a.endTime,
|
||||||
|
status: a.status,
|
||||||
|
confirmationStatus: a.confirmationStatus,
|
||||||
|
customerNotes: a.customerNotes,
|
||||||
|
notes: a.notes,
|
||||||
|
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
||||||
|
service: a.serviceId ? { id: a.serviceId } : null,
|
||||||
|
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
|
||||||
|
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
|
||||||
|
|
||||||
|
return c.json({ upcoming, past });
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.get("/pets", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.get("/invoices", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||||
|
const invoiceIds = clientInvoices.map(i => i.id);
|
||||||
|
const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : [];
|
||||||
|
|
||||||
|
const itemsByInvoice: Record<string, typeof lineItems> = {};
|
||||||
|
for (const li of lineItems) {
|
||||||
|
if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = [];
|
||||||
|
itemsByInvoice[li.invoiceId]!.push(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(clientInvoices.map(inv => ({
|
||||||
|
id: inv.id,
|
||||||
|
status: inv.status,
|
||||||
|
totalCents: inv.totalCents,
|
||||||
|
createdAt: inv.createdAt,
|
||||||
|
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Appointment action routes ────────────────────────────────────────────────
|
||||||
|
|
||||||
const customerNotesSchema = z.object({
|
const customerNotesSchema = z.object({
|
||||||
// .min(1) prevents empty strings — clearing notes is not a supported use case
|
// .min(1) prevents empty strings — clearing notes is not a supported use case
|
||||||
customerNotes: z.string().min(1).max(500),
|
customerNotes: z.string().min(1).max(500),
|
||||||
@@ -20,27 +144,11 @@ portalRouter.patch(
|
|||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
if (!sessionId) {
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authClientId = session.clientId;
|
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
@@ -51,7 +159,7 @@ portalRouter.patch(
|
|||||||
return c.json({ error: "Not found" }, 404);
|
return c.json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appt.clientId !== authClientId) {
|
if (appt.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,22 +192,8 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
if (!sessionId) {
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
if (!clientId) {
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +207,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
return c.json({ error: "Not found" }, 404);
|
return c.json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appt.clientId !== session.clientId) {
|
if (appt.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,22 +246,8 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
|||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
if (!sessionId) {
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
if (!clientId) {
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +261,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
|||||||
return c.json({ error: "Not found" }, 404);
|
return c.json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appt.clientId !== session.clientId) {
|
if (appt.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +292,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Client-facing waitlist routes ───────────────────────────────────────────
|
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||||
|
|
||||||
const createWaitlistEntrySchema = z.object({
|
const createWaitlistEntrySchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
@@ -366,4 +446,4 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, services } from "@groombook/db";
|
import { eq, getDb, services } from "@groombook/db";
|
||||||
|
|
||||||
export const servicesRouter = new Hono();
|
export const servicesRouter = new Hono();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||||
|
|
||||||
export const settingsRouter = new Hono();
|
export const settingsRouter = new Hono();
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { z } from "zod/v3";
|
||||||
|
import { eq, getDb, staff, businessSettings } from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
|
setupRouter.get("/status", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Check if any super user exists
|
||||||
|
const [superUser] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return c.json({ needsSetup: !superUser });
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupSchema = z.object({
|
||||||
|
businessName: z.string().min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/setup — authenticated, marks current staff as super user and sets business name
|
||||||
|
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
|
|
||||||
|
// Use a transaction with row-level locking to prevent race conditions
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
// Lock the business_settings row for update to prevent concurrent setup
|
||||||
|
const [existingSettings] = await tx
|
||||||
|
.select({ id: businessSettings.id })
|
||||||
|
.from(businessSettings)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Lock super user rows to prevent concurrent claims
|
||||||
|
// FOR UPDATE serializes concurrent claims: second transaction blocks until first commits
|
||||||
|
const [existingSuperUser] = await tx
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.for("update")
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSuperUser) {
|
||||||
|
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create business settings with the business name
|
||||||
|
if (existingSettings) {
|
||||||
|
await tx
|
||||||
|
.update(businessSettings)
|
||||||
|
.set({ businessName: body.businessName, updatedAt: new Date() })
|
||||||
|
.where(eq(businessSettings.id, existingSettings.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(businessSettings).values({ businessName: body.businessName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the current staff as super user
|
||||||
|
const [updatedStaff] = await tx
|
||||||
|
.update(staff)
|
||||||
|
.set({ isSuperUser: true, updatedAt: new Date() })
|
||||||
|
.where(eq(staff.id, currentStaff.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { staff: updatedStaff };
|
||||||
|
});
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
return c.json({ error: result.error }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, staff: result.staff }, 201);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v3";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { and, eq, getDb, ne, staff, appointments } from "@groombook/db";
|
import { and, eq, getDb, ne, staff, appointments } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.50.1"
|
"@playwright/test": "^1.50.1"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ test("clients page shows client list", async ({ page }) => {
|
|||||||
|
|
||||||
test("clients page shows search input", async ({ page }) => {
|
test("clients page shows search input", async ({ page }) => {
|
||||||
await page.goto("/admin/clients");
|
await page.goto("/admin/clients");
|
||||||
await expect(page.getByPlaceholder(/search/i)).toBeVisible();
|
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking a client shows their details", async ({ page }) => {
|
test("clicking a client shows their details", async ({ page }) => {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { test as base } from "@playwright/test";
|
import { test as base } from "@playwright/test";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom test fixture that bypasses the dev login redirect for E2E tests.
|
* Custom test fixture that bypasses auth for E2E tests.
|
||||||
*
|
*
|
||||||
* When AUTH_DISABLED=true, the app fetches /api/dev/config and redirects to
|
* When authDisabled=true, the app uses the dev login selector instead of
|
||||||
* /login if no dev-user is in localStorage. This fixture:
|
* Better Auth signIn.social(). This fixture:
|
||||||
* 1. Mocks /api/dev/config to return authDisabled: false
|
* 1. Mocks /api/dev/config to return authDisabled: true
|
||||||
* 2. Seeds localStorage with a dev user as a fallback
|
* 2. Seeds localStorage with a dev user so the selector auto-selects a session
|
||||||
*
|
*
|
||||||
* This ensures E2E tests render pages directly without the login redirect.
|
* This ensures E2E tests render pages directly without the auth redirect.
|
||||||
*/
|
*/
|
||||||
const MOCK_DEV_USERS = {
|
const MOCK_DEV_USERS = {
|
||||||
staff: [
|
staff: [
|
||||||
@@ -23,9 +23,9 @@ const MOCK_DEV_USERS = {
|
|||||||
|
|
||||||
export const test = base.extend({
|
export const test = base.extend({
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
// Mock the dev config endpoint so the app skips the auth-disabled redirect
|
// Mock the dev config endpoint so the app uses dev login selector (bypasses Better Auth)
|
||||||
await page.route("**/api/dev/config", (route) =>
|
await page.route("**/api/dev/config", (route) =>
|
||||||
route.fulfill({ json: { authDisabled: false } })
|
route.fulfill({ json: { authDisabled: true } })
|
||||||
);
|
);
|
||||||
// Mock the dev users endpoint for login selector tests
|
// Mock the dev users endpoint for login selector tests
|
||||||
await page.route("**/api/dev/users", (route) =>
|
await page.route("**/api/dev/users", (route) =>
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ test.beforeEach(async ({ page }) => {
|
|||||||
// Reports endpoints need shaped responses (not bare []) to avoid render crashes.
|
// Reports endpoints need shaped responses (not bare []) to avoid render crashes.
|
||||||
await page.route("/api/**", (route) => {
|
await page.route("/api/**", (route) => {
|
||||||
const url = route.request().url();
|
const url = route.request().url();
|
||||||
|
if (url.includes("/api/dev/config")) {
|
||||||
|
return route.fulfill({ json: { authDisabled: true } });
|
||||||
|
}
|
||||||
|
if (url.includes("/api/dev/users")) {
|
||||||
|
return route.fulfill({ json: { staff: [], clients: [] } });
|
||||||
|
}
|
||||||
|
if (url.includes("/api/branding")) {
|
||||||
|
return route.fulfill({ json: { businessName: "GroomBook", logoUrl: null, theme: "default" } });
|
||||||
|
}
|
||||||
if (url.includes("/api/reports/summary")) {
|
if (url.includes("/api/reports/summary")) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Ignore untracked .js files containing JSX (build artifacts)
|
||||||
|
src/__tests__/*.js
|
||||||
|
src/portal/sections/*.js
|
||||||
|
src/portal/*.js
|
||||||
|
src/pages/*.js
|
||||||
|
src/components/*.js
|
||||||
|
src/lib/*.js
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
// Untracked .js files containing JSX (build artifacts)
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
],
|
||||||
|
},
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@groombook/types": "workspace:*",
|
"@groombook/types": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"better-auth": "^1.0.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -36,5 +37,6 @@
|
|||||||
"vite": "^6.0.7",
|
"vite": "^6.0.7",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-11
@@ -12,11 +12,68 @@ import { SettingsPage } from "./pages/Settings.js";
|
|||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
|
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||||
|
import { useSession, signIn } from "./lib/auth-client.js";
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100vh",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
background: "#f0f2f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "2rem 2.5rem",
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
|
textAlign: "center",
|
||||||
|
minWidth: 280,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: 22, marginBottom: "0.5rem", color: "#1a202c" }}>GroomBook</h1>
|
||||||
|
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
||||||
|
Sign in to continue
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: "0.6rem 1.5rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
background: "#4f8a6f",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: isLoading ? "wait" : "pointer",
|
||||||
|
opacity: isLoading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ to: "/admin", label: "Appointments" },
|
{ to: "/admin", label: "Appointments" },
|
||||||
@@ -133,6 +190,11 @@ function AdminLayout() {
|
|||||||
export function App() {
|
export function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
||||||
|
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||||
|
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
||||||
|
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
||||||
|
const session = authDisabled ? null : rawSession;
|
||||||
|
const sessionLoading = authDisabled ? false : rawSessionLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/dev/config")
|
fetch("/api/dev/config")
|
||||||
@@ -141,18 +203,18 @@ export function App() {
|
|||||||
.catch(() => setAuthDisabled(false));
|
.catch(() => setAuthDisabled(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show login selector page
|
// After session is confirmed, check if setup is needed
|
||||||
if (location.pathname === "/login") {
|
useEffect(() => {
|
||||||
return <DevLoginSelector />;
|
if (authDisabled === null || sessionLoading) return;
|
||||||
}
|
// Skip if no authenticated session (will redirect to login or dev selector)
|
||||||
|
if (!authDisabled && !session) return;
|
||||||
|
if (authDisabled && !getDevUser()) return;
|
||||||
|
|
||||||
// While checking auth config, render nothing briefly
|
fetch("/api/setup/status")
|
||||||
if (authDisabled === null) return null;
|
.then((r) => r.json())
|
||||||
|
.then((data) => setNeedsSetup(data.needsSetup === true))
|
||||||
// If auth is disabled and no dev user is selected, redirect to login selector
|
.catch(() => setNeedsSetup(false));
|
||||||
if (authDisabled && !getDevUser() && location.pathname !== "/login") {
|
}, [authDisabled, session, sessionLoading]);
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public booking redirect pages — no auth or portal chrome needed
|
// Public booking redirect pages — no auth or portal chrome needed
|
||||||
if (location.pathname === "/booking/confirmed") {
|
if (location.pathname === "/booking/confirmed") {
|
||||||
@@ -165,6 +227,41 @@ export function App() {
|
|||||||
return <BookingErrorPage />;
|
return <BookingErrorPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup wizard — standalone, no admin chrome
|
||||||
|
if (location.pathname === "/setup") {
|
||||||
|
return (
|
||||||
|
<BrandingProvider>
|
||||||
|
<SetupWizard />
|
||||||
|
</BrandingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still loading auth state or setup check (skip setup check in dev mode)
|
||||||
|
if (authDisabled === null || sessionLoading) return null;
|
||||||
|
|
||||||
|
// Dev mode: show login selector (no setup check needed in dev mode)
|
||||||
|
if (authDisabled && location.pathname === "/login") {
|
||||||
|
return <DevLoginSelector />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev mode: use dev login selector (no setup check needed in dev mode)
|
||||||
|
if (authDisabled && !getDevUser()) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production: need setup check
|
||||||
|
if (needsSetup === null) return null;
|
||||||
|
|
||||||
|
// Production mode: if no session, redirect to Authentik sign-in
|
||||||
|
if (!authDisabled && !session) {
|
||||||
|
return <LoginPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to setup wizard if needed
|
||||||
|
if (needsSetup) {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
{location.pathname.startsWith("/admin") ? (
|
{location.pathname.startsWith("/admin") ? (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, within, waitFor } from "@testing-library/react";
|
import { render, screen, within, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { App } from "../App.js";
|
import { App } from "../App";
|
||||||
|
|
||||||
|
|
||||||
// Mock fetch to return appropriate responses based on URL
|
// Mock fetch to return appropriate responses based on URL
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,6 +45,32 @@ async function renderApp(route = "/admin") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("App navigation", () => {
|
describe("App navigation", () => {
|
||||||
|
// Use authDisabled=true (dev mode) so nav renders without needing Better Auth useSession() mock
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||||
|
global.fetch = vi.fn((url: string) => {
|
||||||
|
if (url === "/api/dev/config") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ authDisabled: true }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/branding") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
businessName: "GroomBook",
|
||||||
|
primaryColor: "#4f8a6f",
|
||||||
|
accentColor: "#8b7355",
|
||||||
|
logoBase64: null,
|
||||||
|
logoMimeType: null,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the Groom Book brand", async () => {
|
it("renders the Groom Book brand", async () => {
|
||||||
const nav = await renderApp();
|
const nav = await renderApp();
|
||||||
expect(
|
expect(
|
||||||
@@ -124,6 +151,12 @@ describe("Dev login selector", () => {
|
|||||||
}),
|
}),
|
||||||
} as Response);
|
} as Response);
|
||||||
}
|
}
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: null }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import type { Appointment } from "../portal/mockData.js";
|
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
||||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.js";
|
|
||||||
|
|
||||||
const UPCOMING_APPT: Appointment = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
petId: "pet-1",
|
petId: "pet-1",
|
||||||
petName: "Buddy",
|
petName: "Buddy",
|
||||||
groomerId: "groomer-1",
|
groomerId: "groomer-1",
|
||||||
groomerName: "Sarah",
|
groomerName: "Sarah",
|
||||||
services: ["Bath & Brush"],
|
services: ["Bath & Brush"],
|
||||||
|
serviceId: "service-1",
|
||||||
addOns: [],
|
addOns: [],
|
||||||
date: "2027-01-01",
|
date: "2027-01-01",
|
||||||
time: "10:00 AM",
|
time: "10:00 AM",
|
||||||
duration: 60,
|
duration: 60,
|
||||||
price: 50,
|
price: 50,
|
||||||
status: "confirmed",
|
status: "confirmed" as const,
|
||||||
notes: "",
|
notes: "",
|
||||||
customerNotes: "",
|
customerNotes: "",
|
||||||
confirmationStatus: "pending",
|
confirmationStatus: "pending" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAST_APPT: Appointment = {
|
const PAST_APPT = {
|
||||||
...UPCOMING_APPT,
|
...UPCOMING_APPT,
|
||||||
id: "appt-2",
|
id: "appt-2",
|
||||||
date: "2025-01-01",
|
date: "2025-01-01",
|
||||||
time: "10:00 AM",
|
time: "10:00 AM",
|
||||||
status: "completed",
|
status: "completed" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("parseTimeTo24Hour", () => {
|
describe("parseTimeTo24Hour", () => {
|
||||||
@@ -78,7 +78,7 @@ describe("CustomerNotesSection", () => {
|
|||||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends X-Impersonation-Session-Id header when session exists", async () => {
|
it("sends Authorization header when session exists", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||||
@@ -93,14 +93,14 @@ describe("CustomerNotesSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/notes",
|
"/api/portal/appointments/appt-1/notes",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"X-Impersonation-Session-Id": "test-session-id",
|
"Authorization": "Bearer test-session-id",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
|
it("does not send Authorization header when sessionId is null", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||||
@@ -115,7 +115,7 @@ describe("CustomerNotesSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/notes",
|
"/api/portal/appointments/appt-1/notes",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.not.objectContaining({
|
headers: expect.not.objectContaining({
|
||||||
"X-Impersonation-Session-Id": expect.anything(),
|
"Authorization": expect.anything(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -212,7 +212,7 @@ describe("ConfirmationSection", () => {
|
|||||||
|
|
||||||
it("renders confirmed badge when confirmationStatus is confirmed", () => {
|
it("renders confirmed badge when confirmationStatus is confirmed", () => {
|
||||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||||
expect(screen.getByText("✓ Confirmed")).toBeInTheDocument();
|
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders cancelled badge when confirmationStatus is cancelled", () => {
|
it("renders cancelled badge when confirmationStatus is cancelled", () => {
|
||||||
@@ -251,11 +251,11 @@ describe("ConfirmationSection", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("✓ Confirmed")).toBeInTheDocument();
|
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends X-Impersonation-Session-Id header when session exists", async () => {
|
it("sends Authorization header when session exists", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||||
@@ -269,14 +269,14 @@ describe("ConfirmationSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/confirm",
|
"/api/portal/appointments/appt-1/confirm",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"X-Impersonation-Session-Id": "test-session-id",
|
"Authorization": "Bearer test-session-id",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
|
it("does not send Authorization header when sessionId is null", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||||
@@ -290,7 +290,7 @@ describe("ConfirmationSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/confirm",
|
"/api/portal/appointments/appt-1/confirm",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.not.objectContaining({
|
headers: expect.not.objectContaining({
|
||||||
"X-Impersonation-Session-Id": expect.anything(),
|
"Authorization": expect.anything(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { signIn, signOut, useSession } = authClient;
|
||||||
@@ -9,6 +9,9 @@ const originalFetch = window.fetch;
|
|||||||
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
|
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
|
||||||
*/
|
*/
|
||||||
export function installDevFetchInterceptor() {
|
export function installDevFetchInterceptor() {
|
||||||
|
// In production, Better-Auth handles auth via cookies — no interception needed
|
||||||
|
if (!import.meta.env.DEV) return;
|
||||||
|
|
||||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
||||||
const user = getDevUser();
|
const user = getDevUser();
|
||||||
if (!user) return originalFetch(input, init);
|
if (!user) return originalFetch(input, init);
|
||||||
|
|||||||
@@ -131,9 +131,18 @@ export function AppointmentsPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadAppointments(),
|
loadAppointments(),
|
||||||
fetch("/api/clients").then((r) => r.json() as Promise<Client[]>).then(setClients),
|
fetch("/api/clients").then((r) => {
|
||||||
fetch("/api/services").then((r) => r.json() as Promise<Service[]>).then(setServices),
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
fetch("/api/staff").then((r) => r.json() as Promise<Staff[]>).then(setStaff),
|
return r.json() as Promise<Client[]>;
|
||||||
|
}).then(setClients),
|
||||||
|
fetch("/api/services").then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json() as Promise<Service[]>;
|
||||||
|
}).then(setServices),
|
||||||
|
fetch("/api/staff").then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json() as Promise<Staff[]>;
|
||||||
|
}).then(setStaff),
|
||||||
])
|
])
|
||||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { Service } from "@groombook/types";
|
import type { Service } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
@@ -107,6 +108,7 @@ export function BookPage() {
|
|||||||
|
|
||||||
// Step 2 — date & time
|
// Step 2 — date & time
|
||||||
const [date, setDate] = useState(todayIso());
|
const [date, setDate] = useState(todayIso());
|
||||||
|
const [dateError, setDateError] = useState<string | null>(null);
|
||||||
const [slots, setSlots] = useState<string[]>([]);
|
const [slots, setSlots] = useState<string[]>([]);
|
||||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||||
@@ -125,6 +127,28 @@ export function BookPage() {
|
|||||||
});
|
});
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pre-fill form from URL params (e.g., ?clientName=Jane&clientEmail=jane@example.com)
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
useEffect(() => {
|
||||||
|
const clientName = searchParams.get("clientName");
|
||||||
|
const clientEmail = searchParams.get("clientEmail");
|
||||||
|
const clientPhone = searchParams.get("clientPhone");
|
||||||
|
const petName = searchParams.get("petName");
|
||||||
|
const petSpecies = searchParams.get("petSpecies");
|
||||||
|
const petBreed = searchParams.get("petBreed");
|
||||||
|
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
...(clientName && { clientName }),
|
||||||
|
...(clientEmail && { clientEmail }),
|
||||||
|
...(clientPhone && { clientPhone }),
|
||||||
|
...(petName && { petName }),
|
||||||
|
...(petSpecies && { petSpecies }),
|
||||||
|
...(petBreed && { petBreed }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
// Step 4 — result
|
// Step 4 — result
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [result, setResult] = useState<BookingResult | null>(null);
|
const [result, setResult] = useState<BookingResult | null>(null);
|
||||||
@@ -328,8 +352,21 @@ export function BookPage() {
|
|||||||
value={date}
|
value={date}
|
||||||
min={todayIso()}
|
min={todayIso()}
|
||||||
style={{ ...input, width: "auto" }}
|
style={{ ...input, width: "auto" }}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
// HTML5 date input enforces yyyy-MM-dd; empty value means invalid format
|
||||||
|
if (!val) {
|
||||||
|
setDateError("Please enter a valid date (YYYY-MM-DD).");
|
||||||
|
setDate("");
|
||||||
|
} else {
|
||||||
|
setDateError(null);
|
||||||
|
setDate(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{dateError && (
|
||||||
|
<p style={{ color: "#dc2626", fontSize: 12, marginTop: 4 }}>{dateError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: "1.25rem" }}>
|
<div style={{ marginBottom: "1.25rem" }}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
interface StaffUser {
|
interface StaffUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -66,7 +67,7 @@ export function DevLoginSelector() {
|
|||||||
{staff.map((s) => (
|
{staff.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
onClick={() => selectUser("staff", s.id, s.name)}
|
onClick={() => selectUser("staff", s.userId ?? s.id, s.name)}
|
||||||
style={userButtonStyle}
|
style={userButtonStyle}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SetupWizard } from "./SetupWizard.jsx";
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||||
|
{ title: "Business Name", description: "What is the name of your business?" },
|
||||||
|
{ title: "Super User", description: "You will be designated as a Super User with full administrative access." },
|
||||||
|
{ title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
|
||||||
|
{ title: "All Set!", description: "Your GroomBook instance is ready to use." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SetupWizard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { refresh: refreshBranding } = useBranding();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const current = STEPS[step];
|
||||||
|
const isLast = step === STEPS.length - 1;
|
||||||
|
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||||
|
const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0);
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (step === STEPS.length - 1) {
|
||||||
|
// Done - redirect to admin
|
||||||
|
navigate("/admin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 1 && businessName.trim()) {
|
||||||
|
// Step 2 (index 1) -> Step 3 (index 2): submit setup
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/setup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || "Setup failed. Please try again.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Refresh branding so the nav bar shows the new business name
|
||||||
|
refreshBranding();
|
||||||
|
} catch (e) {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
setStep((s) => s + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step > 0) setStep((s) => s - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#f0f2f5",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.10)",
|
||||||
|
padding: "2.5rem 3rem",
|
||||||
|
maxWidth: 480,
|
||||||
|
width: "100%",
|
||||||
|
}}>
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||||
|
{STEPS.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: i === step ? "#4f8a6f" : i < step ? "#4f8a6f" : "#e2e8f0",
|
||||||
|
opacity: i === step ? 1 : i < step ? 0.5 : 1,
|
||||||
|
transition: "background 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||||
|
Step {step + 1} of {STEPS.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||||
|
{current.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||||
|
{current.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Step 2: Business name input */}
|
||||||
|
{step === 1 && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Happy Paws Grooming"
|
||||||
|
value={businessName}
|
||||||
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.6rem 0.85rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
fontSize: 15,
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
marginBottom: error ? "0.5rem" : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Info about super user */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div style={{
|
||||||
|
background: "#f0fdf4",
|
||||||
|
border: "1px solid #bbf7d0",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#166534",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}>
|
||||||
|
As a Super User, you can manage all settings, staff, and appointments.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Info about second admin */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div style={{
|
||||||
|
background: "#fffbeb",
|
||||||
|
border: "1px solid #fde68a",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#92400e",
|
||||||
|
}}>
|
||||||
|
You can add additional Super Users from the Staff management page after setup.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p style={{
|
||||||
|
margin: "0.5rem 0 0",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#dc2626",
|
||||||
|
background: "#fef2f2",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation buttons */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginTop: step === 3 ? "1.5rem" : "1.25rem",
|
||||||
|
justifyContent: step === 0 ? "flex-end" : "space-between",
|
||||||
|
}}>
|
||||||
|
{canGoBack && (
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.55rem 1.1rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
background: "#fff",
|
||||||
|
color: "#374151",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canGoNext || loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.55rem 1.25rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: canGoNext && !loading ? "#4f8a6f" : "#9ca3af",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: canGoNext && !loading ? "pointer" : "not-allowed",
|
||||||
|
opacity: loading ? 0.7 : 1,
|
||||||
|
marginLeft: canGoBack ? 0 : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Setting up..." : isLast ? "Go to Dashboard" : step === 1 ? "Continue" : "Next"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Settings, LogOut, Shield,
|
Settings, LogOut, Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Dashboard } from "./sections/Dashboard.js";
|
import { Dashboard } from "./sections/Dashboard.js";
|
||||||
import { AppointmentsSection } from "./sections/Appointments.js";
|
import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js";
|
||||||
import { PetProfiles } from "./sections/PetProfiles.js";
|
import { PetProfiles } from "./sections/PetProfiles.js";
|
||||||
import { ReportCards } from "./sections/ReportCards.js";
|
import { ReportCards } from "./sections/ReportCards.js";
|
||||||
import { BillingPayments } from "./sections/BillingPayments.js";
|
import { BillingPayments } from "./sections/BillingPayments.js";
|
||||||
@@ -13,7 +13,6 @@ import { Communication } from "./sections/Communication.js";
|
|||||||
import { AccountSettings } from "./sections/AccountSettings.js";
|
import { AccountSettings } from "./sections/AccountSettings.js";
|
||||||
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||||
import { CUSTOMER } from "./mockData.js";
|
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
|
|
||||||
@@ -33,8 +32,11 @@ export function CustomerPortal() {
|
|||||||
const [activeSection, setActiveSection] = useState<Section>("dashboard");
|
const [activeSection, setActiveSection] = useState<Section>("dashboard");
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
|
const [showReschedule, setShowReschedule] = useState(false);
|
||||||
|
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
||||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||||
const [sessionExtended, setSessionExtended] = useState(false);
|
const [sessionExtended, setSessionExtended] = useState(false);
|
||||||
|
const [clientName, setClientName] = useState<string>("");
|
||||||
const { branding } = useBranding();
|
const { branding } = useBranding();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -55,6 +57,11 @@ export function CustomerPortal() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (s && s.status === "active") {
|
if (s && s.status === "active") {
|
||||||
setSession(s);
|
setSession(s);
|
||||||
|
// Fetch client name for display
|
||||||
|
fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data?.name) setClientName(data.name); })
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
// Clean sessionId from URL
|
// Clean sessionId from URL
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
@@ -107,27 +114,37 @@ export function CustomerPortal() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReschedule = useCallback((appointmentId: string) => {
|
||||||
|
// Look up the full appointment from Dashboard's displayed data
|
||||||
|
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||||
|
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
|
||||||
|
setShowReschedule(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isReadOnly = session?.status === "active";
|
const isReadOnly = session?.status === "active";
|
||||||
|
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
|
const sessionId = session?.id ?? null;
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
|
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} />;
|
||||||
case "appointments":
|
case "appointments":
|
||||||
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
|
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
case "pets":
|
case "pets":
|
||||||
return <PetProfiles readOnly={!!isReadOnly} />;
|
return <PetProfiles readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
case "reports":
|
case "reports":
|
||||||
return <ReportCards />;
|
return <ReportCards />;
|
||||||
case "billing":
|
case "billing":
|
||||||
return <BillingPayments readOnly={!!isReadOnly} />;
|
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
case "messages":
|
case "messages":
|
||||||
return <Communication readOnly={!!isReadOnly} />;
|
return <Communication readOnly={!!isReadOnly} />;
|
||||||
case "settings":
|
case "settings":
|
||||||
return <AccountSettings readOnly={!!isReadOnly} />;
|
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen bg-[#faf8f5] font-sans"
|
className="min-h-screen bg-[#faf8f5] font-sans"
|
||||||
@@ -158,6 +175,15 @@ export function CustomerPortal() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showReschedule && rescheduleAppointment && (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
<RescheduleFlow
|
||||||
|
appointment={rescheduleAppointment as any}
|
||||||
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
|
sessionId={session?.id ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile Header */}
|
{/* Mobile Header */}
|
||||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
|
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
|
||||||
<button
|
<button
|
||||||
@@ -171,7 +197,7 @@ export function CustomerPortal() {
|
|||||||
</button>
|
</button>
|
||||||
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||||
SM
|
{avatarInitials}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -258,9 +284,9 @@ export function CustomerPortal() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-stone-600">Hi, {CUSTOMER.name.split(" ")[0]}</span>
|
<span className="text-sm text-stone-600">Hi, {clientName.split(" ")[0] || "Guest"}</span>
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||||
SM
|
{avatarInitials}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||||
import { CUSTOMER, PETS, SIGNED_AGREEMENTS } from "../mockData.js";
|
import { PetForm } from "./PetForm.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountSettings({ readOnly }: Props) {
|
interface PersonalInfoData {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PetData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
species?: string;
|
||||||
|
breed?: string;
|
||||||
|
weight?: number;
|
||||||
|
photo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSettings({ sessionId, readOnly }: Props) {
|
||||||
const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal");
|
const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,21 +50,65 @@ export function AccountSettings({ readOnly }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "personal" && <PersonalInfo readOnly={readOnly} />}
|
{tab === "personal" && <PersonalInfo sessionId={sessionId} readOnly={readOnly} />}
|
||||||
{tab === "password" && <PasswordChange readOnly={readOnly} />}
|
{tab === "password" && <PasswordChange readOnly={readOnly} />}
|
||||||
{tab === "pets" && <ManagePets readOnly={readOnly} />}
|
{tab === "pets" && <ManagePets sessionId={sessionId} readOnly={readOnly} />}
|
||||||
{tab === "agreements" && <Agreements />}
|
{tab === "agreements" && <Agreements />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PersonalInfo({ readOnly }: { readOnly: boolean }) {
|
function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: CUSTOMER.name,
|
name: "",
|
||||||
email: CUSTOMER.email,
|
email: "",
|
||||||
phone: CUSTOMER.phone,
|
phone: "",
|
||||||
address: CUSTOMER.address,
|
address: "",
|
||||||
});
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPersonalInfo = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch("/api/portal/me");
|
||||||
|
if (response.ok) {
|
||||||
|
const data: PersonalInfoData = await response.json();
|
||||||
|
setForm({
|
||||||
|
name: [data.firstName, data.lastName].filter(Boolean).join(" ") || "",
|
||||||
|
email: data.email || "",
|
||||||
|
phone: data.phone || "",
|
||||||
|
address: data.address || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError("Failed to load personal info");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load personal info");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPersonalInfo();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
|
<p className="text-sm text-stone-500">Loading personal info...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
@@ -111,10 +174,67 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManagePets({ readOnly }: { readOnly: boolean }) {
|
function ManagePets({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||||
|
const [pets, setPets] = useState<PetData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPets = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch("/api/portal/pets");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setPets(Array.isArray(data) ? data : []);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load pets");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load pets");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPets();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? undefined : undefined;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
|
<p className="text-sm text-stone-500">Loading pets...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingPet || showAddForm) {
|
||||||
|
return (
|
||||||
|
<PetForm
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
pet={(editingPet ?? undefined) as any}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||||
|
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{PETS.map(pet => (
|
{pets.map(pet => (
|
||||||
<div key={pet.id} className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm flex items-center gap-4">
|
<div key={pet.id} className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm flex items-center gap-4">
|
||||||
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-3xl">
|
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-3xl">
|
||||||
{pet.photo}
|
{pet.photo}
|
||||||
@@ -125,7 +245,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50">
|
<button
|
||||||
|
onClick={() => setEditingPetId(pet.id)}
|
||||||
|
className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50"
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button className="p-1.5 border border-stone-200 rounded-lg text-stone-400 hover:text-amber-600 hover:border-amber-200">
|
<button className="p-1.5 border border-stone-200 rounded-lg text-stone-400 hover:text-amber-600 hover:border-amber-200">
|
||||||
@@ -136,7 +259,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors">
|
<button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors"
|
||||||
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add New Pet
|
Add New Pet
|
||||||
</button>
|
</button>
|
||||||
@@ -147,31 +273,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
|||||||
|
|
||||||
function Agreements() {
|
function Agreements() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<p className="text-sm text-stone-500">
|
||||||
<table className="w-full text-sm">
|
No agreements found. There is currently no agreements table in the database.
|
||||||
<thead>
|
</p>
|
||||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
|
||||||
<th className="px-5 py-3 font-medium">Document</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Date Signed</th>
|
|
||||||
<th className="px-5 py-3 font-medium"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{SIGNED_AGREEMENTS.map(agr => (
|
|
||||||
<tr key={agr.id} className="border-b border-stone-50">
|
|
||||||
<td className="px-5 py-3 font-medium text-stone-800">{agr.name}</td>
|
|
||||||
<td className="px-5 py-3 text-stone-600">
|
|
||||||
{new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<button className="text-sm text-(--color-accent-dark) font-medium hover:underline">View</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,129 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { CreditCard, Download, DollarSign, Package, Zap, Plus, Trash2 } from "lucide-react";
|
import { CreditCard, Download, DollarSign, Package, Zap } from "lucide-react";
|
||||||
import { INVOICES, SAVED_PAYMENT_METHODS, PREPAID_PACKAGES, Invoice } from "../mockData.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
status: "pending" | "paid" | "failed" | "refunded";
|
||||||
|
totalCents: number;
|
||||||
|
date: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentMethod {
|
||||||
|
brand: string;
|
||||||
|
last4: string;
|
||||||
|
expiryMonth: number;
|
||||||
|
expiryYear: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Package {
|
||||||
|
name: string;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillingPaymentsProps {
|
||||||
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
paid: "bg-green-100 text-green-700",
|
paid: "bg-green-100 text-green-700",
|
||||||
outstanding: "bg-amber-100 text-amber-700",
|
pending: "bg-yellow-100 text-yellow-700",
|
||||||
overdue: "bg-red-100 text-red-700",
|
failed: "bg-red-100 text-red-700",
|
||||||
|
refunded: "bg-gray-100 text-gray-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BillingPayments({ readOnly }: Props) {
|
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||||
|
const [packages, setPackages] = useState<Package[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||||
const [autopay, setAutopay] = useState(false);
|
const [autopay, setAutopay] = useState(false);
|
||||||
const [showTipModal, setShowTipModal] = useState(false);
|
|
||||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
|
|
||||||
const outstanding = INVOICES.filter(i => i.status === "outstanding");
|
useEffect(() => {
|
||||||
const totalOutstanding = outstanding.reduce((sum, i) => sum + i.amount, 0);
|
async function fetchData() {
|
||||||
|
if (!sessionId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/portal/invoices", {
|
||||||
|
headers: {
|
||||||
|
"x-session-id": sessionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch invoices");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setInvoices(data.invoices || []);
|
||||||
|
setPaymentMethods(data.paymentMethods || []);
|
||||||
|
setPackages(data.packages || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const formatCents = (cents: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(cents / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = invoices.filter((i) => i.status === "pending");
|
||||||
|
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
<div className="h-24 bg-gray-200 rounded"></div>
|
||||||
|
<div className="h-24 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-red-600">Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Outstanding Balance Banner */}
|
{/* Outstanding Balance Banner */}
|
||||||
{totalOutstanding > 0 && (
|
{totalPending > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-stone-500">Outstanding Balance</p>
|
<p className="text-sm text-stone-500">Outstanding Balance</p>
|
||||||
<p className="text-3xl font-bold text-stone-800">${totalOutstanding.toFixed(2)}</p>
|
<p className="text-3xl font-bold text-stone-800">{formatCents(totalPending)}</p>
|
||||||
<p className="text-xs text-stone-400 mt-0.5">{outstanding.length} unpaid invoice{outstanding.length > 1 ? "s" : ""}</p>
|
<p className="text-xs text-stone-400 mt-0.5">
|
||||||
|
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="flex gap-2">
|
<button
|
||||||
<button
|
onClick={() => setShowPaymentModal(true)}
|
||||||
onClick={() => setShowTipModal(true)}
|
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||||
className="px-4 py-2 border border-stone-200 rounded-lg text-sm font-medium text-stone-600 hover:bg-stone-50"
|
>
|
||||||
>
|
Pay Now
|
||||||
Add Tip
|
</button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPaymentModal(true)}
|
|
||||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
|
||||||
>
|
|
||||||
Pay Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -61,7 +139,9 @@ export function BillingPayments({ readOnly }: Props) {
|
|||||||
key={id}
|
key={id}
|
||||||
onClick={() => setTab(id)}
|
onClick={() => setTab(id)}
|
||||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||||
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
tab === id
|
||||||
|
? "bg-(--color-accent-light) text-(--color-accent-dark)"
|
||||||
|
: "text-stone-500 hover:bg-stone-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon size={14} />
|
<Icon size={14} />
|
||||||
@@ -78,23 +158,35 @@ export function BillingPayments({ readOnly }: Props) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
||||||
<th className="px-5 py-3 font-medium">Date</th>
|
<th className="px-5 py-3 font-medium">Date</th>
|
||||||
<th className="px-5 py-3 font-medium">Items</th>
|
<th className="px-5 py-3 font-medium">Description</th>
|
||||||
<th className="px-5 py-3 font-medium">Amount</th>
|
<th className="px-5 py-3 font-medium">Amount</th>
|
||||||
<th className="px-5 py-3 font-medium">Status</th>
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
<th className="px-5 py-3 font-medium"></th>
|
<th className="px-5 py-3 font-medium"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{INVOICES.map(inv => (
|
{invoices.map((inv) => (
|
||||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
||||||
<td className="px-5 py-3 text-stone-700">
|
<td className="px-5 py-3 text-stone-700">
|
||||||
{new Date(inv.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-stone-600">
|
||||||
|
{inv.description || `Invoice ${inv.id.slice(0, 8)}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 font-medium text-stone-800">
|
||||||
|
{formatCents(inv.totalCents)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-stone-600">{inv.items.join(", ")}</td>
|
|
||||||
<td className="px-5 py-3 font-medium text-stone-800">${inv.amount.toFixed(2)}</td>
|
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[inv.status]}`}>
|
<span
|
||||||
{inv.status}
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
STATUS_STYLES[inv.status] || "bg-gray-100 text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{inv.status.charAt(0).toUpperCase() + inv.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
@@ -113,37 +205,33 @@ export function BillingPayments({ readOnly }: Props) {
|
|||||||
{/* Payment Methods */}
|
{/* Payment Methods */}
|
||||||
{tab === "payment" && (
|
{tab === "payment" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm space-y-3">
|
{paymentMethods.length === 0 ? (
|
||||||
{SAVED_PAYMENT_METHODS.map(pm => (
|
<p className="text-gray-500 italic">No payment methods on file</p>
|
||||||
<div key={pm.id} className="flex items-center justify-between py-2 border-b border-stone-50 last:border-0">
|
) : (
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-stone-100 flex items-center justify-center">
|
{paymentMethods.map((method) => (
|
||||||
<CreditCard size={18} className="text-stone-500" />
|
<div
|
||||||
|
key={`${method.brand}-${method.last4}`}
|
||||||
|
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-6 bg-gray-200 rounded flex items-center justify-center text-xs">
|
||||||
|
{method.brand.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-stone-700">**** {method.last4}</span>
|
||||||
|
<span className="text-stone-500">
|
||||||
|
{method.expiryMonth}/{method.expiryYear}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-stone-800 capitalize">{pm.type} •••• {pm.last4}</p>
|
|
||||||
<p className="text-xs text-stone-400">Expires {pm.expiry}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{pm.isDefault && (
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Default</span>
|
|
||||||
)}
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="p-1 text-stone-400 hover:text-red-500">
|
<button className="text-sm text-blue-600 hover:underline">
|
||||||
<Trash2 size={14} />
|
Remove
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
{!readOnly && (
|
)}
|
||||||
<button className="flex items-center gap-2 text-sm text-(--color-accent-dark) font-medium hover:underline mt-2">
|
|
||||||
<Plus size={14} />
|
|
||||||
Add Payment Method
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Autopay */}
|
{/* Autopay */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
@@ -154,18 +242,28 @@ export function BillingPayments({ readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
||||||
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
<p className="text-xs text-stone-500">
|
||||||
|
Automatically charge after each appointment
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAutopay(!autopay)}
|
onClick={() => setAutopay(!autopay)}
|
||||||
className={`w-12 h-6 rounded-full transition-colors ${autopay ? "bg-(--color-accent)" : "bg-stone-300"}`}
|
className={`w-12 h-6 rounded-full transition-colors ${
|
||||||
|
autopay ? "bg-(--color-accent)" : "bg-stone-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${autopay ? "translate-x-6" : "translate-x-0.5"}`} />
|
<div
|
||||||
|
className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||||
|
autopay ? "translate-x-6" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-stone-400">{autopay ? "Enabled" : "Disabled"}</span>
|
<span className="text-xs text-stone-400">
|
||||||
|
{autopay ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,40 +273,29 @@ export function BillingPayments({ readOnly }: Props) {
|
|||||||
{/* Packages */}
|
{/* Packages */}
|
||||||
{tab === "packages" && (
|
{tab === "packages" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{PREPAID_PACKAGES.map(pkg => (
|
{packages.length === 0 ? (
|
||||||
<div key={pkg.id} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<p className="text-gray-500 italic">No packages purchased</p>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
) : (
|
||||||
<Package size={20} className="text-(--color-accent)" />
|
packages.map((pkg, index) => (
|
||||||
<h3 className="font-medium text-stone-800">{pkg.name}</h3>
|
<div
|
||||||
</div>
|
key={index}
|
||||||
<div className="flex items-center gap-4 mb-3">
|
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
|
||||||
<div>
|
>
|
||||||
<p className="text-2xl font-bold text-stone-800">{pkg.totalCredits - pkg.usedCredits}</p>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-stone-500">remaining of {pkg.totalCredits}</p>
|
<span className="font-medium text-stone-800">{pkg.name}</span>
|
||||||
</div>
|
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
||||||
<div className="flex-1 bg-stone-100 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-(--color-accent) h-full rounded-full"
|
|
||||||
style={{ width: `${((pkg.totalCredits - pkg.usedCredits) / pkg.totalCredits) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-stone-400">Expires {new Date(pkg.expiresAt).toLocaleDateString()}</p>
|
))
|
||||||
</div>
|
)}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tip Modal */}
|
|
||||||
{showTipModal && !readOnly && (
|
|
||||||
<TipModal onClose={() => setShowTipModal(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Payment Modal */}
|
{/* Payment Modal */}
|
||||||
{showPaymentModal && !readOnly && (
|
{showPaymentModal && !readOnly && (
|
||||||
<PaymentModal
|
<PaymentModal
|
||||||
outstanding={outstanding}
|
pending={pending}
|
||||||
totalOutstanding={totalOutstanding}
|
totalPending={totalPending}
|
||||||
onClose={() => setShowPaymentModal(false)}
|
onClose={() => setShowPaymentModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -216,11 +303,27 @@ export function BillingPayments({ readOnly }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClose }: { outstanding: Invoice[]; totalOutstanding: number; onClose: () => void }) {
|
function PaymentModal({
|
||||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(outstanding.map(i => i.id)));
|
pending,
|
||||||
|
totalPending: _totalPending,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
pending: Invoice[];
|
||||||
|
totalPending: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
|
||||||
|
new Set(pending.map((i) => i.id))
|
||||||
|
);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
|
|
||||||
|
const formatCents = (cents: number) =>
|
||||||
|
new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(cents / 100);
|
||||||
|
|
||||||
const toggleInvoice = (id: string) => {
|
const toggleInvoice = (id: string) => {
|
||||||
const next = new Set(selectedInvoices);
|
const next = new Set(selectedInvoices);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
@@ -233,26 +336,45 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
|||||||
|
|
||||||
const handlePay = async () => {
|
const handlePay = async () => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
// Simulate payment processing
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
setIsComplete(true);
|
setIsComplete(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedTotal = outstanding.filter(i => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.amount, 0);
|
const selectedTotal = pending
|
||||||
|
.filter((i) => selectedInvoices.has(i.id))
|
||||||
|
.reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
||||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
className="w-8 h-8 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-semibold text-stone-800 text-lg mb-2">Payment Successful</h2>
|
<h2 className="font-semibold text-stone-800 text-lg mb-2">
|
||||||
<p className="text-stone-500 text-sm mb-6">Your payment of ${selectedTotal.toFixed(2)} has been processed. A receipt has been sent to your email.</p>
|
Payment Successful
|
||||||
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
</h2>
|
||||||
|
<p className="text-stone-500 text-sm mb-6">
|
||||||
|
Your payment of {formatCents(selectedTotal)} has been processed. A
|
||||||
|
receipt has been sent to your email.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,10 +386,25 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
|||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
<h2 className="font-semibold text-stone-800 text-lg">
|
||||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
Pay Outstanding Balance
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</h2>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-stone-400 hover:text-stone-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,11 +412,13 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
|||||||
<p className="text-sm text-stone-500 mb-4">Select invoices to pay:</p>
|
<p className="text-sm text-stone-500 mb-4">Select invoices to pay:</p>
|
||||||
|
|
||||||
<div className="space-y-3 mb-6">
|
<div className="space-y-3 mb-6">
|
||||||
{outstanding.map(inv => (
|
{pending.map((inv) => (
|
||||||
<label
|
<label
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
|
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
selectedInvoices.has(inv.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
|
selectedInvoices.has(inv.id)
|
||||||
|
? "border-(--color-accent) bg-(--color-accent-lighter)"
|
||||||
|
: "border-stone-200 hover:border-stone-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -290,11 +429,17 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
|||||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-800">{inv.items.join(", ")}</p>
|
<p className="text-sm font-medium text-stone-800">
|
||||||
<p className="text-xs text-stone-500">{new Date(inv.date).toLocaleDateString()}</p>
|
{inv.description || `Invoice ${inv.id.slice(0, 8)}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-stone-500">
|
||||||
|
{new Date(inv.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-stone-800">${inv.amount.toFixed(2)}</span>
|
<span className="text-sm font-medium text-stone-800">
|
||||||
|
{formatCents(inv.totalCents)}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +447,9 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
|||||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-stone-600">Total</span>
|
<span className="text-sm text-stone-600">Total</span>
|
||||||
<span className="text-lg font-bold text-stone-800">${selectedTotal.toFixed(2)}</span>
|
<span className="text-lg font-bold text-stone-800">
|
||||||
|
{formatCents(selectedTotal)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -326,50 +473,4 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TipModal({ onClose }: { onClose: () => void }) {
|
export default BillingPayments;
|
||||||
const [tipPercent, setTipPercent] = useState<number | null>(20);
|
|
||||||
const [customTip, setCustomTip] = useState("");
|
|
||||||
const presets = [15, 20, 25];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full p-6">
|
|
||||||
<h2 className="font-semibold text-stone-800 mb-4">Add a Tip</h2>
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
{presets.map(pct => (
|
|
||||||
<button
|
|
||||||
key={pct}
|
|
||||||
onClick={() => { setTipPercent(pct); setCustomTip(""); }}
|
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
|
|
||||||
tipPercent === pct ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pct}%
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => { setTipPercent(null); }}
|
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
|
|
||||||
tipPercent === null ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Custom
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{tipPercent === null && (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Enter amount"
|
|
||||||
value={customTip}
|
|
||||||
onChange={e => setCustomTip(e.target.value)}
|
|
||||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-4"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={onClose} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Cancel</button>
|
|
||||||
<button onClick={onClose} className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">Add Tip</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
|
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
|
||||||
import { MESSAGES, BUSINESS_NAME } from "../mockData.js";
|
|
||||||
import type { Message } from "../mockData.js";
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
sender: "customer" | "business";
|
||||||
|
senderName: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationCategory {
|
||||||
|
email: boolean;
|
||||||
|
sms: boolean;
|
||||||
|
push: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationPreferences {
|
||||||
|
appointmentReminders: NotificationCategory;
|
||||||
|
vaccinationAlerts: NotificationCategory;
|
||||||
|
promotional: NotificationCategory;
|
||||||
|
reportCards: NotificationCategory;
|
||||||
|
invoiceReceipts: NotificationCategory;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
@@ -39,15 +60,31 @@ export function Communication({ readOnly }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MessageThread({ readOnly }: { readOnly: boolean }) {
|
function MessageThread({ readOnly }: { readOnly: boolean }) {
|
||||||
const [messages, setMessages] = useState<Message[]>(MESSAGES);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [newMessage, setNewMessage] = useState("");
|
const [newMessage, setNewMessage] = useState("");
|
||||||
|
const [businessName, setBusinessName] = useState<string>("Business");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchBranding() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/branding");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setBusinessName(data.businessName || data.name || "Business");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setBusinessName("Business");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBranding();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!newMessage.trim() || readOnly) return;
|
if (!newMessage.trim() || readOnly) return;
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
id: `m-${Date.now()}`,
|
id: `m-${Date.now()}`,
|
||||||
sender: "customer",
|
sender: "customer",
|
||||||
senderName: "Sarah",
|
senderName: "You",
|
||||||
text: newMessage.trim(),
|
text: newMessage.trim(),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
read: false,
|
read: false,
|
||||||
@@ -59,32 +96,36 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
|
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
|
||||||
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
|
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
|
||||||
<p className="text-sm font-medium text-stone-800">{BUSINESS_NAME}</p>
|
<p className="text-sm font-medium text-stone-800">{businessName}</p>
|
||||||
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
|
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
{messages.map(msg => (
|
{messages.length === 0 ? (
|
||||||
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
|
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
|
||||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
) : (
|
||||||
msg.sender === "customer"
|
messages.map(msg => (
|
||||||
? "bg-(--color-accent) text-white rounded-br-md"
|
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
|
||||||
: "bg-stone-100 text-stone-800 rounded-bl-md"
|
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
||||||
}`}>
|
msg.sender === "customer"
|
||||||
<p className="text-sm">{msg.text}</p>
|
? "bg-(--color-accent) text-white rounded-br-md"
|
||||||
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
|
: "bg-stone-100 text-stone-800 rounded-bl-md"
|
||||||
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
}`}>
|
||||||
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
<p className="text-sm">{msg.text}</p>
|
||||||
</span>
|
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
|
||||||
{msg.sender === "customer" && (
|
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
||||||
msg.read
|
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||||
? <CheckCheck size={12} className="text-white/60" />
|
</span>
|
||||||
: <Check size={12} className="text-white/60" />
|
{msg.sender === "customer" && (
|
||||||
)}
|
msg.read
|
||||||
|
? <CheckCheck size={12} className="text-white/60" />
|
||||||
|
: <Check size={12} className="text-white/60" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -111,7 +152,7 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
||||||
const [prefs, setPrefs] = useState({
|
const [prefs, setPrefs] = useState<NotificationPreferences>({
|
||||||
appointmentReminders: { email: true, sms: true, push: true },
|
appointmentReminders: { email: true, sms: true, push: true },
|
||||||
vaccinationAlerts: { email: true, sms: false, push: true },
|
vaccinationAlerts: { email: true, sms: false, push: true },
|
||||||
promotional: { email: false, sms: false, push: false },
|
promotional: { email: false, sms: false, push: false },
|
||||||
@@ -119,7 +160,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
|||||||
invoiceReceipts: { email: true, sms: false, push: false },
|
invoiceReceipts: { email: true, sms: false, push: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
type PrefKey = keyof typeof prefs;
|
type PrefKey = keyof NotificationPreferences;
|
||||||
type ChannelKey = "email" | "sms" | "push";
|
type ChannelKey = "email" | "sms" | "push";
|
||||||
|
|
||||||
const toggle = (category: PrefKey, channel: ChannelKey) => {
|
const toggle = (category: PrefKey, channel: ChannelKey) => {
|
||||||
@@ -194,3 +235,5 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Communication;
|
||||||
|
|||||||
@@ -1,9 +1,53 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
||||||
import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSINESS_NAME } from "../mockData.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface DashboardProps {
|
||||||
|
sessionId: string | null;
|
||||||
|
clientName: string;
|
||||||
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
onReschedule: (appointmentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Appointment {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
petName: string;
|
||||||
|
serviceName: string;
|
||||||
|
status: string;
|
||||||
|
staffName?: string;
|
||||||
|
services?: string[];
|
||||||
|
addOns?: string[];
|
||||||
|
groomerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
species: string;
|
||||||
|
breed?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
weight?: number;
|
||||||
|
healthAlerts: string[];
|
||||||
|
photo?: string;
|
||||||
|
vaccinations?: { name: string; status: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
date: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
dueDate?: string;
|
||||||
|
items: { description: string; price: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Branding {
|
||||||
|
clinicName: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
primaryColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function daysUntil(dateStr: string): number {
|
function daysUntil(dateStr: string): number {
|
||||||
@@ -15,27 +59,154 @@ function daysUntil(dateStr: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard({ onNavigate, readOnly }: Props) {
|
export function Dashboard({
|
||||||
const nextAppt = UPCOMING_APPOINTMENTS[0];
|
sessionId,
|
||||||
const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0);
|
clientName,
|
||||||
const recentEvents = [
|
onNavigate,
|
||||||
...PAST_APPOINTMENTS.slice(0, 3).map(a => ({
|
readOnly,
|
||||||
id: a.id, date: a.date, text: `${a.petName} — ${a.services.join(", ")}`, type: "appointment" as const,
|
onReschedule,
|
||||||
})),
|
}: DashboardProps) {
|
||||||
...INVOICES.filter(i => i.status === "paid").slice(0, 2).map(i => ({
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
id: i.id, date: i.date, text: `Invoice paid — $${i.amount}`, type: "payment" as const,
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
})),
|
const [pendingInvoices, setPendingInvoices] = useState<Invoice[]>([]);
|
||||||
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
|
const [branding, setBranding] = useState<Branding | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = {
|
||||||
|
"x-session-id": sessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [appointmentsRes, petsRes, invoicesRes, brandingRes] = await Promise.all([
|
||||||
|
fetch("/api/portal/appointments", { headers }),
|
||||||
|
fetch("/api/portal/pets", { headers }),
|
||||||
|
fetch("/api/portal/invoices", { headers }),
|
||||||
|
fetch("/api/branding", { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!appointmentsRes.ok || !petsRes.ok || !invoicesRes.ok || !brandingRes.ok) {
|
||||||
|
throw new Error("Failed to fetch dashboard data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointmentsData = await appointmentsRes.json();
|
||||||
|
const petsData = await petsRes.json();
|
||||||
|
const invoicesData = await invoicesRes.json();
|
||||||
|
const brandingData = await brandingRes.json();
|
||||||
|
|
||||||
|
setAppointments(appointmentsData.appointments || []);
|
||||||
|
setPets(petsData.pets || []);
|
||||||
|
|
||||||
|
// Filter for pending invoices only (not "outstanding")
|
||||||
|
const pending = (invoicesData.invoices || []).filter(
|
||||||
|
(invoice: Invoice) => invoice.status === "pending"
|
||||||
|
);
|
||||||
|
setPendingInvoices(pending);
|
||||||
|
|
||||||
|
setBranding(brandingData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const getUpcomingAppointments = (): Appointment[] => {
|
||||||
|
const now = new Date();
|
||||||
|
return appointments
|
||||||
|
.filter((apt) => new Date(`${apt.date}T${apt.time}`) >= now)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(`${a.date}T${a.time}`).getTime() -
|
||||||
|
new Date(`${b.date}T${b.time}`).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPetHealthAlerts = (): { petName: string; alert: string }[] => {
|
||||||
|
return pets
|
||||||
|
.filter((pet) => pet.healthAlerts && pet.healthAlerts.length > 0)
|
||||||
|
.flatMap((pet) =>
|
||||||
|
pet.healthAlerts.map((alert) => ({ petName: pet.name, alert }))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number): string => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPendingBalance = (): number => {
|
||||||
|
return pendingInvoices.reduce((sum, invoice) => sum + invoice.amount, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-(--color-accent)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-2xl p-5">
|
||||||
|
<p className="text-red-700">Error: {error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-stone-100 rounded-2xl p-5 text-center">
|
||||||
|
<p className="text-stone-600">Please sign in to view your dashboard.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingAppointments = getUpcomingAppointments();
|
||||||
|
const healthAlerts = getPetHealthAlerts();
|
||||||
|
const pendingBalance = getPendingBalance();
|
||||||
|
const nextAppt = upcomingAppointments[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Welcome */}
|
{/* Welcome */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold text-stone-800">Welcome back, Sarah</h2>
|
<h2 className="text-2xl font-semibold text-stone-800">
|
||||||
<p className="text-stone-500 text-sm mt-1">Here's what's happening at {BUSINESS_NAME}</p>
|
Welcome back, {clientName}
|
||||||
|
</h2>
|
||||||
|
<p className="text-stone-500 text-sm mt-1">
|
||||||
|
Here's what's happening at {branding?.clinicName || "your clinic"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Appointment */}
|
{/* Next Appointment */}
|
||||||
@@ -53,11 +224,16 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-lg font-semibold text-stone-800">
|
<p className="text-lg font-semibold text-stone-800">
|
||||||
{nextAppt.petName} with {nextAppt.groomerName}
|
{nextAppt.petName}
|
||||||
|
{nextAppt.groomerName && ` with ${nextAppt.groomerName}`}
|
||||||
|
{nextAppt.staffName && ` with ${nextAppt.staffName}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-stone-600 text-sm mt-1">
|
<p className="text-stone-600 text-sm mt-1">
|
||||||
{nextAppt.services.join(", ")}
|
{nextAppt.services?.join(", ") ||
|
||||||
{nextAppt.addOns.length > 0 && ` + ${nextAppt.addOns.join(", ")}`}
|
nextAppt.serviceName ||
|
||||||
|
"Appointment"}
|
||||||
|
{nextAppt.addOns && nextAppt.addOns.length > 0 &&
|
||||||
|
` + ${nextAppt.addOns.join(", ")}`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
|
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -71,13 +247,18 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center sm:text-right">
|
<div className="text-center sm:text-right">
|
||||||
<div className="text-3xl font-bold text-(--color-accent-dark)">{daysUntil(nextAppt.date)}</div>
|
<div className="text-3xl font-bold text-(--color-accent-dark)">
|
||||||
|
{daysUntil(nextAppt.date)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-stone-500">days away</div>
|
<div className="text-xs text-stone-500">days away</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
<button
|
||||||
|
onClick={() => onReschedule(nextAppt.id)}
|
||||||
|
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
|
||||||
|
>
|
||||||
Reschedule
|
Reschedule
|
||||||
</button>
|
</button>
|
||||||
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||||
@@ -94,8 +275,8 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
|||||||
{/* Pet Cards & Loyalty */}
|
{/* Pet Cards & Loyalty */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{/* Pet Cards */}
|
{/* Pet Cards */}
|
||||||
{PETS.map(pet => {
|
{pets.map((pet) => {
|
||||||
const expiringVax = pet.vaccinations.filter(v => v.status !== "valid");
|
const petAlerts = pet.healthAlerts || [];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={pet.id}
|
key={pet.id}
|
||||||
@@ -104,59 +285,63 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
|
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
|
||||||
{pet.photo}
|
{pet.photo || pet.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-stone-800">{pet.name}</p>
|
<p className="font-semibold text-stone-800">{pet.name}</p>
|
||||||
<p className="text-xs text-stone-500">{pet.breed} · {pet.weight} lbs</p>
|
<p className="text-xs text-stone-500">
|
||||||
|
{pet.breed || pet.species}
|
||||||
|
{pet.weight && ` · ${pet.weight} lbs`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expiringVax.length > 0 ? (
|
{petAlerts.length > 0 ? (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-lg">
|
<div className="flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-lg">
|
||||||
<AlertTriangle size={12} />
|
<AlertTriangle size={12} />
|
||||||
{expiringVax.map(v => v.name).join(", ")} {expiringVax[0]?.status === "expired" ? "expired" : "expiring soon"}
|
{petAlerts.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-lg">
|
<div className="flex items-center gap-1.5 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-lg">
|
||||||
<PawPrint size={12} />
|
<PawPrint size={12} />
|
||||||
All vaccinations current
|
All health records current
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Loyalty Card */}
|
{/* Loyalty Card Placeholder */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark) mb-3">
|
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark) mb-3">
|
||||||
<Star size={16} />
|
<Star size={16} />
|
||||||
Loyalty Rewards
|
Loyalty Rewards
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-stone-800">{LOYALTY.points} <span className="text-sm font-normal text-stone-500">pts</span></p>
|
<div className="flex flex-col items-center justify-center py-4">
|
||||||
<div className="mt-2 bg-stone-100 rounded-full h-2 overflow-hidden">
|
<div className="w-16 h-16 rounded-full bg-(--color-accent-light) flex items-center justify-center mb-3">
|
||||||
<div
|
<Star size={32} className="text-(--color-accent)" />
|
||||||
className="bg-(--color-accent) h-full rounded-full transition-all"
|
</div>
|
||||||
style={{ width: `${(LOYALTY.points / LOYALTY.nextRewardAt) * 100}%` }}
|
<p className="text-lg font-bold text-stone-800">Coming Soon</p>
|
||||||
/>
|
<p className="text-xs text-stone-500 text-center mt-1">
|
||||||
|
Earn points with every visit and redeem for exclusive rewards
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-stone-500 mt-1">
|
|
||||||
{LOYALTY.nextRewardAt - LOYALTY.points} pts to {LOYALTY.rewardName}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outstanding Balance & Recent Activity */}
|
{/* Pending Balance & Recent Activity */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Outstanding Balance */}
|
{/* Pending Invoices */}
|
||||||
{outstanding > 0 && (
|
{pendingInvoices.length > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
|
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
|
||||||
<CreditCard size={16} />
|
<CreditCard size={16} />
|
||||||
Outstanding Balance
|
Pending Invoices
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-stone-800">${outstanding.toFixed(2)}</p>
|
<p className="text-2xl font-bold text-stone-800">
|
||||||
|
{formatCurrency(pendingBalance)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -167,29 +352,51 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingInvoices.slice(0, 3).map((invoice) => (
|
||||||
|
<div
|
||||||
|
key={invoice.id}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-stone-600">
|
||||||
|
{invoice.invoiceNumber} - {formatCurrency(invoice.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-stone-400">
|
||||||
|
Due {invoice.dueDate ? formatDate(invoice.dueDate) : formatDate(invoice.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Health Alerts */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
{healthAlerts.length > 0 && (
|
||||||
<h3 className="text-sm font-medium text-stone-500 mb-3">Recent Activity</h3>
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="space-y-2.5">
|
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-3">
|
||||||
{recentEvents.map(evt => (
|
<AlertTriangle size={16} />
|
||||||
<div key={evt.id} className="flex items-center gap-3 text-sm">
|
Health Alerts
|
||||||
<div className={`w-2 h-2 rounded-full shrink-0 ${evt.type === "payment" ? "bg-green-400" : "bg-(--color-accent)"}`} />
|
</div>
|
||||||
<span className="text-stone-600 flex-1">{evt.text}</span>
|
<div className="space-y-2">
|
||||||
<span className="text-xs text-stone-400">{formatDate(evt.date)}</span>
|
{healthAlerts.slice(0, 5).map((item, index) => (
|
||||||
</div>
|
<div key={index} className="flex items-center gap-3 text-sm">
|
||||||
))}
|
<div className="w-2 h-2 rounded-full shrink-0 bg-amber-400" />
|
||||||
|
<span className="text-stone-600 flex-1">
|
||||||
|
<span className="font-medium">{item.petName}:</span>{" "}
|
||||||
|
{item.alert}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate("pets")}
|
||||||
|
className="flex items-center gap-1 text-sm text-(--color-accent-dark) font-medium mt-3 hover:text-(--color-accent)"
|
||||||
|
>
|
||||||
|
View all <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={() => onNavigate("appointments")}
|
|
||||||
className="flex items-center gap-1 text-sm text-(--color-accent-dark) font-medium mt-3 hover:text-(--color-accent)"
|
|
||||||
>
|
|
||||||
View all <ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { X, Save } from "lucide-react";
|
||||||
|
import type { Pet } from "../mockData.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pet?: Pet;
|
||||||
|
onSave: (pet: Pet) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PetForm({ pet, onSave, onCancel }: Props) {
|
||||||
|
const [name, setName] = useState(pet?.name ?? "");
|
||||||
|
const [breed, setBreed] = useState(pet?.breed ?? "");
|
||||||
|
const [weight, setWeight] = useState(pet?.weight ?? 0);
|
||||||
|
const [notes, setNotes] = useState(pet?.allergies ?? "");
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!pet) return;
|
||||||
|
onSave({ ...pet, name, breed, weight, allergies: notes });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-6 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-stone-800">{pet ? "Edit Pet" : "Add Pet"}</h2>
|
||||||
|
<button onClick={onCancel} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||||
|
<X size={16} className="text-stone-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-stone-600 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-stone-600 mb-1">Breed</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={breed}
|
||||||
|
onChange={e => setBreed(e.target.value)}
|
||||||
|
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-stone-600 mb-1">Weight (lbs)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={weight}
|
||||||
|
onChange={e => setWeight(Number(e.target.value))}
|
||||||
|
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-stone-600 mb-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,152 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { PawPrint, Heart, Scissors, Syringe, AlertTriangle, CheckCircle, Clock, Upload, Edit3 } from "lucide-react";
|
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2 } from "lucide-react";
|
||||||
import { PETS, PAST_APPOINTMENTS } from "../mockData.js";
|
import { PetForm } from "./PetForm.js";
|
||||||
import type { Pet } from "../mockData.js";
|
|
||||||
|
interface Pet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
breed: string;
|
||||||
|
weight: number;
|
||||||
|
birthDate: string;
|
||||||
|
photoUrl: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Appointment {
|
||||||
|
id: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string;
|
||||||
|
confirmationStatus: string | null;
|
||||||
|
customerNotes: string | null;
|
||||||
|
groomerNotes: string | null;
|
||||||
|
reportCardId: string | null;
|
||||||
|
pet: { id: string; name: string; photo: string | null } | null;
|
||||||
|
service: { id: string } | null;
|
||||||
|
staff: { id: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppointmentsResponse {
|
||||||
|
upcoming: Appointment[];
|
||||||
|
past: Appointment[];
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type VaxStatus = "valid" | "expiring" | "expired";
|
function buildHeaders(sessionId: string | null): Record<string, string> {
|
||||||
const VAX_STATUS_STYLES: Record<VaxStatus, { bg: string; text: string; icon: typeof CheckCircle }> = {
|
const headers: Record<string, string> = {};
|
||||||
valid: { bg: "bg-green-100", text: "text-green-700", icon: CheckCircle },
|
if (sessionId) {
|
||||||
expiring: { bg: "bg-amber-100", text: "text-amber-700", icon: Clock },
|
headers["X-Impersonation-Session-Id"] = sessionId;
|
||||||
expired: { bg: "bg-red-100", text: "text-red-700", icon: AlertTriangle },
|
}
|
||||||
};
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
export function PetProfiles({ readOnly }: Props) {
|
export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||||
const [selectedPetId, setSelectedPetId] = useState<string>(PETS[0]?.id ?? "");
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "vaccinations" | "history">("info");
|
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
|
||||||
|
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
||||||
|
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
||||||
|
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const pet = PETS.find(p => p.id === selectedPetId)!;
|
useEffect(() => {
|
||||||
const petHistory = PAST_APPOINTMENTS.filter(a => a.petId === selectedPetId);
|
async function fetchData() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [petsRes, apptsRes] = await Promise.all([
|
||||||
|
fetch("/api/portal/pets", { headers: buildHeaders(sessionId) }),
|
||||||
|
fetch("/api/portal/appointments", { headers: buildHeaders(sessionId) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!petsRes.ok) {
|
||||||
|
throw new Error("Failed to load pets");
|
||||||
|
}
|
||||||
|
if (!apptsRes.ok) {
|
||||||
|
throw new Error("Failed to load appointments");
|
||||||
|
}
|
||||||
|
|
||||||
|
const petsData = await petsRes.json();
|
||||||
|
const apptsData: AppointmentsResponse = await apptsRes.json();
|
||||||
|
|
||||||
|
setPets(petsData);
|
||||||
|
setAppointments(apptsData);
|
||||||
|
|
||||||
|
if (petsData.length > 0 && !selectedPetId) {
|
||||||
|
setSelectedPetId(petsData[0].id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
||||||
|
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
|
||||||
|
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
||||||
|
|
||||||
|
function handlePetSave(updatedPet: Pet) {
|
||||||
|
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
|
||||||
|
setEditingPetId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingPet) {
|
||||||
|
return (
|
||||||
|
<PetForm
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
pet={editingPet as any}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onSave={handlePetSave as any}
|
||||||
|
onCancel={() => setEditingPetId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 size={24} className="animate-spin text-stone-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-500 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-stone-400 text-sm">No pets found</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Pet Selector */}
|
{/* Pet Selector */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||||
{PETS.map(p => (
|
{pets.map(p => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
|
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors shrink-0 ${
|
||||||
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
|
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-2xl">{p.photo}</span>
|
<span className="text-2xl">{p.photoUrl ? "🐾" : "🐾"}</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
|
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
|
||||||
<p className="text-xs text-stone-500">{p.breed}</p>
|
<p className="text-xs text-stone-500">{p.breed}</p>
|
||||||
@@ -43,23 +156,31 @@ export function PetProfiles({ readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile Header */}
|
{/* Profile Header */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
{selectedPet && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl">
|
<div className="flex items-center gap-4">
|
||||||
{pet.photo}
|
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl overflow-hidden">
|
||||||
|
{selectedPet.photoUrl ? (
|
||||||
|
<img src={selectedPet.photoUrl} alt={selectedPet.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span>🐾</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
|
||||||
|
<p className="text-stone-500 text-sm">{selectedPet.breed} · {selectedPet.weight} lbs</p>
|
||||||
|
<p className="text-stone-400 text-xs mt-0.5">
|
||||||
|
Born {selectedPet.birthDate ? new Date(selectedPet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
|
<button onClick={() => setEditingPetId(selectedPet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||||
|
<Edit3 size={16} className="text-stone-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<h2 className="text-xl font-semibold text-stone-800">{pet.name}</h2>
|
|
||||||
<p className="text-stone-500 text-sm">{pet.breed} · {pet.weight} lbs · {pet.sex === "male" ? "♂" : "♀"} {pet.spayedNeutered ? "(spayed/neutered)" : ""}</p>
|
|
||||||
<p className="text-stone-400 text-xs mt-0.5">Born {new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</p>
|
|
||||||
</div>
|
|
||||||
{!readOnly && (
|
|
||||||
<button className="p-2 hover:bg-stone-50 rounded-lg">
|
|
||||||
<Edit3 size={16} className="text-stone-400" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
||||||
@@ -67,7 +188,6 @@ export function PetProfiles({ readOnly }: Props) {
|
|||||||
{ id: "info", label: "Basic Info", icon: PawPrint },
|
{ id: "info", label: "Basic Info", icon: PawPrint },
|
||||||
{ id: "medical", label: "Medical", icon: Heart },
|
{ id: "medical", label: "Medical", icon: Heart },
|
||||||
{ id: "grooming", label: "Grooming", icon: Scissors },
|
{ id: "grooming", label: "Grooming", icon: Scissors },
|
||||||
{ id: "vaccinations", label: "Vaccinations", icon: Syringe },
|
|
||||||
{ id: "history", label: "History", icon: Clock },
|
{ id: "history", label: "History", icon: Clock },
|
||||||
] as const).map(({ id, label, icon: Icon }) => (
|
] as const).map(({ id, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
@@ -85,10 +205,9 @@ export function PetProfiles({ readOnly }: Props) {
|
|||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
{activeTab === "info" && <BasicInfoTab pet={pet} readOnly={readOnly} />}
|
{activeTab === "info" && selectedPet && <BasicInfoTab pet={selectedPet} readOnly={readOnly} />}
|
||||||
{activeTab === "medical" && <MedicalTab pet={pet} readOnly={readOnly} />}
|
{activeTab === "medical" && selectedPet && <MedicalTab pet={selectedPet} readOnly={readOnly} />}
|
||||||
{activeTab === "grooming" && <GroomingTab pet={pet} readOnly={readOnly} />}
|
{activeTab === "grooming" && selectedPet && <GroomingTab pet={selectedPet} readOnly={readOnly} />}
|
||||||
{activeTab === "vaccinations" && <VaccinationsTab pet={pet} readOnly={readOnly} />}
|
|
||||||
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
|
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,11 +227,10 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InfoRow label="Name" value={pet.name} />
|
<InfoRow label="Name" value={pet.name} />
|
||||||
<InfoRow label="Breed" value={pet.breed} />
|
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
||||||
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
|
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
|
||||||
<InfoRow label="Date of Birth" value={new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} />
|
<InfoRow label="Date of Birth" value={pet.birthDate ? new Date(pet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
|
||||||
<InfoRow label="Sex" value={pet.sex === "male" ? "Male" : "Female"} />
|
<InfoRow label="Notes" value={pet.notes || "None"} />
|
||||||
<InfoRow label="Spayed/Neutered" value={pet.spayedNeutered ? "Yes" : "No"} />
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||||
Upload Photo
|
Upload Photo
|
||||||
@@ -125,12 +243,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InfoRow label="Allergies" value={pet.allergies} />
|
<InfoRow label="Notes" value={pet.notes || "No medical notes on file"} />
|
||||||
<InfoRow label="Skin Conditions" value={pet.skinConditions} />
|
|
||||||
<InfoRow label="Anxiety Triggers" value={pet.anxietyTriggers} />
|
|
||||||
<InfoRow label="Aggression Notes" value={pet.aggressionNotes} />
|
|
||||||
<InfoRow label="Mobility Issues" value={pet.mobilityIssues} />
|
|
||||||
<InfoRow label="Medications" value={pet.medications} />
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<p className="mt-3 text-xs text-stone-400">
|
<p className="mt-3 text-xs text-stone-400">
|
||||||
Changes to medical notes will be flagged for staff review.
|
Changes to medical notes will be flagged for staff review.
|
||||||
@@ -143,10 +256,7 @@ function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InfoRow label="Preferred Cut" value={pet.preferredCut} />
|
<InfoRow label="Notes" value={pet.notes || "No grooming notes on file"} />
|
||||||
<InfoRow label="Shampoo Preference" value={pet.shampooPreference} />
|
|
||||||
<InfoRow label="Sensitive Areas" value={pet.sensitiveAreas} />
|
|
||||||
<InfoRow label="Standing Instructions" value={pet.standingInstructions} />
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||||
Upload Reference Photo
|
Upload Reference Photo
|
||||||
@@ -156,58 +266,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
|
||||||
<th className="pb-2 font-medium">Vaccine</th>
|
|
||||||
<th className="pb-2 font-medium">Administered</th>
|
|
||||||
<th className="pb-2 font-medium">Expires</th>
|
|
||||||
<th className="pb-2 font-medium">Status</th>
|
|
||||||
<th className="pb-2 font-medium">Proof</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{pet.vaccinations.map(vax => {
|
|
||||||
const style = VAX_STATUS_STYLES[vax.status];
|
|
||||||
const StatusIcon = style.icon;
|
|
||||||
return (
|
|
||||||
<tr key={vax.name} className="border-b border-stone-50">
|
|
||||||
<td className="py-2.5 font-medium text-stone-800">{vax.name}</td>
|
|
||||||
<td className="py-2.5 text-stone-600">{new Date(vax.lastAdministered).toLocaleDateString()}</td>
|
|
||||||
<td className="py-2.5 text-stone-600">{new Date(vax.expirationDate).toLocaleDateString()}</td>
|
|
||||||
<td className="py-2.5">
|
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}>
|
|
||||||
<StatusIcon size={12} />
|
|
||||||
{vax.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5">
|
|
||||||
{vax.documentUploaded ? (
|
|
||||||
<span className="text-green-600 text-xs">Uploaded</span>
|
|
||||||
) : !readOnly ? (
|
|
||||||
<button className="flex items-center gap-1 text-xs text-(--color-accent-dark) hover:underline">
|
|
||||||
<Upload size={12} />
|
|
||||||
Upload
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-stone-400 text-xs">Missing</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{petHistory.length === 0 ? (
|
{petHistory.length === 0 ? (
|
||||||
@@ -219,14 +278,18 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
|
|||||||
<Scissors size={14} />
|
<Scissors size={14} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-stone-800">{appt.services.join(", ")}</p>
|
<p className="text-sm font-medium text-stone-800">
|
||||||
<p className="text-xs text-stone-500">with {appt.groomerName} · ${appt.price}</p>
|
{appt.service ? "Grooming Service" : "Appointment"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-stone-500">
|
||||||
|
with {appt.staff?.name || "Unknown Groomer"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-stone-400">
|
<span className="text-xs text-stone-400">
|
||||||
{new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
{new Date(appt.startTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
{appt.reportCardId && (
|
{appt.reportCardId && (
|
||||||
<span className="text-xs text-(--color-accent-dark) font-medium">Report →</span>
|
<span className="text-xs text-(--color-accent-dark) font-medium">Report</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FileText, Share2, Calendar, Smile, Meh, AlertCircle, ChevronRight } from "lucide-react";
|
import { FileText, Share2, Calendar, Smile, Meh, ChevronRight, Loader2 } from "lucide-react";
|
||||||
import { REPORT_CARDS } from "../mockData.js";
|
|
||||||
import type { ReportCard } from "../mockData.js";
|
|
||||||
|
|
||||||
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
|
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
|
||||||
|
|
||||||
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
|
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
|
||||||
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
|
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
|
||||||
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
|
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
|
||||||
@@ -11,8 +10,87 @@ const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: s
|
|||||||
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
|
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Appointment {
|
||||||
|
id: string;
|
||||||
|
petId: string;
|
||||||
|
serviceId: string;
|
||||||
|
groomerId: string | null;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
status: string;
|
||||||
|
petName?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
groomerName?: string;
|
||||||
|
reportCardId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function ReportCards() {
|
export function ReportCards() {
|
||||||
const [selectedCard, setSelectedCard] = useState<ReportCard | null>(null);
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedCard, setSelectedCard] = useState<Appointment | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchReportCards = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/portal/appointments");
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const allAppointments: Appointment[] = data.appointments || data || [];
|
||||||
|
const reportCardAppointments = allAppointments.filter(
|
||||||
|
(appt) => appt.reportCardId
|
||||||
|
);
|
||||||
|
setAppointments(reportCardAppointments);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load report cards.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load report cards. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchReportCards();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="animate-spin text-stone-400" size={24} />
|
||||||
|
<span className="ml-3 text-stone-500">Loading report cards...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-600 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-md hover:bg-stone-200"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appointments.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-100 flex items-center justify-center">
|
||||||
|
<FileText size={24} className="text-stone-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-stone-800 mb-1">No Report Cards Yet</h3>
|
||||||
|
<p className="text-sm text-stone-500">
|
||||||
|
Report cards from your grooming visits will appear here after your appointments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedCard) {
|
if (selectedCard) {
|
||||||
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
|
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
|
||||||
@@ -23,8 +101,9 @@ export function ReportCards() {
|
|||||||
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
|
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{REPORT_CARDS.map(card => {
|
{appointments.map((card) => {
|
||||||
const mood = MOOD_CONFIG[card.behaviorMood];
|
const moodKey: MoodKey = "cooperative";
|
||||||
|
const mood = MOOD_CONFIG[moodKey];
|
||||||
const MoodIcon = mood.icon;
|
const MoodIcon = mood.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -38,16 +117,20 @@ export function ReportCards() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-stone-800">{card.petName}'s Report Card</h3>
|
<h3 className="font-semibold text-stone-800">{card.petName || "Pet"}'s Report Card</h3>
|
||||||
<ChevronRight size={16} className="text-stone-400" />
|
<ChevronRight size={16} className="text-stone-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-stone-500 mt-0.5">
|
<p className="text-sm text-stone-500 mt-0.5">
|
||||||
{card.servicesPerformed.join(", ")} with {card.groomerName}
|
{card.serviceName || "Grooming"} with {card.groomerName || "your groomer"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<span className="flex items-center gap-1 text-xs text-stone-400">
|
<span className="flex items-center gap-1 text-xs text-stone-400">
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{new Date(card.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
{new Date(card.date).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
|
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
|
||||||
<MoodIcon size={12} />
|
<MoodIcon size={12} />
|
||||||
@@ -64,28 +147,40 @@ export function ReportCards() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => void }) {
|
function ReportCardDetail({ card, onBack }: { card: Appointment; onBack: () => void }) {
|
||||||
const mood = MOOD_CONFIG[card.behaviorMood];
|
const moodKey: MoodKey = "cooperative";
|
||||||
|
const mood = MOOD_CONFIG[moodKey];
|
||||||
const MoodIcon = mood.icon;
|
const MoodIcon = mood.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<button onClick={onBack} className="text-sm text-(--color-accent-dark) font-medium hover:underline">
|
<button
|
||||||
← Back to Report Cards
|
onClick={onBack}
|
||||||
|
className="text-sm text-(--color-accent-dark) font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Back to Report Cards
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
|
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<h2 className="text-xl font-semibold text-stone-800">{card.petName}'s Grooming Report</h2>
|
<h2 className="text-xl font-semibold text-stone-800">
|
||||||
|
{card.petName || "Pet"}'s Grooming Report
|
||||||
|
</h2>
|
||||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
|
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
|
||||||
<Share2 size={14} />
|
<Share2 size={14} />
|
||||||
Share
|
Share
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-stone-600">
|
<p className="text-sm text-stone-600">
|
||||||
{new Date(card.date).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })} · Groomer: {card.groomerName}
|
{new Date(card.date).toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
{card.groomerName ? ` · Groomer: ${card.groomerName}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,14 +194,14 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
|||||||
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
|
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
|
||||||
Photo placeholder
|
Photo placeholder
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-stone-600">{card.beforeDescription}</p>
|
<p className="text-sm text-stone-600">Before photo description not available.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
|
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
|
||||||
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
|
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
|
||||||
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
|
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
|
||||||
Photo placeholder
|
Photo placeholder
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-stone-700">{card.afterDescription}</p>
|
<p className="text-sm text-stone-700">After photo description not available.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,11 +210,9 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
|
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{card.servicesPerformed.map(s => (
|
<span className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
|
||||||
<span key={s} className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
|
{card.serviceName || "Grooming"}
|
||||||
{s}
|
</span>
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,36 +225,32 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Condition Observations */}
|
|
||||||
{card.conditionObservations.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-stone-800 mb-2">Condition Observations</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{card.conditionObservations.map((obs, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-2 text-sm">
|
|
||||||
<AlertCircle size={16} className="text-amber-500 mt-0.5 shrink-0" />
|
|
||||||
<span className="text-stone-700">{obs}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Groomer's Note */}
|
{/* Groomer's Note */}
|
||||||
<div className="bg-(--color-accent-lighter) rounded-xl p-4">
|
<div className="bg-(--color-accent-lighter) rounded-xl p-4">
|
||||||
<h3 className="font-medium text-stone-800 mb-2">A Note from {card.groomerName}</h3>
|
<h3 className="font-medium text-stone-800 mb-2">
|
||||||
<p className="text-sm text-stone-700 italic leading-relaxed">"{card.groomerNote}"</p>
|
A Note from {card.groomerName || "Your Groomer"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-stone-700 italic leading-relaxed">
|
||||||
|
"Report card details are not yet available. Please check back after your visit."
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Appointment CTA */}
|
{/* Next Appointment CTA */}
|
||||||
<div className="bg-white border border-stone-200 rounded-xl p-4 flex items-center justify-between">
|
<div className="bg-white border border-stone-200 rounded-xl p-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-800">Next recommended visit</p>
|
<p className="text-sm font-medium text-stone-800">Book your next visit</p>
|
||||||
<p className="text-xs text-stone-500">
|
<p className="text-xs text-stone-500">Schedule your next grooming appointment</p>
|
||||||
{new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Pre-select the service from report card (serviceId/serviceName) once BookPage supports service pre-selection via URL param
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (card.petName) params.set("petName", card.petName);
|
||||||
|
if (card.serviceName) params.set("serviceName", card.serviceName);
|
||||||
|
window.location.href = `/admin/book${params.size > 0 ? `?${params.toString()}` : ""}`;
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||||
|
>
|
||||||
Rebook Now
|
Rebook Now
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.idea/
|
||||||
|
*.tmproj
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: groombook
|
||||||
|
description: Open source pet grooming business management & CRM
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "2026.03.19-ea54506"
|
||||||
|
home: https://groombook.github.io
|
||||||
|
sources:
|
||||||
|
- https://github.com/groombook/groombook
|
||||||
|
maintainers:
|
||||||
|
- name: GroomBook
|
||||||
|
url: https://github.com/groombook
|
||||||
|
keywords:
|
||||||
|
- groombook
|
||||||
|
- pet-grooming
|
||||||
|
- scheduling
|
||||||
|
- crm
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create chart name and version as used by the chart label.
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Common labels
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "groombook.chart" . }}
|
||||||
|
{{ include "groombook.selectorLabels" . }}
|
||||||
|
{{- if .Chart.AppVersion }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "groombook.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Component labels (extends common labels with component name)
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.componentLabels" -}}
|
||||||
|
{{ include "groombook.labels" . }}
|
||||||
|
app.kubernetes.io/component: {{ .component }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Component selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.componentSelectorLabels" -}}
|
||||||
|
{{ include "groombook.selectorLabels" . }}
|
||||||
|
app.kubernetes.io/component: {{ .component }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Service account name
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
{{- default (include "groombook.fullname" .) .Values.serviceAccount.name }}
|
||||||
|
{{- else }}
|
||||||
|
{{- default "default" .Values.serviceAccount.name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
API image reference
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.apiImage" -}}
|
||||||
|
{{- printf "%s:%s" .Values.api.image.repository (default .Chart.AppVersion .Values.api.image.tag) }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Web image reference
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.webImage" -}}
|
||||||
|
{{- printf "%s:%s" .Values.web.image.repository (default .Chart.AppVersion .Values.web.image.tag) }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Migrate image reference
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.migrateImage" -}}
|
||||||
|
{{- printf "%s:%s" .Values.migrate.image.repository (default .Chart.AppVersion .Values.migrate.image.tag) }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Database URL — differs by postgresql.mode
|
||||||
|
Integrated: construct from chart-managed PostgreSQL credentials
|
||||||
|
Operator: read from credentialsSecret
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.databaseSecretName" -}}
|
||||||
|
{{- if eq .Values.postgresql.mode "operator" }}
|
||||||
|
{{- required "postgresql.operator.credentialsSecret is required in operator mode" .Values.postgresql.operator.credentialsSecret }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "groombook.fullname" . }}-db-credentials
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Database URL secret key
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.databaseSecretKey" -}}
|
||||||
|
{{- if eq .Values.postgresql.mode "operator" -}}
|
||||||
|
uri
|
||||||
|
{{- else -}}
|
||||||
|
database-url
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-api
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: api
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.api.replicas }}
|
||||||
|
progressDeadlineSeconds: 300
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: api
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: api
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
serviceAccountName: {{ include "groombook.serviceAccountName" . }}
|
||||||
|
containers:
|
||||||
|
- name: api
|
||||||
|
image: {{ include "groombook.apiImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
name: http
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: PORT
|
||||||
|
value: {{ .Values.api.env.port | quote }}
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: {{ .Values.api.env.nodeEnv | quote }}
|
||||||
|
- name: AUTH_DISABLED
|
||||||
|
value: {{ .Values.api.env.authDisabled | quote }}
|
||||||
|
{{- if .Values.api.env.corsOrigin }}
|
||||||
|
- name: CORS_ORIGIN
|
||||||
|
value: {{ .Values.api.env.corsOrigin | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.api.env.oidcIssuer }}
|
||||||
|
- name: OIDC_ISSUER
|
||||||
|
value: {{ .Values.api.env.oidcIssuer | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.api.env.oidcAudience }}
|
||||||
|
- name: OIDC_AUDIENCE
|
||||||
|
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.databaseSecretName" . }}
|
||||||
|
key: {{ include "groombook.databaseSecretKey" . }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.api.resources | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 30
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-api
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: api
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.api.service.type }}
|
||||||
|
selector:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: api
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.api.service.port }}
|
||||||
|
targetPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{- if eq .Values.postgresql.mode "operator" }}
|
||||||
|
apiVersion: postgresql.cnpg.io/v1
|
||||||
|
kind: Cluster
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-postgres
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
instances: {{ .Values.postgresql.operator.instances }}
|
||||||
|
storage:
|
||||||
|
size: {{ .Values.postgresql.operator.storage.size }}
|
||||||
|
{{- if .Values.postgresql.operator.storage.storageClass }}
|
||||||
|
storageClass: {{ .Values.postgresql.operator.storage.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
bootstrap:
|
||||||
|
initdb:
|
||||||
|
database: {{ .Values.postgresql.operator.bootstrap.database }}
|
||||||
|
owner: {{ .Values.postgresql.operator.bootstrap.owner }}
|
||||||
|
{{- if .Values.postgresql.operator.credentialsSecret }}
|
||||||
|
secret:
|
||||||
|
name: {{ .Values.postgresql.operator.credentialsSecret }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.postgresql.operator.monitoring.enabled }}
|
||||||
|
monitoring:
|
||||||
|
enablePodMonitor: true
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{- if eq .Values.postgresql.mode "integrated" }}
|
||||||
|
{{- $password := default (randAlphaNum 16) .Values.postgresql.integrated.auth.password }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-db-credentials
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
postgresql-password: {{ $password | quote }}
|
||||||
|
database-url: {{ printf "postgres://%s:%s@%s-postgresql:5432/%s" .Values.postgresql.integrated.auth.username $password (include "groombook.fullname" .) .Values.postgresql.integrated.auth.database | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{- if and .Values.dragonfly.enabled (eq .Values.dragonfly.mode "operator") }}
|
||||||
|
apiVersion: dragonflydb.io/v1alpha1
|
||||||
|
kind: Dragonfly
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-dragonfly
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.dragonfly.operator.replicas }}
|
||||||
|
{{- with .Values.dragonfly.operator.resources }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{- if and .Values.dragonfly.enabled (eq .Values.dragonfly.mode "integrated") }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-dragonfly
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: dragonfly
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.dragonfly.integrated.service.type }}
|
||||||
|
selector:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: dragonfly
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.dragonfly.integrated.service.port }}
|
||||||
|
targetPort: 6379
|
||||||
|
protocol: TCP
|
||||||
|
name: redis
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{{- if and .Values.dragonfly.enabled (eq .Values.dragonfly.mode "integrated") }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-dragonfly
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: dragonfly
|
||||||
|
spec:
|
||||||
|
serviceName: {{ include "groombook.fullname" . }}-dragonfly
|
||||||
|
replicas: {{ .Values.dragonfly.integrated.replicas }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: dragonfly
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: dragonfly
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: dragonfly
|
||||||
|
image: {{ printf "%s:%s" .Values.dragonfly.integrated.image.repository .Values.dragonfly.integrated.image.tag }}
|
||||||
|
imagePullPolicy: {{ .Values.dragonfly.integrated.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 6379
|
||||||
|
name: redis
|
||||||
|
protocol: TCP
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.dragonfly.integrated.resources | nindent 12 }}
|
||||||
|
{{- if .Values.dragonfly.integrated.storage.size }}
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
{{- if .Values.dragonfly.integrated.storage.storageClass }}
|
||||||
|
storageClassName: {{ .Values.dragonfly.integrated.storage.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.dragonfly.integrated.storage.size }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
{{- if eq .service "api" }}
|
||||||
|
name: {{ include "groombook.fullname" $ }}-api
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.api.service.port }}
|
||||||
|
{{- else }}
|
||||||
|
name: {{ include "groombook.fullname" $ }}-web
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.web.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{{- if .Values.migrate.enabled }}
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-migrate
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: migrate
|
||||||
|
annotations:
|
||||||
|
"helm.sh/hook": pre-install,pre-upgrade
|
||||||
|
"helm.sh/hook-weight": "1"
|
||||||
|
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||||
|
spec:
|
||||||
|
backoffLimit: {{ .Values.migrate.backoffLimit }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: migrate
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
restartPolicy: OnFailure
|
||||||
|
containers:
|
||||||
|
- name: migrate
|
||||||
|
image: {{ include "groombook.migrateImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.migrate.image.pullPolicy }}
|
||||||
|
command: ["pnpm", "db:migrate"]
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.databaseSecretName" . }}
|
||||||
|
key: {{ include "groombook.databaseSecretKey" . }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{- if eq .Values.postgresql.mode "integrated" }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-postgresql
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgresql
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgresql
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
protocol: TCP
|
||||||
|
name: postgresql
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{{- if eq .Values.postgresql.mode "integrated" }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-postgresql
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgresql
|
||||||
|
spec:
|
||||||
|
serviceName: {{ include "groombook.fullname" . }}-postgresql
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: postgresql
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: postgresql
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgresql
|
||||||
|
image: {{ .Values.postgresql.integrated.image }}
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
name: postgresql
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: {{ .Values.postgresql.integrated.auth.database | quote }}
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: {{ .Values.postgresql.integrated.auth.username | quote }}
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.fullname" . }}-db-credentials
|
||||||
|
key: postgresql-password
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- pg_isready
|
||||||
|
- -U
|
||||||
|
- {{ .Values.postgresql.integrated.auth.username | quote }}
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- pg_isready
|
||||||
|
- -U
|
||||||
|
- {{ .Values.postgresql.integrated.auth.username | quote }}
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
{{- if .Values.postgresql.integrated.storage.storageClass }}
|
||||||
|
storageClassName: {{ .Values.postgresql.integrated.storage.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.postgresql.integrated.storage.size }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.serviceAccountName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-web
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: web
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.web.replicas }}
|
||||||
|
progressDeadlineSeconds: 300
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: web
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: web
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
serviceAccountName: {{ include "groombook.serviceAccountName" . }}
|
||||||
|
containers:
|
||||||
|
- name: web
|
||||||
|
image: {{ include "groombook.webImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.web.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
name: http
|
||||||
|
protocol: TCP
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.web.resources | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "groombook.fullname" . }}-web
|
||||||
|
labels:
|
||||||
|
{{- include "groombook.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: web
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.web.service.type }}
|
||||||
|
selector:
|
||||||
|
{{- include "groombook.selectorLabels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: web
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.web.service.port }}
|
||||||
|
targetPort: 80
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# -- API deployment
|
||||||
|
api:
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/groombook/api
|
||||||
|
tag: "" # defaults to chart appVersion
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
replicas: 1
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
env:
|
||||||
|
nodeEnv: production
|
||||||
|
authDisabled: false
|
||||||
|
corsOrigin: ""
|
||||||
|
oidcIssuer: ""
|
||||||
|
oidcAudience: groombook
|
||||||
|
port: "3000"
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 3000
|
||||||
|
|
||||||
|
# -- Web deployment (nginx)
|
||||||
|
web:
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/groombook/web
|
||||||
|
tag: "" # defaults to chart appVersion
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
replicas: 1
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 128Mi
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
# -- Database migration job (runs as pre-install/pre-upgrade hook)
|
||||||
|
migrate:
|
||||||
|
enabled: true
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/groombook/api
|
||||||
|
tag: "" # same image as api
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
backoffLimit: 3
|
||||||
|
|
||||||
|
# -- PostgreSQL configuration
|
||||||
|
postgresql:
|
||||||
|
# Choose deployment mode: 'integrated' deploys a native PostgreSQL StatefulSet, 'operator' creates a CNPG Cluster CR
|
||||||
|
mode: integrated
|
||||||
|
integrated:
|
||||||
|
image: postgres:16
|
||||||
|
storage:
|
||||||
|
size: 10Gi
|
||||||
|
storageClass: ""
|
||||||
|
auth:
|
||||||
|
database: groombook
|
||||||
|
username: groombook
|
||||||
|
password: "" # auto-generated if empty
|
||||||
|
existingSecret: ""
|
||||||
|
operator:
|
||||||
|
instances: 3
|
||||||
|
storage:
|
||||||
|
size: 10Gi
|
||||||
|
storageClass: ""
|
||||||
|
bootstrap:
|
||||||
|
database: groombook
|
||||||
|
owner: groombook
|
||||||
|
credentialsSecret: "" # must pre-exist with key 'uri'
|
||||||
|
monitoring:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# -- DragonflyDB (cache/pub-sub) — optional, disabled by default
|
||||||
|
dragonfly:
|
||||||
|
enabled: false
|
||||||
|
# Choose deployment mode: 'integrated' deploys a StatefulSet, 'operator' creates a Dragonfly CR
|
||||||
|
mode: integrated
|
||||||
|
integrated:
|
||||||
|
image:
|
||||||
|
repository: docker.dragonflydb.io/dragonflydb/dragonfly
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
replicas: 1
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
storage:
|
||||||
|
size: 1Gi
|
||||||
|
storageClass: ""
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 6379
|
||||||
|
operator:
|
||||||
|
replicas: 1
|
||||||
|
resources: {}
|
||||||
|
|
||||||
|
# -- Ingress configuration
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
annotations: {}
|
||||||
|
hosts:
|
||||||
|
- host: groombook.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
service: web
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
service: api
|
||||||
|
tls: []
|
||||||
|
|
||||||
|
# -- Service account
|
||||||
|
serviceAccount:
|
||||||
|
create: true
|
||||||
|
name: ""
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
# -- Global image pull secrets
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
|
# -- Override chart name
|
||||||
|
nameOverride: ""
|
||||||
|
fullnameOverride: ""
|
||||||
+2
-1
@@ -17,5 +17,6 @@
|
|||||||
"node": ">=20",
|
"node": ">=20",
|
||||||
"pnpm": ">=9"
|
"pnpm": ">=9"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.4"
|
"packageManager": "pnpm@9.15.4",
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Better-Auth required tables for session-based authentication
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
image TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
access_token TEXT,
|
||||||
|
refresh_token TEXT,
|
||||||
|
id_token TEXT,
|
||||||
|
access_token_expires_at TIMESTAMPTZ,
|
||||||
|
refresh_token_expires_at TIMESTAMPTZ,
|
||||||
|
scope TEXT,
|
||||||
|
password TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Link staff records to auth identity
|
||||||
|
ALTER TABLE staff ADD COLUMN user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Backfill staff.user_id for staff records created before Better-Auth integration.
|
||||||
|
-- Staff records that predate this migration have user_id = NULL; the resolveStaffMiddleware
|
||||||
|
-- now falls back to staff.id (dev mode) and oidcSub (production) so these records still work.
|
||||||
|
-- This migration populates user_id for the known demo/dev staff seeded by seed.ts.
|
||||||
|
|
||||||
|
-- Create demo Better-Auth users for seeded staff (these match the ba-user-* IDs used in tests)
|
||||||
|
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES ('ba-user-manager', 'Demo Manager', 'demo-manager@groombook.dev', true, NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Link the demo manager staff record to the Better-Auth user
|
||||||
|
UPDATE staff
|
||||||
|
SET user_id = 'ba-user-manager', updated_at = NOW()
|
||||||
|
WHERE oidc_sub = 'demo-manager-001' AND user_id IS NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,62 @@
|
|||||||
"when": 1742587200000,
|
"when": 1742587200000,
|
||||||
"tag": "0011_impersonation_indexes",
|
"tag": "0011_impersonation_indexes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774080000000,
|
||||||
|
"tag": "0012_pet_photo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774166400000,
|
||||||
|
"tag": "0013_appointment_confirmation",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774252800000,
|
||||||
|
"tag": "0014_customer_notes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774339200000,
|
||||||
|
"tag": "0015_waitlist",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774425600000,
|
||||||
|
"tag": "0016_ical_token",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774512000000,
|
||||||
|
"tag": "0017_better_auth_tables",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774598400000,
|
||||||
|
"tag": "0018_backfill_staff_user_id",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774729055924,
|
||||||
|
"tag": "0019_concerned_sunfire",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -33,5 +33,6 @@
|
|||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.4",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ export function buildStaff(overrides: Partial<StaffRow> = {}): StaffRow {
|
|||||||
name: `Staff Member ${id}`,
|
name: `Staff Member ${id}`,
|
||||||
email: `${id}@groombook.test`,
|
email: `${id}@groombook.test`,
|
||||||
oidcSub: `oidc-${id}`,
|
oidcSub: `oidc-${id}`,
|
||||||
|
userId: null,
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
|
isSuperUser: false,
|
||||||
active: true,
|
active: true,
|
||||||
icalToken: null,
|
icalToken: null,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import postgres from "postgres";
|
|||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
export { and, asc, desc, eq, exists, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
|
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,58 @@ export const clientStatusEnum = pgEnum("client_status", [
|
|||||||
"disabled",
|
"disabled",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const user = pgTable("user", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
emailVerified: boolean("email_verified").notNull().default(false),
|
||||||
|
image: text("image"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const session = pgTable("session", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
ipAddress: text("ip_address"),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const account = pgTable("account", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
accountId: text("account_id").notNull(),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: text("access_token"),
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
idToken: text("id_token"),
|
||||||
|
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||||
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verification = pgTable("verification", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const clients = pgTable("clients", {
|
export const clients = pgTable("clients", {
|
||||||
@@ -104,7 +156,11 @@ export const staff = pgTable("staff", {
|
|||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
// oidcSub links to the Authentik OIDC subject claim
|
// oidcSub links to the Authentik OIDC subject claim
|
||||||
oidcSub: text("oidc_sub").unique(),
|
oidcSub: text("oidc_sub").unique(),
|
||||||
|
// Better-Auth user ID — links staff business record to auth identity
|
||||||
|
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||||
role: staffRoleEnum("role").notNull().default("groomer"),
|
role: staffRoleEnum("role").notNull().default("groomer"),
|
||||||
|
// Super users bypass appointment-booking restrictions and access admin panels
|
||||||
|
isSuperUser: boolean("is_super_user").notNull().default(false),
|
||||||
active: boolean("active").notNull().default(true),
|
active: boolean("active").notNull().default(true),
|
||||||
// Token for iCal calendar feed subscription (no auth required)
|
// Token for iCal calendar feed subscription (no auth required)
|
||||||
icalToken: text("ical_token").unique(),
|
icalToken: text("ical_token").unique(),
|
||||||
|
|||||||
+65
-25
@@ -287,6 +287,7 @@ async function seedKnownUsers() {
|
|||||||
email: "demo-manager@groombook.dev",
|
email: "demo-manager@groombook.dev",
|
||||||
oidcSub: "demo-manager-001",
|
oidcSub: "demo-manager-001",
|
||||||
role: "manager",
|
role: "manager",
|
||||||
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
||||||
@@ -384,35 +385,41 @@ async function seed() {
|
|||||||
// ── Staff ──
|
// ── Staff ──
|
||||||
// Deterministic staff IDs so they can be referenced in scripts/tests
|
// Deterministic staff IDs so they can be referenced in scripts/tests
|
||||||
const managerStaff = [
|
const managerStaff = [
|
||||||
{ id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const },
|
{ id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const receptionistStaff = [
|
const receptionistStaff = [
|
||||||
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const },
|
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const, isSuperUser: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const groomers = [
|
const groomers = [
|
||||||
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const },
|
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||||
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const },
|
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||||
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const },
|
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
|
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
|
||||||
const bathers = [
|
const bathers = [
|
||||||
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const },
|
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||||
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const },
|
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||||
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const },
|
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
|
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
|
||||||
for (const s of allStaff) {
|
for (const s of allStaff) {
|
||||||
await db.insert(schema.staff).values({
|
await db.insert(schema.staff)
|
||||||
id: s.id,
|
.values({
|
||||||
name: s.name,
|
id: s.id,
|
||||||
email: s.email,
|
name: s.name,
|
||||||
role: s.role,
|
email: s.email,
|
||||||
active: true,
|
role: s.role,
|
||||||
});
|
isSuperUser: s.isSuperUser,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.staff.email,
|
||||||
|
set: { name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`);
|
console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`);
|
||||||
|
|
||||||
@@ -421,14 +428,19 @@ async function seed() {
|
|||||||
for (const s of servicesDef) {
|
for (const s of servicesDef) {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
serviceIds.push(id);
|
serviceIds.push(id);
|
||||||
await db.insert(schema.services).values({
|
await db.insert(schema.services)
|
||||||
id,
|
.values({
|
||||||
name: s.name,
|
id,
|
||||||
description: s.desc,
|
name: s.name,
|
||||||
basePriceCents: s.price,
|
description: s.desc,
|
||||||
durationMinutes: s.dur,
|
basePriceCents: s.price,
|
||||||
active: true,
|
durationMinutes: s.dur,
|
||||||
});
|
active: true,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.services.id,
|
||||||
|
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
console.log(`✓ Created ${servicesDef.length} services`);
|
console.log(`✓ Created ${servicesDef.length} services`);
|
||||||
|
|
||||||
@@ -500,8 +512,36 @@ async function seed() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(schema.clients).values(clientBatch);
|
for (const client of clientBatch) {
|
||||||
await db.insert(schema.pets).values(petBatch);
|
await db.insert(schema.clients)
|
||||||
|
.values(client)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.clients.email,
|
||||||
|
set: { name: client.name, phone: client.phone, address: client.address, notes: client.notes, emailOptOut: client.emailOptOut },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pet of petBatch) {
|
||||||
|
await db.insert(schema.pets)
|
||||||
|
.values(pet)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.pets.id,
|
||||||
|
set: {
|
||||||
|
clientId: pet.clientId,
|
||||||
|
name: pet.name,
|
||||||
|
species: pet.species,
|
||||||
|
breed: pet.breed,
|
||||||
|
weightKg: pet.weightKg,
|
||||||
|
dateOfBirth: pet.dateOfBirth,
|
||||||
|
healthAlerts: pet.healthAlerts,
|
||||||
|
groomingNotes: pet.groomingNotes,
|
||||||
|
cutStyle: pet.cutStyle,
|
||||||
|
shampooPreference: pet.shampooPreference,
|
||||||
|
specialCareNotes: pet.specialCareNotes,
|
||||||
|
customFields: pet.customFields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✓ Created 500 clients with ${petRecords.length} pets`);
|
console.log(`✓ Created 500 clients with ${petRecords.length} pets`);
|
||||||
|
|||||||
@@ -17,5 +17,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface Staff {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: "groomer" | "receptionist" | "manager";
|
role: "groomer" | "receptionist" | "manager";
|
||||||
|
isSuperUser: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
Generated
+304
-38
@@ -26,26 +26,23 @@ importers:
|
|||||||
specifier: ^1.13.7
|
specifier: ^1.13.7
|
||||||
version: 1.19.11(hono@4.12.8)
|
version: 1.19.11(hono@4.12.8)
|
||||||
'@hono/zod-validator':
|
'@hono/zod-validator':
|
||||||
specifier: ^0.4.3
|
specifier: ^0.7.6
|
||||||
version: 0.4.3(hono@4.12.8)(zod@3.25.76)
|
version: 0.7.6(hono@4.12.8)(zod@4.3.6)
|
||||||
|
better-auth:
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.6.17
|
specifier: ^4.6.17
|
||||||
version: 4.12.8
|
version: 4.12.8
|
||||||
jose:
|
|
||||||
specifier: ^5.9.6
|
|
||||||
version: 5.10.0
|
|
||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.9.16
|
specifier: ^6.9.16
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
openid-client:
|
|
||||||
specifier: ^6.1.7
|
|
||||||
version: 6.8.2
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.1
|
specifier: ^4.3.6
|
||||||
version: 3.25.76
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.7
|
specifier: ^22.10.7
|
||||||
@@ -89,6 +86,9 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
|
better-auth:
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.577.0
|
specifier: ^0.577.0
|
||||||
version: 0.577.0(react@19.2.4)
|
version: 0.577.0(react@19.2.4)
|
||||||
@@ -155,7 +155,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.4
|
specifier: ^0.38.4
|
||||||
version: 0.38.4(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/react@19.2.14)(kysely@0.28.14)(postgres@3.4.8)(react@19.2.4)
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.5
|
specifier: ^3.4.5
|
||||||
version: 3.4.8
|
version: 3.4.8
|
||||||
@@ -875,6 +875,81 @@ packages:
|
|||||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@better-auth/core@1.5.6':
|
||||||
|
resolution: {integrity: sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
'@cloudflare/workers-types': '>=4'
|
||||||
|
'@opentelemetry/api': ^1.9.0
|
||||||
|
better-call: 1.3.2
|
||||||
|
jose: ^6.1.0
|
||||||
|
kysely: ^0.28.5
|
||||||
|
nanostores: ^1.0.1
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@cloudflare/workers-types':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@better-auth/drizzle-adapter@1.5.6':
|
||||||
|
resolution: {integrity: sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@better-auth/core': 1.5.6
|
||||||
|
'@better-auth/utils': ^0.3.0
|
||||||
|
drizzle-orm: '>=0.41.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
drizzle-orm:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@better-auth/kysely-adapter@1.5.6':
|
||||||
|
resolution: {integrity: sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@better-auth/core': 1.5.6
|
||||||
|
'@better-auth/utils': ^0.3.0
|
||||||
|
kysely: ^0.27.0 || ^0.28.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
kysely:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@better-auth/memory-adapter@1.5.6':
|
||||||
|
resolution: {integrity: sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@better-auth/core': 1.5.6
|
||||||
|
'@better-auth/utils': ^0.3.0
|
||||||
|
|
||||||
|
'@better-auth/mongo-adapter@1.5.6':
|
||||||
|
resolution: {integrity: sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@better-auth/core': 1.5.6
|
||||||
|
'@better-auth/utils': ^0.3.0
|
||||||
|
mongodb: ^6.0.0 || ^7.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
mongodb:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@better-auth/prisma-adapter@1.5.6':
|
||||||
|
resolution: {integrity: sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@better-auth/core': 1.5.6
|
||||||
|
'@better-auth/utils': ^0.3.0
|
||||||
|
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@prisma/client':
|
||||||
|
optional: true
|
||||||
|
prisma:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@better-auth/telemetry@1.5.6':
|
||||||
|
resolution: {integrity: sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@better-auth/core': 1.5.6
|
||||||
|
|
||||||
|
'@better-auth/utils@0.3.1':
|
||||||
|
resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==}
|
||||||
|
|
||||||
|
'@better-fetch/fetch@1.1.21':
|
||||||
|
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
|
||||||
|
|
||||||
'@csstools/color-helpers@5.1.0':
|
'@csstools/color-helpers@5.1.0':
|
||||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1540,11 +1615,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: ^4
|
||||||
|
|
||||||
'@hono/zod-validator@0.4.3':
|
'@hono/zod-validator@0.7.6':
|
||||||
resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==}
|
resolution: {integrity: sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: '>=3.9.0'
|
hono: '>=3.9.0'
|
||||||
zod: ^3.19.1
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
@@ -1593,6 +1668,22 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@noble/ciphers@2.1.1':
|
||||||
|
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
|
'@noble/hashes@2.0.1':
|
||||||
|
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.1':
|
||||||
|
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
'@opentelemetry/semantic-conventions@1.40.0':
|
||||||
|
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@petamoriken/float16@3.9.3':
|
'@petamoriken/float16@3.9.3':
|
||||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||||
|
|
||||||
@@ -2430,6 +2521,76 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
better-auth@1.5.6:
|
||||||
|
resolution: {integrity: sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@lynx-js/react': '*'
|
||||||
|
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
'@sveltejs/kit': ^2.0.0
|
||||||
|
'@tanstack/react-start': ^1.0.0
|
||||||
|
'@tanstack/solid-start': ^1.0.0
|
||||||
|
better-sqlite3: ^12.0.0
|
||||||
|
drizzle-kit: '>=0.31.4'
|
||||||
|
drizzle-orm: '>=0.41.0'
|
||||||
|
mongodb: ^6.0.0 || ^7.0.0
|
||||||
|
mysql2: ^3.0.0
|
||||||
|
next: ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||||
|
pg: ^8.0.0
|
||||||
|
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
solid-js: ^1.0.0
|
||||||
|
svelte: ^4.0.0 || ^5.0.0
|
||||||
|
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||||
|
vue: ^3.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@lynx-js/react':
|
||||||
|
optional: true
|
||||||
|
'@prisma/client':
|
||||||
|
optional: true
|
||||||
|
'@sveltejs/kit':
|
||||||
|
optional: true
|
||||||
|
'@tanstack/react-start':
|
||||||
|
optional: true
|
||||||
|
'@tanstack/solid-start':
|
||||||
|
optional: true
|
||||||
|
better-sqlite3:
|
||||||
|
optional: true
|
||||||
|
drizzle-kit:
|
||||||
|
optional: true
|
||||||
|
drizzle-orm:
|
||||||
|
optional: true
|
||||||
|
mongodb:
|
||||||
|
optional: true
|
||||||
|
mysql2:
|
||||||
|
optional: true
|
||||||
|
next:
|
||||||
|
optional: true
|
||||||
|
pg:
|
||||||
|
optional: true
|
||||||
|
prisma:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
solid-js:
|
||||||
|
optional: true
|
||||||
|
svelte:
|
||||||
|
optional: true
|
||||||
|
vitest:
|
||||||
|
optional: true
|
||||||
|
vue:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
better-call@1.3.2:
|
||||||
|
resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^4.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
bowser@2.14.1:
|
bowser@2.14.1:
|
||||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||||
|
|
||||||
@@ -2629,6 +2790,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
defu@6.1.4:
|
||||||
|
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||||
|
|
||||||
dequal@2.0.3:
|
dequal@2.0.3:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3283,9 +3447,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jose@5.10.0:
|
|
||||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
|
||||||
|
|
||||||
jose@6.2.1:
|
jose@6.2.1:
|
||||||
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
||||||
|
|
||||||
@@ -3346,6 +3507,10 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
kysely@0.28.14:
|
||||||
|
resolution: {integrity: sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
leven@3.1.0:
|
leven@3.1.0:
|
||||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3510,6 +3675,10 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanostores@1.2.0:
|
||||||
|
resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==}
|
||||||
|
engines: {node: ^20.0.0 || >=22.0.0}
|
||||||
|
|
||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
@@ -3527,9 +3696,6 @@ packages:
|
|||||||
nwsapi@2.2.23:
|
nwsapi@2.2.23:
|
||||||
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
||||||
|
|
||||||
oauth4webapi@3.8.5:
|
|
||||||
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
|
|
||||||
|
|
||||||
object-inspect@1.13.4:
|
object-inspect@1.13.4:
|
||||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3542,9 +3708,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
openid-client@6.8.2:
|
|
||||||
resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==}
|
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3777,6 +3940,9 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rou3@0.7.12:
|
||||||
|
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
|
||||||
|
|
||||||
rrweb-cssom@0.8.0:
|
rrweb-cssom@0.8.0:
|
||||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||||
|
|
||||||
@@ -3820,6 +3986,9 @@ packages:
|
|||||||
set-cookie-parser@2.7.2:
|
set-cookie-parser@2.7.2:
|
||||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
|
set-cookie-parser@3.1.0:
|
||||||
|
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4369,8 +4538,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
zod@3.25.76:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
@@ -5524,6 +5693,56 @@ snapshots:
|
|||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2': {}
|
'@bcoe/v8-coverage@1.0.2': {}
|
||||||
|
|
||||||
|
'@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
|
'@opentelemetry/semantic-conventions': 1.40.0
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
better-call: 1.3.2(zod@4.3.6)
|
||||||
|
jose: 6.2.1
|
||||||
|
kysely: 0.28.14
|
||||||
|
nanostores: 1.2.0
|
||||||
|
zod: 4.3.6
|
||||||
|
|
||||||
|
'@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
|
'@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)':
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
optionalDependencies:
|
||||||
|
kysely: 0.28.14
|
||||||
|
|
||||||
|
'@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
|
'@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
|
'@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
|
'@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))':
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
|
||||||
|
'@better-auth/utils@0.3.1': {}
|
||||||
|
|
||||||
|
'@better-fetch/fetch@1.1.21': {}
|
||||||
|
|
||||||
'@csstools/color-helpers@5.1.0': {}
|
'@csstools/color-helpers@5.1.0': {}
|
||||||
|
|
||||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||||
@@ -5897,10 +6116,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.8
|
hono: 4.12.8
|
||||||
|
|
||||||
'@hono/zod-validator@0.4.3(hono@4.12.8)(zod@3.25.76)':
|
'@hono/zod-validator@0.7.6(hono@4.12.8)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.8
|
hono: 4.12.8
|
||||||
zod: 3.25.76
|
zod: 4.3.6
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
@@ -5950,6 +6169,14 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@noble/ciphers@2.1.1': {}
|
||||||
|
|
||||||
|
'@noble/hashes@2.0.1': {}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.1': {}
|
||||||
|
|
||||||
|
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||||
|
|
||||||
'@petamoriken/float16@3.9.3': {}
|
'@petamoriken/float16@3.9.3': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
@@ -6903,6 +7130,42 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.8: {}
|
baseline-browser-mapping@2.10.8: {}
|
||||||
|
|
||||||
|
better-auth@1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)):
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||||
|
'@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||||
|
'@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)
|
||||||
|
'@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||||
|
'@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||||
|
'@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||||
|
'@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
'@noble/ciphers': 2.1.1
|
||||||
|
'@noble/hashes': 2.0.1
|
||||||
|
better-call: 1.3.2(zod@4.3.6)
|
||||||
|
defu: 6.1.4
|
||||||
|
jose: 6.2.1
|
||||||
|
kysely: 0.28.14
|
||||||
|
nanostores: 1.2.0
|
||||||
|
zod: 4.3.6
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
vitest: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@cloudflare/workers-types'
|
||||||
|
- '@opentelemetry/api'
|
||||||
|
|
||||||
|
better-call@1.3.2(zod@4.3.6):
|
||||||
|
dependencies:
|
||||||
|
'@better-auth/utils': 0.3.1
|
||||||
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
rou3: 0.7.12
|
||||||
|
set-cookie-parser: 3.1.0
|
||||||
|
optionalDependencies:
|
||||||
|
zod: 4.3.6
|
||||||
|
|
||||||
bowser@2.14.1: {}
|
bowser@2.14.1: {}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
@@ -7092,6 +7355,8 @@ snapshots:
|
|||||||
has-property-descriptors: 1.0.2
|
has-property-descriptors: 1.0.2
|
||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
|
|
||||||
|
defu@6.1.4: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
@@ -7110,9 +7375,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
drizzle-orm@0.38.4(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4):
|
drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/react@19.2.14)(kysely@0.28.14)(postgres@3.4.8)(react@19.2.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
kysely: 0.28.14
|
||||||
postgres: 3.4.8
|
postgres: 3.4.8
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
@@ -7820,8 +8087,6 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
jose@5.10.0: {}
|
|
||||||
|
|
||||||
jose@6.2.1: {}
|
jose@6.2.1: {}
|
||||||
|
|
||||||
js-tokens@10.0.0: {}
|
js-tokens@10.0.0: {}
|
||||||
@@ -7887,6 +8152,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
kysely@0.28.14: {}
|
||||||
|
|
||||||
leven@3.1.0: {}
|
leven@3.1.0: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
@@ -8015,6 +8282,8 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
|
nanostores@1.2.0: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
node-cron@3.0.3:
|
node-cron@3.0.3:
|
||||||
@@ -8027,8 +8296,6 @@ snapshots:
|
|||||||
|
|
||||||
nwsapi@2.2.23: {}
|
nwsapi@2.2.23: {}
|
||||||
|
|
||||||
oauth4webapi@3.8.5: {}
|
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|
||||||
object-keys@1.1.1: {}
|
object-keys@1.1.1: {}
|
||||||
@@ -8042,11 +8309,6 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
|
|
||||||
openid-client@6.8.2:
|
|
||||||
dependencies:
|
|
||||||
jose: 6.2.1
|
|
||||||
oauth4webapi: 3.8.5
|
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
@@ -8299,6 +8561,8 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
rou3@0.7.12: {}
|
||||||
|
|
||||||
rrweb-cssom@0.8.0: {}
|
rrweb-cssom@0.8.0: {}
|
||||||
|
|
||||||
safe-array-concat@1.1.3:
|
safe-array-concat@1.1.3:
|
||||||
@@ -8340,6 +8604,8 @@ snapshots:
|
|||||||
|
|
||||||
set-cookie-parser@2.7.2: {}
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
|
set-cookie-parser@3.1.0: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -9008,4 +9274,4 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@4.3.6: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user