From 5d77214beabe601549b0010204e5bebd70dcd45f Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Wed, 17 Jun 2026 15:26:57 +0800
Subject: [PATCH 1/7] Harden storefront web for v0.1
---
.env.example | 2 +
CHANGELOG.md | 1 +
LICENSE | 667 ++++++++++++++++++++-
README.md | 19 +
SECURITY.md | 1 +
app/api/storefront/auth/session/route.ts | 12 +
app/api/storefront/orders/[id]/route.ts | 12 +
app/categories/[slug]/page.tsx | 28 +
app/orders/[id]/page.tsx | 48 ++
app/orders/[id]/tracking/page.tsx | 30 +
app/search/page.tsx | 57 +-
app/sitemap.ts | 19 +-
app/stores/[id]/page.tsx | 31 +
components/catalog/category-pills.tsx | 2 +-
components/catalog/product-card.tsx | 4 +-
components/catalog/store-directory.tsx | 3 +-
docs/deployment.md | 36 ++
docs/fleetbase-setup.md | 42 ++
docs/plugins.md | 70 +++
docs/release.md | 38 ++
docs/themes.md | 40 ++
docs/validation.md | 35 ++
features/checkout/checkout-page-client.tsx | 26 +-
features/customer/account-page-client.tsx | 41 +-
features/customer/orders-page-client.tsx | 4 +-
lib/fleetbase/storefront.ts | 4 +
lib/types.ts | 5 +
middleware.ts | 40 ++
plugins/analytics.tsx | 17 +-
plugins/checkout-message.tsx | 24 +
storefront.config.ts | 3 +-
tests/plugins.test.tsx | 24 +-
tests/storefront-flow.test.ts | 61 ++
tests/storefront-routes.test.ts | 114 ++++
34 files changed, 1527 insertions(+), 33 deletions(-)
create mode 100644 app/api/storefront/auth/session/route.ts
create mode 100644 app/api/storefront/orders/[id]/route.ts
create mode 100644 app/categories/[slug]/page.tsx
create mode 100644 app/orders/[id]/page.tsx
create mode 100644 app/orders/[id]/tracking/page.tsx
create mode 100644 app/stores/[id]/page.tsx
create mode 100644 docs/deployment.md
create mode 100644 docs/fleetbase-setup.md
create mode 100644 docs/plugins.md
create mode 100644 docs/release.md
create mode 100644 docs/themes.md
create mode 100644 docs/validation.md
create mode 100644 middleware.ts
create mode 100644 plugins/checkout-message.tsx
create mode 100644 tests/storefront-flow.test.ts
create mode 100644 tests/storefront-routes.test.ts
diff --git a/.env.example b/.env.example
index a00d3e3..d3e3fe6 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,8 @@ NEXT_PUBLIC_DEFAULT_THEME=default
NEXT_PUBLIC_IMAGE_HOSTS=api.fleetbase.io
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
NEXT_PUBLIC_PROMO_BANNER=
+NEXT_PUBLIC_CHECKOUT_MESSAGE=
+STOREFRONT_ANALYTICS_WEBHOOK_URL=
# Optional real-time tracking
NEXT_PUBLIC_SOCKETCLUSTER_HOST=
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72d9c31..e759228 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,3 +6,4 @@
- Added self-hosting documentation and Docker packaging.
- Added health checks, secure cookie defaults, constrained image host configuration, and plugin hook tests.
- Added a stores page, cart quantity updates, SMS sign-in UI, logout route, and example promo banner plugin.
+- Added session awareness, order detail/tracking pages, catalog filters, category/store pages, plugin/theme/deployment docs, and security headers.
diff --git a/LICENSE b/LICENSE
index 920b314..dba13ed 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,20 +1,661 @@
-GNU Affero General Public License v3.0
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
-Copyright (C) 2026 Fleetbase Pte. Ltd.
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
-This project is licensed under the GNU Affero General Public License version 3
-only (AGPL-3.0-only).
+ Preamble
-You may copy, distribute, and modify this program under the terms of the GNU
-Affero General Public License as published by the Free Software Foundation,
-version 3.
+ 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.
-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.
+ 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.
-The full license text is available from the Free Software Foundation:
+ 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.
-https://www.gnu.org/licenses/agpl-3.0.txt
+ 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.
-SPDX-License-Identifier: AGPL-3.0-only
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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
+ .
diff --git a/README.md b/README.md
index 6dea080..3e3419f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,10 @@
# Fleetbase Storefront Web
+[](https://github.com/fleetbase/storefront-web/actions/workflows/ci.yml)
+[](./LICENSE)
+[](https://nextjs.org/)
+[](./Dockerfile)
+
Fleetbase Storefront Web is an open-source, self-hostable commerce frontend powered by the Fleetbase Storefront API. Fleetbase Console remains the back office for catalog, stores, networks, payments, orders, and operations; this project provides the public storefront that customers browse, customize, and checkout through.
Think of it as a themeable storefront layer for businesses that want Shopify-like launch speed while keeping Fleetbase as the headless commerce and logistics system of record.
@@ -61,6 +66,8 @@ Open `http://localhost:3000`.
| `NEXT_PUBLIC_IMAGE_HOSTS` | Recommended | Comma-separated image host allowlist for Next Image. Include your Fleetbase asset/CDN hosts. |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | If using Stripe | Stripe publishable key used by browser checkout confirmation. |
| `NEXT_PUBLIC_PROMO_BANNER` | No | Example plugin banner text rendered above the header. |
+| `NEXT_PUBLIC_CHECKOUT_MESSAGE` | No | Example plugin message rendered in checkout. |
+| `STOREFRONT_ANALYTICS_WEBHOOK_URL` | No | Server-side webhook URL for the analytics plugin. |
## Self Hosting
@@ -86,6 +93,8 @@ Health checks are available at:
The health route validates required server-side settings and reports missing variables without exposing secrets.
+More deployment notes are available in [docs/deployment.md](./docs/deployment.md).
+
## Themes
Theme presets live in `lib/theme/index.ts`. The selected theme comes from `NEXT_PUBLIC_DEFAULT_THEME` and is converted into CSS custom properties on the root layout.
@@ -126,6 +135,16 @@ Current server hooks:
The included `promo-banner` plugin is a small example of a UI slot plugin controlled by environment configuration.
+See [docs/plugins.md](./docs/plugins.md) and [docs/themes.md](./docs/themes.md) for extension and branding guides.
+
+## Fleetbase Setup
+
+Use [docs/fleetbase-setup.md](./docs/fleetbase-setup.md) to connect a Fleetbase Storefront key, prepare products, enable payments, and run the launch checklist.
+
+## Release Checklist
+
+Use [docs/validation.md](./docs/validation.md) for demo-store validation and [docs/release.md](./docs/release.md) before tagging `v0.1.0`.
+
## Development
```bash
diff --git a/SECURITY.md b/SECURITY.md
index 06054c8..2a28534 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -24,3 +24,4 @@ We will acknowledge reports as quickly as possible and coordinate fixes before p
- Set a strict `NEXT_PUBLIC_IMAGE_HOSTS` allowlist for product and storefront images.
- Rotate Storefront keys if they are exposed.
- Review plugins before enabling them; plugins run as trusted application code.
+- Keep the default security headers enabled unless your deployment has an equivalent policy at the edge.
diff --git a/app/api/storefront/auth/session/route.ts b/app/api/storefront/auth/session/route.ts
new file mode 100644
index 0000000..8734d60
--- /dev/null
+++ b/app/api/storefront/auth/session/route.ts
@@ -0,0 +1,12 @@
+import { cookies } from 'next/headers';
+import { NextResponse } from 'next/server';
+
+export async function GET() {
+ const cookieStore = await cookies();
+ const customerId = cookieStore.get('customer_id')?.value ?? null;
+
+ return NextResponse.json({
+ authenticated: Boolean(cookieStore.get('customer_token')?.value),
+ customer: customerId ? { id: customerId } : null
+ });
+}
diff --git a/app/api/storefront/orders/[id]/route.ts b/app/api/storefront/orders/[id]/route.ts
new file mode 100644
index 0000000..f021166
--- /dev/null
+++ b/app/api/storefront/orders/[id]/route.ts
@@ -0,0 +1,12 @@
+import { NextResponse } from 'next/server';
+import { getCustomerOrder } from '@/lib/fleetbase/storefront';
+
+export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
+ try {
+ const { id } = await params;
+ const order = await getCustomerOrder(id);
+ return NextResponse.json({ order });
+ } catch (error) {
+ return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to load order.' }, { status: 404 });
+ }
+}
diff --git a/app/categories/[slug]/page.tsx b/app/categories/[slug]/page.tsx
new file mode 100644
index 0000000..fb254f0
--- /dev/null
+++ b/app/categories/[slug]/page.tsx
@@ -0,0 +1,28 @@
+import { notFound } from 'next/navigation';
+import { ProductGrid } from '@/components/catalog/product-grid';
+import { getCategories, getProducts } from '@/lib/fleetbase/storefront';
+
+export default async function CategoryPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<{ page?: string; sort?: string }> }) {
+ const [{ slug }, query] = await Promise.all([params, searchParams]);
+ const categories = await getCategories().catch(() => []);
+ const category = categories.find((candidate) => candidate.slug === slug || candidate.id === slug);
+
+ if (!category) {
+ notFound();
+ }
+
+ const page = Math.max(1, Number(query.page ?? 1) || 1);
+ const products = await getProducts({ category: category.slug ?? category.id, page, limit: 24, sort: query.sort }).catch(() => category.products ?? []);
+
+ return (
+
+
+
+
{category.name}
+ {category.description &&
{category.description}
}
+
+
+
+
+ );
+}
diff --git a/app/orders/[id]/page.tsx b/app/orders/[id]/page.tsx
new file mode 100644
index 0000000..eee38a5
--- /dev/null
+++ b/app/orders/[id]/page.tsx
@@ -0,0 +1,48 @@
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+import { Slot } from '@/components/plugin-slot';
+import { formatMoney } from '@/lib/format';
+import { getCustomerOrder } from '@/lib/fleetbase/storefront';
+
+export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const order = await getCustomerOrder(id).catch(() => null);
+
+ if (!order) {
+ notFound();
+ }
+
+ return (
+
+
+
+
Order {order.public_id ?? order.id}
+
{order.created_at ? new Date(order.created_at).toLocaleString() : 'Order details'}
+
+
+ Status
+ {order.status ?? 'Pending'}
+
+
+ {order.items?.length ? (
+
+
Items
+ {order.items.map((item) => (
+
+ {item.quantity}x {item.name}
+ {formatMoney(item.subtotal, order.currency)}
+
+ ))}
+
+ ) : null}
+
+
+ Summary
+ {order.subtotal !== undefined && Subtotal {formatMoney(order.subtotal, order.currency)}
}
+ {order.total !== undefined && Total {formatMoney(order.total, order.currency)}
}
+ {order.tracking_number && Track order}
+ Back to orders
+
+
+ );
+}
diff --git a/app/orders/[id]/tracking/page.tsx b/app/orders/[id]/tracking/page.tsx
new file mode 100644
index 0000000..6c35218
--- /dev/null
+++ b/app/orders/[id]/tracking/page.tsx
@@ -0,0 +1,30 @@
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+import { getCustomerOrder } from '@/lib/fleetbase/storefront';
+
+export default async function OrderTrackingPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const order = await getCustomerOrder(id).catch(() => null);
+
+ if (!order) {
+ notFound();
+ }
+
+ return (
+
+
+
+
Track order
+
Order {order.public_id ?? order.id}
+
+
+ {order.status ?? 'Pending'}
+
+ {order.tracking_number ? `Tracking number: ${order.tracking_number}` : 'Tracking will appear here when the order is dispatched.'}
+
+
+
Back to order
+
+
+ );
+}
diff --git a/app/search/page.tsx b/app/search/page.tsx
index 2c31ffe..e38aa2d 100644
--- a/app/search/page.tsx
+++ b/app/search/page.tsx
@@ -1,10 +1,34 @@
import { ProductGrid } from '@/components/catalog/product-grid';
-import { searchProducts } from '@/lib/fleetbase/storefront';
+import { getCategories, getProducts, searchProducts } from '@/lib/fleetbase/storefront';
-export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string; tag?: string }> }) {
+export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string; tag?: string; category?: string; sort?: string; page?: string }> }) {
const params = await searchParams;
const query = params.q ?? params.tag ?? '';
- const products = query ? await searchProducts(query).catch(() => []) : [];
+ const page = Math.max(1, Number(params.page ?? 1) || 1);
+ const limit = 24;
+ const options = {
+ category: params.category,
+ tag: params.tag,
+ sort: params.sort,
+ page,
+ limit
+ };
+ const [categories, products] = await Promise.all([
+ getCategories().catch(() => []),
+ query ? searchProducts(query, options).catch(() => []) : getProducts(options).catch(() => [])
+ ]);
+ const nextParams = new URLSearchParams();
+ Object.entries({ ...params, page: String(page + 1) }).forEach(([key, value]) => {
+ if (value) {
+ nextParams.set(key, value);
+ }
+ });
+ const previousParams = new URLSearchParams();
+ Object.entries({ ...params, page: String(Math.max(1, page - 1)) }).forEach(([key, value]) => {
+ if (value) {
+ previousParams.set(key, value);
+ }
+ });
return (
);
}
diff --git a/app/sitemap.ts b/app/sitemap.ts
index e03132e..59d6ae2 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,15 +1,28 @@
import type { MetadataRoute } from 'next';
-import { getProducts } from '@/lib/fleetbase/storefront';
+import { getCategories, getNetworkStores, getProducts } from '@/lib/fleetbase/storefront';
export default async function sitemap(): Promise {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
- const products = await getProducts({ limit: 100 }).catch(() => []);
+ const [products, categories, stores] = await Promise.all([
+ getProducts({ limit: 100 }).catch(() => []),
+ getCategories().catch(() => []),
+ getNetworkStores().catch(() => [])
+ ]);
return [
{ url: siteUrl, lastModified: new Date() },
{ url: `${siteUrl}/search`, lastModified: new Date() },
+ { url: `${siteUrl}/stores`, lastModified: new Date() },
+ ...categories.map((category) => ({
+ url: `${siteUrl}/categories/${category.slug ?? category.id}`,
+ lastModified: new Date()
+ })),
+ ...stores.map((store) => ({
+ url: `${siteUrl}/stores/${store.slug ?? store.id}`,
+ lastModified: new Date()
+ })),
...products.map((product) => ({
- url: `${siteUrl}/products/${product.id}`,
+ url: `${siteUrl}/products/${product.slug ?? product.id}`,
lastModified: product.updated_at ? new Date(product.updated_at) : new Date()
}))
];
diff --git a/app/stores/[id]/page.tsx b/app/stores/[id]/page.tsx
new file mode 100644
index 0000000..c54d917
--- /dev/null
+++ b/app/stores/[id]/page.tsx
@@ -0,0 +1,31 @@
+import { notFound } from 'next/navigation';
+import { ProductGrid } from '@/components/catalog/product-grid';
+import { StoreHero } from '@/components/store-hero';
+import { getNetworkStores, getProducts } from '@/lib/fleetbase/storefront';
+
+export default async function StoreProfilePage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const stores = await getNetworkStores().catch(() => []);
+ const store = stores.find((candidate) => candidate.slug === id || candidate.id === id);
+
+ if (!store) {
+ notFound();
+ }
+
+ const products = await getProducts({ store: store.slug ?? store.id, limit: 24 }).catch(() => []);
+
+ return (
+ <>
+
+
+
+
+
Products from {store.name}
+
{store.email ?? store.phone ?? store.website ?? 'Browse this store catalog.'}
+
+
+
+
+ >
+ );
+}
diff --git a/components/catalog/category-pills.tsx b/components/catalog/category-pills.tsx
index 7f5dd8a..84beb14 100644
--- a/components/catalog/category-pills.tsx
+++ b/components/catalog/category-pills.tsx
@@ -9,7 +9,7 @@ export function CategoryPills({ categories }: { categories: StorefrontCategory[]
return (
{categories.map((category) => (
-
+
{category.name}
))}
diff --git a/components/catalog/product-card.tsx b/components/catalog/product-card.tsx
index aa9bdca..348cac2 100644
--- a/components/catalog/product-card.tsx
+++ b/components/catalog/product-card.tsx
@@ -5,9 +5,11 @@ import { productPrice } from '@/lib/format';
import { Slot } from '@/components/plugin-slot';
export function ProductCard({ product }: { product: StorefrontProduct }) {
+ const href = `/products/${product.slug ?? product.id}`;
+
return (
-
+
{product.primary_image_url ? (
diff --git a/components/catalog/store-directory.tsx b/components/catalog/store-directory.tsx
index 08cac27..3c6d930 100644
--- a/components/catalog/store-directory.tsx
+++ b/components/catalog/store-directory.tsx
@@ -1,4 +1,5 @@
import Image from 'next/image';
+import Link from 'next/link';
import type { NetworkStore } from '@/lib/types';
export function StoreDirectory({ stores }: { stores: NetworkStore[] }) {
@@ -8,7 +9,7 @@ export function StoreDirectory({ stores }: { stores: NetworkStore[] }) {
{store.logo_url ? : null}
-
{store.name}
+
{store.name}
{store.description}
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..81276e2
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,36 @@
+# Deployment Guide
+
+Storefront Web is a standard Next.js app with standalone output enabled.
+
+## Docker Compose
+
+```bash
+cp .env.example .env.local
+docker compose -f docker-compose.example.yml up --build
+```
+
+Use `/api/health` for container health checks.
+
+## Vercel
+
+1. Import `fleetbase/storefront-web`.
+2. Set required environment variables.
+3. Set `NEXT_PUBLIC_SITE_URL` to the production domain.
+4. Deploy.
+
+## Fly.io
+
+Use the Dockerfile as the deployment artifact. Set environment variables with `fly secrets set`.
+
+## Render
+
+Use a Docker web service and point Render at the repository Dockerfile.
+
+## Reverse Proxy
+
+When running behind Nginx, Caddy, Traefik, or another proxy:
+
+- Terminate HTTPS at the proxy or platform.
+- Forward `Host`, `X-Forwarded-Proto`, and `X-Forwarded-For`.
+- Keep `NEXT_PUBLIC_SITE_URL` aligned with the public storefront URL.
+- Restrict `NEXT_PUBLIC_IMAGE_HOSTS` to Fleetbase/CDN asset hosts used by the store.
diff --git a/docs/fleetbase-setup.md b/docs/fleetbase-setup.md
new file mode 100644
index 0000000..7564710
--- /dev/null
+++ b/docs/fleetbase-setup.md
@@ -0,0 +1,42 @@
+# Fleetbase Setup Guide
+
+Use Fleetbase Console as the management layer for Storefront Web.
+
+## Storefront Key
+
+1. Create or select a Fleetbase Storefront store or network.
+2. Generate a Storefront key.
+3. Set it as `STOREFRONT_KEY` in Storefront Web.
+4. Set `FLEETBASE_HOST` to the Fleetbase API host.
+
+## Catalog
+
+Create products with:
+
+- Name and description.
+- Price and currency.
+- Images.
+- Categories and tags.
+- Variants and addon categories when needed.
+- Availability/status configured for public storefront display.
+
+## Payments
+
+Enable gateways in Fleetbase Console, then configure matching public browser settings in Storefront Web. For Stripe, set:
+
+```bash
+NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
+```
+
+## Launch Checklist
+
+- Storefront key is scoped to the intended store or network.
+- Products, images, categories, and prices render in Storefront Web.
+- Cart create, add, update, and remove flows work.
+- Password and SMS sign-in work.
+- Pickup checkout works.
+- Delivery quote and checkout work for the target service area.
+- Stripe prepare/capture creates a Fleetbase order.
+- `NEXT_PUBLIC_SITE_URL` matches the production domain.
+- `NEXT_PUBLIC_IMAGE_HOSTS` contains only required asset/CDN hosts.
+- `/api/health` returns `ok: true`.
diff --git a/docs/plugins.md b/docs/plugins.md
new file mode 100644
index 0000000..a9b25a8
--- /dev/null
+++ b/docs/plugins.md
@@ -0,0 +1,70 @@
+# Plugin Guide
+
+Storefront Web plugins are trusted build-time TypeScript modules registered in `storefront.config.ts`. Runtime installation of arbitrary code is out of scope for v0.1.
+
+## Plugin Shape
+
+```tsx
+import type { StorefrontPlugin } from '@/plugins/types';
+
+export const examplePlugin: StorefrontPlugin = {
+ id: 'example',
+ name: 'Example Plugin',
+ slots: {
+ 'Header.before': [ExampleBanner]
+ },
+ serverHooks: {
+ beforeCheckoutCapture(payload) {
+ return payload;
+ },
+ afterOrderPlaced(order) {
+ console.info(order);
+ }
+ }
+};
+```
+
+Register plugins in `storefront.config.ts`:
+
+```ts
+plugins: [stripePlugin, checkoutMessagePlugin, promoBannerPlugin, analyticsPlugin]
+```
+
+## Slots
+
+Slots render React components in stable storefront locations.
+
+| Slot | Purpose |
+| --- | --- |
+| `Header.before` | Full-width content above the header. |
+| `Header.after` | Content below the header. |
+| `ProductCard.badges` | Badges or product metadata inside product cards. |
+| `ProductDetail.afterOptions` | Product detail content after variant/addon controls. |
+| `Cart.summary` | Cart summary extensions. |
+| `Checkout.paymentMethods` | Payment guidance or payment UI extensions. |
+| `OrderDetail.afterStatus` | Order detail status extensions. |
+| `Footer.columns` | Footer columns and links. |
+
+Slot components receive the `props` passed by the host component. Treat props as read-only.
+
+## Server Hooks
+
+| Hook | When it runs |
+| --- | --- |
+| `beforeCheckoutCapture(payload)` | Before the local checkout capture route calls Fleetbase. Return the final payload. |
+| `afterOrderPlaced(order)` | After Fleetbase returns a captured order. Use for analytics, notifications, and logging. |
+
+Hooks run in registration order. Keep hooks idempotent and avoid long-running work in the request path.
+
+## Compatibility Rules
+
+- New slots and hooks should be additive.
+- Existing slot names should not be renamed in a patch release.
+- Plugin authors should not depend on private component internals.
+- Plugins run as trusted app code and can access environment variables available to the app.
+
+## Included Examples
+
+- `promo-banner`: renders `NEXT_PUBLIC_PROMO_BANNER` above the header.
+- `checkout-message`: renders `NEXT_PUBLIC_CHECKOUT_MESSAGE` near checkout payment methods.
+- `analytics`: posts `order.placed` events to `STOREFRONT_ANALYTICS_WEBHOOK_URL` when configured, otherwise logs locally.
diff --git a/docs/release.md b/docs/release.md
new file mode 100644
index 0000000..60e1370
--- /dev/null
+++ b/docs/release.md
@@ -0,0 +1,38 @@
+# Release Checklist
+
+Use this checklist for the first public `v0.1.0` release.
+
+## Repository Setup
+
+Set the GitHub repository description to:
+
+```text
+Self-hostable commerce storefront powered by Fleetbase Storefront API.
+```
+
+Recommended repository topics:
+
+- `fleetbase`
+- `storefront`
+- `ecommerce`
+- `commerce`
+- `headless-commerce`
+- `self-hosted`
+- `nextjs`
+- `agpl`
+
+## Before Tagging
+
+- GitHub CI passes on `main`.
+- `pnpm verify` passes locally.
+- Docker image builds from a clean checkout.
+- Demo Storefront validation checklist in `docs/validation.md` is complete.
+- README screenshots or demo GIF are updated if a public demo store is available.
+- Changelog entry for `0.1.0` is finalized.
+
+## Release
+
+1. Tag `v0.1.0`.
+2. Create a GitHub release using the changelog summary.
+3. Attach deployment notes and demo-store validation notes.
+4. Announce that Fleetbase Console remains the admin layer and Storefront Web is the customer-facing frontend.
diff --git a/docs/themes.md b/docs/themes.md
new file mode 100644
index 0000000..95b19da
--- /dev/null
+++ b/docs/themes.md
@@ -0,0 +1,40 @@
+# Theme Guide
+
+Themes are named token presets in `lib/theme/index.ts`. Storefront Web maps the selected preset to CSS custom properties on the root layout.
+
+Select a theme:
+
+```bash
+NEXT_PUBLIC_DEFAULT_THEME=marketplace
+```
+
+## Tokens
+
+| Token | CSS variable | Purpose |
+| --- | --- | --- |
+| `background` | `--background` | Page background. |
+| `surface` | `--surface` | Quiet section and panel background. |
+| `surfaceStrong` | `--surface-strong` | Stronger neutral surface. |
+| `text` | `--text` | Main text color. |
+| `muted` | `--muted` | Secondary text. |
+| `border` | `--border` | Borders and dividers. |
+| `primary` | `--primary` | Primary actions and accents. |
+| `primaryContrast` | `--primary-contrast` | Text on primary color. |
+| `accent` | `--accent` | Secondary accent. |
+| `success` | `--success` | Success states. |
+| `warning` | `--warning` | Warning states. |
+| `error` | `--error` | Error states. |
+| `radius` | `--radius` | Shared border radius. |
+
+## Adding a Theme
+
+1. Add a preset to `themes` in `lib/theme/index.ts`.
+2. Use accessible contrast for `primary` and `primaryContrast`.
+3. Test product cards, forms, checkout, account, and empty states.
+4. Set `NEXT_PUBLIC_DEFAULT_THEME` to the new key.
+
+## Current Presets
+
+- `default`
+- `marketplace`
+- `restaurant`
diff --git a/docs/validation.md b/docs/validation.md
new file mode 100644
index 0000000..8707585
--- /dev/null
+++ b/docs/validation.md
@@ -0,0 +1,35 @@
+# Demo Store Validation
+
+Use this checklist before cutting `v0.1.0`.
+
+## Storefront Data
+
+- Store name, description, logo, backdrop, currency, and network/store mode load.
+- Store and marketplace directory pages load.
+- Store profile pages show products.
+
+## Catalog
+
+- Product listing loads.
+- Product detail loads by ID or slug.
+- Images render from allowed hosts.
+- Categories, filters, sorting, and pagination pass through to the Storefront API.
+- Variants and addons can be selected and priced.
+
+## Customer
+
+- Password login sets a session.
+- SMS request and verify set a session.
+- Logout clears the session.
+- Orders list loads for an authenticated customer.
+- Order detail and tracking pages load when the API exposes the order.
+
+## Cart and Checkout
+
+- Cart is created and persisted with the `cart_id` cookie.
+- Add, update quantity, remove item, and empty-state flows work.
+- Pickup checkout prepares and captures an order.
+- Delivery checkout fetches a service quote before capture.
+- Stripe confirmation works with test keys.
+
+Record API mismatches as frontend fixes or upstream Storefront API issues.
diff --git a/features/checkout/checkout-page-client.tsx b/features/checkout/checkout-page-client.tsx
index 2727688..fd2ad4c 100644
--- a/features/checkout/checkout-page-client.tsx
+++ b/features/checkout/checkout-page-client.tsx
@@ -18,6 +18,7 @@ export function CheckoutPageClient() {
const [status, setStatus] = useState
(null);
const [order, setOrder] = useState(null);
const [loading, setLoading] = useState(false);
+ const [quoteLoading, setQuoteLoading] = useState(false);
const total = useMemo(() => (cart?.subtotal ?? 0) + (pickup ? 0 : quote?.amount ?? 0), [cart, pickup, quote]);
@@ -30,9 +31,12 @@ export function CheckoutPageClient() {
async function fetchQuote() {
if (!cart?.id || pickup || !origin || !destination) {
+ setQuote(null);
return;
}
+ setQuoteLoading(true);
+ setStatus(null);
const response = await fetch('/api/storefront/service-quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -41,6 +45,7 @@ export function CheckoutPageClient() {
const payload = await response.json();
setQuote(payload.quote ?? null);
setStatus(payload.error ?? null);
+ setQuoteLoading(false);
}
async function placeOrder(event: React.FormEvent) {
@@ -49,6 +54,14 @@ export function CheckoutPageClient() {
setStatus(null);
try {
+ if (!cart?.items?.length) {
+ throw new Error('Your cart is empty.');
+ }
+
+ if (!pickup && !quote?.id) {
+ throw new Error('Fetch a delivery quote before placing this order.');
+ }
+
const prepareResponse = await fetch('/api/storefront/checkout/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -120,6 +133,7 @@ export function CheckoutPageClient() {
Checkout
Choose delivery or pickup and place your order.
+ {!cart?.items?.length && Your cart is empty. Add products before checkout.
}
setPickup(event.target.checked)} />
Pickup order
@@ -128,12 +142,16 @@ export function CheckoutPageClient() {
<>
Origin store location
- setOrigin(event.target.value)} placeholder="Store location public id" onBlur={fetchQuote} />
+ setOrigin(event.target.value)} placeholder="Store location public id" onBlur={fetchQuote} />
Delivery destination
- setDestination(event.target.value)} placeholder="Place ID or address payload" onBlur={fetchQuote} />
+ setDestination(event.target.value)} placeholder="Address, place ID, or Fleetbase place payload" onBlur={fetchQuote} />
+ Use the destination format accepted by your Fleetbase Storefront service area.
+
+ {quoteLoading ? 'Fetching quote...' : 'Fetch delivery quote'}
+
>
)}
@@ -149,7 +167,7 @@ export function CheckoutPageClient() {
Order notes
-
+
{loading ? 'Placing order...' : 'Place order'}
{status && {status}
}
@@ -157,7 +175,7 @@ export function CheckoutPageClient() {
Order summary
Subtotal {formatMoney(cart?.subtotal, cart?.currency)}
- {!pickup && Delivery {quote ? formatMoney(quote.amount, quote.currency ?? cart?.currency) : 'Quote required'}
}
+ {!pickup && Delivery {quoteLoading ? 'Fetching...' : quote ? formatMoney(quote.amount, quote.currency ?? cart?.currency) : 'Quote required'}
}
Total {formatMoney(total, cart?.currency)}
diff --git a/features/customer/account-page-client.tsx b/features/customer/account-page-client.tsx
index 08a96ca..4a75675 100644
--- a/features/customer/account-page-client.tsx
+++ b/features/customer/account-page-client.tsx
@@ -1,8 +1,12 @@
'use client';
-import { useState } from 'react';
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
+import type { Customer } from '@/lib/types';
export function AccountPageClient() {
+ const [authenticated, setAuthenticated] = useState(false);
+ const [customer, setCustomer] = useState(null);
const [identity, setIdentity] = useState('');
const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
@@ -10,6 +14,15 @@ export function AccountPageClient() {
const [status, setStatus] = useState(null);
const [smsRequested, setSmsRequested] = useState(false);
+ useEffect(() => {
+ fetch('/api/storefront/auth/session')
+ .then((response) => response.json())
+ .then((payload) => {
+ setAuthenticated(Boolean(payload.authenticated));
+ setCustomer(payload.customer ?? null);
+ });
+ }, []);
+
async function login(event: React.FormEvent) {
event.preventDefault();
const response = await fetch('/api/storefront/auth/login', {
@@ -18,6 +31,10 @@ export function AccountPageClient() {
body: JSON.stringify({ identity, password })
});
const payload = await response.json();
+ if (response.ok) {
+ setAuthenticated(true);
+ setCustomer(payload.customer ?? null);
+ }
setStatus(response.ok ? `Signed in as ${payload.customer?.name ?? payload.customer?.email ?? identity}` : payload.error);
}
@@ -41,11 +58,19 @@ export function AccountPageClient() {
body: JSON.stringify({ phone, code })
});
const payload = await response.json();
+ if (response.ok) {
+ setAuthenticated(true);
+ setCustomer(payload.customer ?? null);
+ }
setStatus(response.ok ? `Signed in as ${payload.customer?.name ?? payload.customer?.phone ?? phone}` : payload.error);
}
async function logout() {
const response = await fetch('/api/storefront/auth/logout', { method: 'POST' });
+ if (response.ok) {
+ setAuthenticated(false);
+ setCustomer(null);
+ }
setStatus(response.ok ? 'Signed out.' : 'Unable to sign out.');
}
@@ -56,6 +81,18 @@ export function AccountPageClient() {
Account
Sign in to view order history and saved checkout details.
+ {authenticated && (
+
+ Signed in
+
+ {customer?.name ?? customer?.email ?? customer?.phone ?? customer?.id ?? 'Your customer session is active.'}
+
+
+ View orders
+ Sign out
+
+
+ )}
- Sign out
+ {!authenticated && Clear session }
{status && {status}
}
diff --git a/features/customer/orders-page-client.tsx b/features/customer/orders-page-client.tsx
index 0acb577..d04e68b 100644
--- a/features/customer/orders-page-client.tsx
+++ b/features/customer/orders-page-client.tsx
@@ -1,5 +1,6 @@
'use client';
+import Link from 'next/link';
import { useEffect, useState } from 'react';
import type { Order } from '@/lib/types';
@@ -23,8 +24,9 @@ export function OrdersPageClient() {
{orders.map((order) => (
- {order.id ?? order.public_id}
+ {order.public_id ?? order.id}
{order.status ?? 'Pending'} {order.created_at ? `- ${new Date(order.created_at).toLocaleString()}` : ''}
+ View order
))}
diff --git a/lib/fleetbase/storefront.ts b/lib/fleetbase/storefront.ts
index 4df7072..129547d 100644
--- a/lib/fleetbase/storefront.ts
+++ b/lib/fleetbase/storefront.ts
@@ -75,6 +75,10 @@ export async function getCustomerOrders() {
return storefrontRequest('customers/orders', { cache: 'no-store' });
}
+export async function getCustomerOrder(id: string) {
+ return storefrontRequest(`customers/orders/${id}`, { cache: 'no-store' });
+}
+
export async function getServiceQuoteFromCart(query: Record) {
return storefrontRequest('service-quotes/from-cart', { query, cache: 'no-store' });
}
diff --git a/lib/types.ts b/lib/types.ts
index 5db1407..3903a23 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -136,6 +136,11 @@ export type Order = {
tracking_number?: string;
public_id?: string;
created_at?: string;
+ updated_at?: string;
+ items?: CartItem[];
+ total?: number;
+ subtotal?: number;
+ currency?: string;
payload?: unknown;
meta?: Record;
};
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..96c25de
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,40 @@
+import { NextResponse, type NextRequest } from 'next/server';
+
+export function middleware(request: NextRequest) {
+ const response = NextResponse.next();
+ const isDev = process.env.NODE_ENV !== 'production';
+ const imageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS ?? '')
+ .split(',')
+ .map((host) => host.trim())
+ .filter(Boolean)
+ .join(' ');
+
+ response.headers.set('X-Content-Type-Options', 'nosniff');
+ response.headers.set('X-Frame-Options', 'DENY');
+ response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+ response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self), payment=(self)');
+ response.headers.set(
+ 'Content-Security-Policy',
+ [
+ "default-src 'self'",
+ `script-src 'self' ${isDev ? "'unsafe-eval'" : ''} 'unsafe-inline'`,
+ "style-src 'self' 'unsafe-inline'",
+ `img-src 'self' data: blob: https: ${imageHosts}`,
+ `connect-src 'self' ${process.env.FLEETBASE_HOST ?? 'https://api.fleetbase.io'}`,
+ "font-src 'self' data:",
+ "frame-ancestors 'none'",
+ "base-uri 'self'",
+ "form-action 'self'"
+ ].join('; ')
+ );
+
+ if (request.nextUrl.pathname.startsWith('/api/')) {
+ response.headers.set('Cache-Control', 'no-store');
+ }
+
+ return response;
+}
+
+export const config = {
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
+};
diff --git a/plugins/analytics.tsx b/plugins/analytics.tsx
index f7787e3..620bb59 100644
--- a/plugins/analytics.tsx
+++ b/plugins/analytics.tsx
@@ -1,10 +1,25 @@
import type { StorefrontPlugin } from './types';
+function endpoint() {
+ return process.env.STOREFRONT_ANALYTICS_WEBHOOK_URL;
+}
+
export const analyticsPlugin: StorefrontPlugin = {
id: 'analytics',
name: 'Analytics Hooks',
serverHooks: {
- afterOrderPlaced(order) {
+ async afterOrderPlaced(order) {
+ const webhookUrl = endpoint();
+
+ if (webhookUrl) {
+ await fetch(webhookUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ event: 'order.placed', order })
+ });
+ return;
+ }
+
console.info('[storefront-web] order placed', order);
}
}
diff --git a/plugins/checkout-message.tsx b/plugins/checkout-message.tsx
new file mode 100644
index 0000000..e216bc0
--- /dev/null
+++ b/plugins/checkout-message.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import type { StorefrontPlugin } from './types';
+
+function CheckoutMessage() {
+ const message = process.env.NEXT_PUBLIC_CHECKOUT_MESSAGE;
+
+ if (!message) {
+ return null;
+ }
+
+ return (
+
+ {message}
+
+ );
+}
+
+export const checkoutMessagePlugin: StorefrontPlugin = {
+ id: 'checkout-message',
+ name: 'Checkout Message',
+ slots: {
+ 'Checkout.paymentMethods': [CheckoutMessage]
+ }
+};
diff --git a/storefront.config.ts b/storefront.config.ts
index bfb6ae5..ca07027 100644
--- a/storefront.config.ts
+++ b/storefront.config.ts
@@ -1,5 +1,6 @@
import type { StorefrontWebConfig } from './lib/config';
import { analyticsPlugin } from './plugins/analytics';
+import { checkoutMessagePlugin } from './plugins/checkout-message';
import { promoBannerPlugin } from './plugins/promo-banner';
import { stripePlugin } from './plugins/stripe';
@@ -23,7 +24,7 @@ const config: StorefrontWebConfig = {
orderHistory: true,
marketplace: true
},
- plugins: [stripePlugin, promoBannerPlugin, analyticsPlugin]
+ plugins: [stripePlugin, checkoutMessagePlugin, promoBannerPlugin, analyticsPlugin]
};
export default config;
diff --git a/tests/plugins.test.tsx b/tests/plugins.test.tsx
index 18d84ac..53b03f8 100644
--- a/tests/plugins.test.tsx
+++ b/tests/plugins.test.tsx
@@ -1,10 +1,15 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { Slot } from '@/components/plugin-slot';
import { runAfterOrderPlacedHooks, runBeforeCheckoutCaptureHooks } from '@/plugins/registry';
describe('plugin registry', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
it('renders registered slot components', () => {
const html = renderToStaticMarkup( );
@@ -19,6 +24,21 @@ describe('plugin registry', () => {
await runAfterOrderPlacedHooks({ id: 'order_test' });
expect(info).toHaveBeenCalledWith('[storefront-web] order placed', { id: 'order_test' });
- info.mockRestore();
+ });
+
+ it('posts analytics events to a configured webhook', async () => {
+ const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
+ vi.stubGlobal('fetch', fetch);
+ vi.stubEnv('STOREFRONT_ANALYTICS_WEBHOOK_URL', 'https://analytics.example.com/events');
+
+ await runAfterOrderPlacedHooks({ id: 'order_test' });
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://analytics.example.com/events',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ event: 'order.placed', order: { id: 'order_test' } })
+ })
+ );
});
});
diff --git a/tests/storefront-flow.test.ts b/tests/storefront-flow.test.ts
new file mode 100644
index 0000000..684e8be
--- /dev/null
+++ b/tests/storefront-flow.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { addCartItem, beforeCheckout, captureCheckout, getCart, getProduct, getProducts } from '@/lib/fleetbase/storefront';
+
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(async () => ({
+ get: () => undefined
+ })),
+ headers: vi.fn(async () => ({
+ get: () => 'en'
+ }))
+}));
+
+describe('mocked storefront customer flow', () => {
+ beforeEach(() => {
+ vi.stubEnv('STOREFRONT_KEY', 'store_test');
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = init?.method ?? 'GET';
+
+ if (url.endsWith('/storefront/v1/products')) {
+ return Response.json([{ id: 'prod_1', name: 'Test Product', price: 1299 }]);
+ }
+
+ if (url.endsWith('/storefront/v1/products/prod_1')) {
+ return Response.json({ id: 'prod_1', name: 'Test Product', price: 1299 });
+ }
+
+ if (url.endsWith('/storefront/v1/carts')) {
+ return Response.json({ id: 'cart_1', items: [] });
+ }
+
+ if (url.endsWith('/storefront/v1/carts/cart_1/prod_1') && method === 'POST') {
+ return Response.json({ id: 'cart_1', items: [{ id: 'line_1', product_id: 'prod_1', name: 'Test Product', quantity: 1, subtotal: 1299 }] });
+ }
+
+ if (url.includes('/storefront/v1/checkouts/before')) {
+ return Response.json({ token: 'checkout_token' });
+ }
+
+ if (url.endsWith('/storefront/v1/checkouts/capture') && method === 'POST') {
+ return Response.json({ id: 'order_1', status: 'created' });
+ }
+
+ return Response.json({ message: `Unhandled ${method} ${url}` }, { status: 404 });
+ }));
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ });
+
+ it('browses, adds to cart, prepares checkout, and captures an order', async () => {
+ await expect(getProducts()).resolves.toMatchObject([{ id: 'prod_1' }]);
+ await expect(getProduct('prod_1')).resolves.toMatchObject({ name: 'Test Product' });
+ await expect(getCart()).resolves.toMatchObject({ id: 'cart_1' });
+ await expect(addCartItem('cart_1', 'prod_1', { quantity: 1 })).resolves.toMatchObject({ items: [{ id: 'line_1' }] });
+ await expect(beforeCheckout({ gateway: 'stripe', cart: 'cart_1', pickup: true })).resolves.toMatchObject({ token: 'checkout_token' });
+ await expect(captureCheckout({ token: 'checkout_token' })).resolves.toMatchObject({ id: 'order_1' });
+ });
+});
diff --git a/tests/storefront-routes.test.ts b/tests/storefront-routes.test.ts
new file mode 100644
index 0000000..0b47f12
--- /dev/null
+++ b/tests/storefront-routes.test.ts
@@ -0,0 +1,114 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const cookieValues = new Map();
+
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieValues.get(name);
+ return value ? { value } : undefined;
+ }
+ })),
+ headers: vi.fn(async () => ({
+ get: () => 'en'
+ }))
+}));
+
+vi.mock('@/lib/fleetbase/storefront', () => ({
+ addCartItem: vi.fn(),
+ beforeCheckout: vi.fn(),
+ captureCheckout: vi.fn(),
+ getCart: vi.fn(),
+ getCustomerOrder: vi.fn(),
+ loginCustomer: vi.fn(),
+ removeCartItem: vi.fn(),
+ updateCartItem: vi.fn()
+}));
+
+describe('storefront route handlers', () => {
+ beforeEach(() => {
+ cookieValues.clear();
+ vi.stubEnv('STOREFRONT_KEY', 'store_test');
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+ });
+
+ it('creates or loads a cart and persists the cart cookie', async () => {
+ const { getCart } = await import('@/lib/fleetbase/storefront');
+ const { GET } = await import('@/app/api/storefront/cart/route');
+ vi.mocked(getCart).mockResolvedValue({ id: 'cart_1', items: [] });
+
+ const response = await GET();
+ const payload = await response.json();
+
+ expect(payload.cart.id).toBe('cart_1');
+ expect(response.headers.get('set-cookie')).toContain('cart_id=cart_1');
+ });
+
+ it('adds a cart item through the local proxy route', async () => {
+ const { addCartItem } = await import('@/lib/fleetbase/storefront');
+ const { POST } = await import('@/app/api/storefront/cart/items/route');
+ cookieValues.set('cart_id', 'cart_1');
+ vi.mocked(addCartItem).mockResolvedValue({ id: 'cart_1', items: [{ id: 'line_1', product_id: 'prod_1', name: 'Test', quantity: 1, subtotal: 1299 }] });
+
+ const response = await POST(new Request('http://localhost/api/storefront/cart/items', {
+ method: 'POST',
+ body: JSON.stringify({ productId: 'prod_1', quantity: 1 })
+ }));
+ const payload = await response.json();
+
+ expect(payload.cart.items).toHaveLength(1);
+ expect(addCartItem).toHaveBeenCalledWith('cart_1', 'prod_1', expect.objectContaining({ quantity: 1 }));
+ });
+
+ it('sets customer cookies on password login', async () => {
+ const { loginCustomer } = await import('@/lib/fleetbase/storefront');
+ const { POST } = await import('@/app/api/storefront/auth/login/route');
+ vi.mocked(loginCustomer).mockResolvedValue({ token: 'customer_token', customer: { id: 'customer_1', email: 'test@example.com' } });
+
+ const response = await POST(new Request('http://localhost/api/storefront/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ identity: 'test@example.com', password: 'secret' })
+ }));
+ const payload = await response.json();
+
+ expect(payload.customer.id).toBe('customer_1');
+ expect(response.headers.get('set-cookie')).toContain('customer_token=customer_token');
+ });
+
+ it('prepares checkout with the customer cookie fallback', async () => {
+ const { beforeCheckout } = await import('@/lib/fleetbase/storefront');
+ const { POST } = await import('@/app/api/storefront/checkout/prepare/route');
+ cookieValues.set('customer_id', 'customer_1');
+ vi.mocked(beforeCheckout).mockResolvedValue({ token: 'checkout_token' });
+
+ const response = await POST(new Request('http://localhost/api/storefront/checkout/prepare', {
+ method: 'POST',
+ body: JSON.stringify({ gateway: 'stripe', cart: 'cart_1', pickup: true })
+ }));
+ const payload = await response.json();
+
+ expect(payload.token).toBe('checkout_token');
+ expect(beforeCheckout).toHaveBeenCalledWith(expect.objectContaining({ customer: 'customer_1', cart: 'cart_1' }));
+ });
+
+ it('captures checkout and clears the cart cookie', async () => {
+ const { captureCheckout } = await import('@/lib/fleetbase/storefront');
+ const { POST } = await import('@/app/api/storefront/checkout/capture/route');
+ const info = vi.spyOn(console, 'info').mockImplementation(() => undefined);
+ vi.mocked(captureCheckout).mockResolvedValue({ id: 'order_1', status: 'created' });
+
+ const response = await POST(new Request('http://localhost/api/storefront/checkout/capture', {
+ method: 'POST',
+ body: JSON.stringify({ token: 'checkout_token' })
+ }));
+ const payload = await response.json();
+
+ expect(payload.order.id).toBe('order_1');
+ expect(response.headers.get('set-cookie')).toContain('cart_id=');
+ info.mockRestore();
+ });
+});
From 94c0e56eb2d091c8bdfd3c0f1c67912ed5136ce0 Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Wed, 17 Jun 2026 16:00:40 +0800
Subject: [PATCH 2/7] Refactor customer auth and account UX
---
CHANGELOG.md | 1 +
app/account/page.tsx | 24 ++-
app/api/storefront/auth/login/route.ts | 5 +-
app/api/storefront/auth/logout/route.ts | 3 +
app/api/storefront/auth/register/route.ts | 26 +++
app/api/storefront/auth/session/route.ts | 7 +-
app/api/storefront/auth/sms/route.ts | 5 +-
app/api/storefront/customer/places/route.ts | 11 ++
app/globals.css | 139 ++++++++++++++
app/login/page.tsx | 10 +
app/login/sms/page.tsx | 10 +
app/orders/[id]/page.tsx | 8 +
app/orders/[id]/tracking/page.tsx | 8 +
app/orders/page.tsx | 10 +-
app/register/page.tsx | 10 +
components/auth/auth-shell.tsx | 28 +++
components/auth/sign-in-prompt.tsx | 19 ++
components/auth/status-message.tsx | 11 ++
components/layout/header.tsx | 2 +-
docs/fleetbase-setup.md | 2 +
docs/validation.md | 3 +
.../customer/account-dashboard-client.tsx | 171 ++++++++++++++++++
features/customer/account-page-client.tsx | 129 -------------
features/customer/login-page-client.tsx | 63 +++++++
features/customer/register-page-client.tsx | 80 ++++++++
features/customer/sms-login-page-client.tsx | 68 +++++++
lib/customer-session.ts | 24 +++
lib/fleetbase/storefront.ts | 13 ++
lib/types.ts | 14 ++
tests/customer-ui.test.tsx | 45 +++++
tests/storefront-flow.test.ts | 17 +-
tests/storefront-routes.test.ts | 104 ++++++++++-
32 files changed, 927 insertions(+), 143 deletions(-)
create mode 100644 app/api/storefront/auth/register/route.ts
create mode 100644 app/api/storefront/customer/places/route.ts
create mode 100644 app/login/page.tsx
create mode 100644 app/login/sms/page.tsx
create mode 100644 app/register/page.tsx
create mode 100644 components/auth/auth-shell.tsx
create mode 100644 components/auth/sign-in-prompt.tsx
create mode 100644 components/auth/status-message.tsx
create mode 100644 features/customer/account-dashboard-client.tsx
delete mode 100644 features/customer/account-page-client.tsx
create mode 100644 features/customer/login-page-client.tsx
create mode 100644 features/customer/register-page-client.tsx
create mode 100644 features/customer/sms-login-page-client.tsx
create mode 100644 lib/customer-session.ts
create mode 100644 tests/customer-ui.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e759228..9b066c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,3 +7,4 @@
- Added health checks, secure cookie defaults, constrained image host configuration, and plugin hook tests.
- Added a stores page, cart quantity updates, SMS sign-in UI, logout route, and example promo banner plugin.
- Added session awareness, order detail/tracking pages, catalog filters, category/store pages, plugin/theme/deployment docs, and security headers.
+- Refactored customer authentication into dedicated login, SMS login, registration, and account dashboard surfaces.
diff --git a/app/account/page.tsx b/app/account/page.tsx
index 6716d7a..9384ae1 100644
--- a/app/account/page.tsx
+++ b/app/account/page.tsx
@@ -1,5 +1,23 @@
-import { AccountPageClient } from '@/features/customer/account-page-client';
+import { cookies } from 'next/headers';
+import { SignInPrompt } from '@/components/auth/sign-in-prompt';
+import { AccountDashboardClient } from '@/features/customer/account-dashboard-client';
-export default function AccountPage() {
- return ;
+export default async function AccountPage() {
+ const cookieStore = await cookies();
+ const authenticated = Boolean(cookieStore.get('customer_token')?.value);
+
+ if (!authenticated) {
+ return ;
+ }
+
+ return (
+
+ );
}
diff --git a/app/api/storefront/auth/login/route.ts b/app/api/storefront/auth/login/route.ts
index 14166f9..a4f9ff6 100644
--- a/app/api/storefront/auth/login/route.ts
+++ b/app/api/storefront/auth/login/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { loginCustomer } from '@/lib/fleetbase/storefront';
import { sessionCookieOptions } from '@/lib/runtime';
+import { setCustomerCookies } from '@/lib/customer-session';
export async function POST(request: Request) {
try {
@@ -8,9 +9,7 @@ export async function POST(request: Request) {
const result = await loginCustomer(body);
const response = NextResponse.json({ customer: result.customer });
response.cookies.set('customer_token', result.token, sessionCookieOptions());
- if (result.customer?.id) {
- response.cookies.set('customer_id', result.customer.id, sessionCookieOptions());
- }
+ setCustomerCookies(response, result.customer);
return response;
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to sign in.' }, { status: 401 });
diff --git a/app/api/storefront/auth/logout/route.ts b/app/api/storefront/auth/logout/route.ts
index 402f1a9..9dbc9fa 100644
--- a/app/api/storefront/auth/logout/route.ts
+++ b/app/api/storefront/auth/logout/route.ts
@@ -4,5 +4,8 @@ export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.delete('customer_token');
response.cookies.delete('customer_id');
+ response.cookies.delete('customer_name');
+ response.cookies.delete('customer_email');
+ response.cookies.delete('customer_phone');
return response;
}
diff --git a/app/api/storefront/auth/register/route.ts b/app/api/storefront/auth/register/route.ts
new file mode 100644
index 0000000..bef4d22
--- /dev/null
+++ b/app/api/storefront/auth/register/route.ts
@@ -0,0 +1,26 @@
+import { NextResponse } from 'next/server';
+import { createCustomer, requestCustomerCreationCode } from '@/lib/fleetbase/storefront';
+import { setCustomerCookies } from '@/lib/customer-session';
+import { sessionCookieOptions } from '@/lib/runtime';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+
+ if (!body.code) {
+ const result = await requestCustomerCreationCode({
+ identity: body.identity,
+ mode: body.mode ?? 'email'
+ });
+ return NextResponse.json(result);
+ }
+
+ const result = await createCustomer(body);
+ const response = NextResponse.json({ customer: result.customer });
+ response.cookies.set('customer_token', result.token, sessionCookieOptions());
+ setCustomerCookies(response, result.customer);
+ return response;
+ } catch (error) {
+ return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to create account.' }, { status: 400 });
+ }
+}
diff --git a/app/api/storefront/auth/session/route.ts b/app/api/storefront/auth/session/route.ts
index 8734d60..17fdd74 100644
--- a/app/api/storefront/auth/session/route.ts
+++ b/app/api/storefront/auth/session/route.ts
@@ -7,6 +7,11 @@ export async function GET() {
return NextResponse.json({
authenticated: Boolean(cookieStore.get('customer_token')?.value),
- customer: customerId ? { id: customerId } : null
+ customer: customerId ? {
+ id: customerId,
+ name: cookieStore.get('customer_name')?.value,
+ email: cookieStore.get('customer_email')?.value,
+ phone: cookieStore.get('customer_phone')?.value
+ } : null
});
}
diff --git a/app/api/storefront/auth/sms/route.ts b/app/api/storefront/auth/sms/route.ts
index dbfa1ab..556d1fb 100644
--- a/app/api/storefront/auth/sms/route.ts
+++ b/app/api/storefront/auth/sms/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { requestSmsLogin, verifyCustomerCode } from '@/lib/fleetbase/storefront';
import { sessionCookieOptions } from '@/lib/runtime';
+import { setCustomerCookies } from '@/lib/customer-session';
export async function POST(request: Request) {
try {
@@ -9,9 +10,7 @@ export async function POST(request: Request) {
const result = await verifyCustomerCode(body);
const response = NextResponse.json({ customer: result.customer });
response.cookies.set('customer_token', result.token, sessionCookieOptions());
- if (result.customer?.id) {
- response.cookies.set('customer_id', result.customer.id, sessionCookieOptions());
- }
+ setCustomerCookies(response, result.customer);
return response;
}
const result = await requestSmsLogin(body);
diff --git a/app/api/storefront/customer/places/route.ts b/app/api/storefront/customer/places/route.ts
new file mode 100644
index 0000000..6c2a5e5
--- /dev/null
+++ b/app/api/storefront/customer/places/route.ts
@@ -0,0 +1,11 @@
+import { NextResponse } from 'next/server';
+import { getCustomerPlaces } from '@/lib/fleetbase/storefront';
+
+export async function GET() {
+ try {
+ const places = await getCustomerPlaces();
+ return NextResponse.json({ places });
+ } catch (error) {
+ return NextResponse.json({ error: error instanceof Error ? error.message : 'Sign in to view saved addresses.' }, { status: 401 });
+ }
+}
diff --git a/app/globals.css b/app/globals.css
index 21798a6..b237e0d 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -138,3 +138,142 @@ img {
grid-template-columns: 1fr;
}
}
+
+.auth-page {
+ min-height: calc(100vh - 180px);
+ display: grid;
+ place-items: center;
+ padding: 48px 0 72px;
+ gap: 16px;
+}
+
+.auth-panel {
+ width: min(100%, 460px);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: #fff;
+ padding: 24px;
+}
+
+.auth-panel h1 {
+ margin: 0 0 6px;
+}
+
+.auth-actions,
+.auth-footer {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.account-page {
+ padding: 32px 0 56px;
+}
+
+.account-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-end;
+ margin-bottom: 22px;
+}
+
+.account-layout {
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr);
+ gap: 24px;
+}
+
+.account-tabs {
+ display: grid;
+ gap: 8px;
+ height: max-content;
+}
+
+.account-tab {
+ min-height: 42px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: #fff;
+ color: var(--text);
+ text-align: left;
+ padding: 0 12px;
+ font-weight: 650;
+}
+
+.account-tab.active {
+ border-color: var(--primary);
+ background: var(--primary);
+ color: var(--primary-contrast);
+}
+
+.account-panel {
+ min-height: 360px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: #fff;
+ padding: 20px;
+}
+
+.account-panel h2 {
+ margin: 0;
+}
+
+.account-summary-grid,
+.profile-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 14px;
+}
+
+.summary-card,
+.empty-state,
+.account-list-item {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 14px;
+ background: var(--surface);
+}
+
+.summary-card {
+ display: grid;
+ gap: 6px;
+}
+
+.summary-card strong {
+ font-size: 1.35rem;
+}
+
+.account-list-item {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: center;
+}
+
+.profile-grid > div {
+ display: grid;
+ gap: 4px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 12px;
+ background: var(--surface);
+}
+
+@media (max-width: 760px) {
+ .account-heading,
+ .account-list-item {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .account-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .account-tabs {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..e26655e
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,10 @@
+import { Suspense } from 'react';
+import { LoginPageClient } from '@/features/customer/login-page-client';
+
+export default function LoginPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/login/sms/page.tsx b/app/login/sms/page.tsx
new file mode 100644
index 0000000..60ba992
--- /dev/null
+++ b/app/login/sms/page.tsx
@@ -0,0 +1,10 @@
+import { Suspense } from 'react';
+import { SmsLoginPageClient } from '@/features/customer/sms-login-page-client';
+
+export default function SmsLoginPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/orders/[id]/page.tsx b/app/orders/[id]/page.tsx
index eee38a5..c2bde88 100644
--- a/app/orders/[id]/page.tsx
+++ b/app/orders/[id]/page.tsx
@@ -1,11 +1,19 @@
import Link from 'next/link';
+import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
+import { SignInPrompt } from '@/components/auth/sign-in-prompt';
import { Slot } from '@/components/plugin-slot';
import { formatMoney } from '@/lib/format';
import { getCustomerOrder } from '@/lib/fleetbase/storefront';
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
+ const authenticated = Boolean((await cookies()).get('customer_token')?.value);
+
+ if (!authenticated) {
+ return ;
+ }
+
const order = await getCustomerOrder(id).catch(() => null);
if (!order) {
diff --git a/app/orders/[id]/tracking/page.tsx b/app/orders/[id]/tracking/page.tsx
index 6c35218..334d406 100644
--- a/app/orders/[id]/tracking/page.tsx
+++ b/app/orders/[id]/tracking/page.tsx
@@ -1,9 +1,17 @@
import Link from 'next/link';
+import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
+import { SignInPrompt } from '@/components/auth/sign-in-prompt';
import { getCustomerOrder } from '@/lib/fleetbase/storefront';
export default async function OrderTrackingPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
+ const authenticated = Boolean((await cookies()).get('customer_token')?.value);
+
+ if (!authenticated) {
+ return ;
+ }
+
const order = await getCustomerOrder(id).catch(() => null);
if (!order) {
diff --git a/app/orders/page.tsx b/app/orders/page.tsx
index 07f91cf..4c73227 100644
--- a/app/orders/page.tsx
+++ b/app/orders/page.tsx
@@ -1,5 +1,13 @@
+import { cookies } from 'next/headers';
+import { SignInPrompt } from '@/components/auth/sign-in-prompt';
import { OrdersPageClient } from '@/features/customer/orders-page-client';
-export default function OrdersPage() {
+export default async function OrdersPage() {
+ const authenticated = Boolean((await cookies()).get('customer_token')?.value);
+
+ if (!authenticated) {
+ return ;
+ }
+
return ;
}
diff --git a/app/register/page.tsx b/app/register/page.tsx
new file mode 100644
index 0000000..71ce24f
--- /dev/null
+++ b/app/register/page.tsx
@@ -0,0 +1,10 @@
+import { Suspense } from 'react';
+import { RegisterPageClient } from '@/features/customer/register-page-client';
+
+export default function RegisterPage() {
+ return (
+
+
+
+ );
+}
diff --git a/components/auth/auth-shell.tsx b/components/auth/auth-shell.tsx
new file mode 100644
index 0000000..3b11194
--- /dev/null
+++ b/components/auth/auth-shell.tsx
@@ -0,0 +1,28 @@
+import Link from 'next/link';
+import React from 'react';
+
+export function AuthShell({
+ title,
+ description,
+ children,
+ footer
+}: {
+ title: string;
+ description: string;
+ children: React.ReactNode;
+ footer?: React.ReactNode;
+}) {
+ return (
+
+
+
+
{title}
+
{description}
+
+ {children}
+ {footer &&
{footer}
}
+
+ Continue shopping
+
+ );
+}
diff --git a/components/auth/sign-in-prompt.tsx b/components/auth/sign-in-prompt.tsx
new file mode 100644
index 0000000..566581f
--- /dev/null
+++ b/components/auth/sign-in-prompt.tsx
@@ -0,0 +1,19 @@
+import Link from 'next/link';
+import React from 'react';
+
+export function SignInPrompt({ title = 'Sign in to continue', next = '/account' }: { title?: string; next?: string }) {
+ return (
+
+
+
+
{title}
+
Access your orders, saved addresses, and checkout details with your customer account.
+
+
+ Sign in
+ Create an account
+
+
+
+ );
+}
diff --git a/components/auth/status-message.tsx b/components/auth/status-message.tsx
new file mode 100644
index 0000000..6e152ea
--- /dev/null
+++ b/components/auth/status-message.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+export function StatusMessage({ message, tone = 'muted' }: { message?: string | null; tone?: 'muted' | 'error' | 'success' }) {
+ if (!message) {
+ return null;
+ }
+
+ const color = tone === 'error' ? 'var(--error)' : tone === 'success' ? 'var(--success)' : 'var(--muted)';
+
+ return {message}
;
+}
diff --git a/components/layout/header.tsx b/components/layout/header.tsx
index 3f5cef7..0a679a3 100644
--- a/components/layout/header.tsx
+++ b/components/layout/header.tsx
@@ -15,7 +15,7 @@ export function Header({ about }: { about?: StorefrontAbout | null }) {
Search
- Orders
+ Account
diff --git a/docs/fleetbase-setup.md b/docs/fleetbase-setup.md
index 7564710..f68cba3 100644
--- a/docs/fleetbase-setup.md
+++ b/docs/fleetbase-setup.md
@@ -34,6 +34,8 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
- Products, images, categories, and prices render in Storefront Web.
- Cart create, add, update, and remove flows work.
- Password and SMS sign-in work.
+- Account creation code request and registration work.
+- Account dashboard shows orders, saved places, and session profile details.
- Pickup checkout works.
- Delivery quote and checkout work for the target service area.
- Stripe prepare/capture creates a Fleetbase order.
diff --git a/docs/validation.md b/docs/validation.md
index 8707585..a162f28 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -21,8 +21,11 @@ Use this checklist before cutting `v0.1.0`.
- Password login sets a session.
- SMS request and verify set a session.
- Logout clears the session.
+- Account creation code request and account creation set a session.
+- `/login`, `/login/sms`, and `/register` stay focused on one auth flow each.
- Orders list loads for an authenticated customer.
- Order detail and tracking pages load when the API exposes the order.
+- Saved places load in the account Addresses tab when the API exposes them.
## Cart and Checkout
diff --git a/features/customer/account-dashboard-client.tsx b/features/customer/account-dashboard-client.tsx
new file mode 100644
index 0000000..7c4e60b
--- /dev/null
+++ b/features/customer/account-dashboard-client.tsx
@@ -0,0 +1,171 @@
+'use client';
+
+import Link from 'next/link';
+import React, { useEffect, useState } from 'react';
+import type { Customer, CustomerPlace, Order } from '@/lib/types';
+import { formatMoney } from '@/lib/format';
+
+type Tab = 'overview' | 'orders' | 'addresses' | 'profile';
+
+export function AccountDashboardClient({ initialCustomer }: { initialCustomer?: Customer | null }) {
+ const [tab, setTab] = useState('overview');
+ const [customer, setCustomer] = useState(initialCustomer ?? null);
+ const [orders, setOrders] = useState([]);
+ const [places, setPlaces] = useState([]);
+ const [ordersStatus, setOrdersStatus] = useState('Loading orders...');
+ const [placesStatus, setPlacesStatus] = useState('Loading saved addresses...');
+ const [status, setStatus] = useState(null);
+
+ useEffect(() => {
+ fetch('/api/storefront/auth/session')
+ .then((response) => response.json())
+ .then((payload) => setCustomer(payload.customer ?? initialCustomer ?? null));
+
+ fetch('/api/storefront/orders')
+ .then((response) => response.json())
+ .then((payload) => {
+ setOrders(payload.orders ?? []);
+ setOrdersStatus(payload.error ?? '');
+ });
+
+ fetch('/api/storefront/customer/places')
+ .then((response) => response.json())
+ .then((payload) => {
+ setPlaces(payload.places ?? []);
+ setPlacesStatus(payload.error ?? '');
+ });
+ }, [initialCustomer]);
+
+ async function logout() {
+ const response = await fetch('/api/storefront/auth/logout', { method: 'POST' });
+ if (response.ok) {
+ window.location.href = '/login';
+ return;
+ }
+ setStatus('Unable to sign out.');
+ }
+
+ return (
+
+
+
+
Your account
+
Manage orders, saved addresses, and customer details.
+
+
Sign out
+
+
+
+
+ {([
+ ['overview', 'Overview'],
+ ['orders', 'Orders'],
+ ['addresses', 'Addresses'],
+ ['profile', 'Profile']
+ ] as [Tab, string][]).map(([value, label]) => (
+ setTab(value)}>
+ {label}
+
+ ))}
+
+
+
+ {tab === 'overview' && (
+ <>
+
Welcome back{customer?.name ? `, ${customer.name}` : ''}
+
+
+
+
+
+ >
+ )}
+
+ {tab === 'orders' && (
+ <>
+
Orders
+ {ordersStatus &&
{ordersStatus}
}
+ {!ordersStatus && orders.length === 0 &&
}
+
+ {orders.map((order) => (
+
+
+
{order.public_id ?? order.id}
+
{order.status ?? 'Pending'} {order.created_at ? `- ${new Date(order.created_at).toLocaleString()}` : ''}
+ {order.total !== undefined &&
{formatMoney(order.total, order.currency)}
}
+
+ View
+
+ ))}
+
+ >
+ )}
+
+ {tab === 'addresses' && (
+ <>
+
Saved addresses
+ {placesStatus &&
{placesStatus}
}
+ {!placesStatus && places.length === 0 &&
}
+
+ {places.map((place) => (
+
+
+
{place.name ?? place.address ?? 'Saved address'}
+
{formatPlace(place)}
+
+
+ ))}
+
+ >
+ )}
+
+ {tab === 'profile' && (
+ <>
+
Profile
+
+
Profile editing, password reset, and saved payment methods will be enabled once the Storefront API exposes those customer management endpoints.
+ {status &&
{status}
}
+ >
+ )}
+
+
+
+ );
+}
+
+function SummaryCard({ label, value, description }: { label: string; value: string; description: string }) {
+ return (
+
+ {label}
+ {value}
+ {description}
+
+ );
+}
+
+function EmptyState({ title, description }: { title: string; description: string }) {
+ return (
+
+
{title}
+
{description}
+
+ );
+}
+
+function ProfileField({ label, value }: { label: string; value?: string }) {
+ return (
+
+ {label}
+ {value ?? 'Not provided'}
+
+ );
+}
+
+function formatPlace(place: CustomerPlace) {
+ return [place.street1, place.city, place.province, place.postal_code, place.country].filter(Boolean).join(', ') || place.address || 'Address details unavailable';
+}
diff --git a/features/customer/account-page-client.tsx b/features/customer/account-page-client.tsx
deleted file mode 100644
index 4a75675..0000000
--- a/features/customer/account-page-client.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import { useEffect, useState } from 'react';
-import type { Customer } from '@/lib/types';
-
-export function AccountPageClient() {
- const [authenticated, setAuthenticated] = useState(false);
- const [customer, setCustomer] = useState(null);
- const [identity, setIdentity] = useState('');
- const [password, setPassword] = useState('');
- const [phone, setPhone] = useState('');
- const [code, setCode] = useState('');
- const [status, setStatus] = useState(null);
- const [smsRequested, setSmsRequested] = useState(false);
-
- useEffect(() => {
- fetch('/api/storefront/auth/session')
- .then((response) => response.json())
- .then((payload) => {
- setAuthenticated(Boolean(payload.authenticated));
- setCustomer(payload.customer ?? null);
- });
- }, []);
-
- async function login(event: React.FormEvent) {
- event.preventDefault();
- const response = await fetch('/api/storefront/auth/login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ identity, password })
- });
- const payload = await response.json();
- if (response.ok) {
- setAuthenticated(true);
- setCustomer(payload.customer ?? null);
- }
- setStatus(response.ok ? `Signed in as ${payload.customer?.name ?? payload.customer?.email ?? identity}` : payload.error);
- }
-
- async function requestSms(event: React.FormEvent) {
- event.preventDefault();
- const response = await fetch('/api/storefront/auth/sms', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ phone })
- });
- const payload = await response.json();
- setSmsRequested(response.ok);
- setStatus(response.ok ? 'Verification code sent.' : payload.error);
- }
-
- async function verifySms(event: React.FormEvent) {
- event.preventDefault();
- const response = await fetch('/api/storefront/auth/sms', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ phone, code })
- });
- const payload = await response.json();
- if (response.ok) {
- setAuthenticated(true);
- setCustomer(payload.customer ?? null);
- }
- setStatus(response.ok ? `Signed in as ${payload.customer?.name ?? payload.customer?.phone ?? phone}` : payload.error);
- }
-
- async function logout() {
- const response = await fetch('/api/storefront/auth/logout', { method: 'POST' });
- if (response.ok) {
- setAuthenticated(false);
- setCustomer(null);
- }
- setStatus(response.ok ? 'Signed out.' : 'Unable to sign out.');
- }
-
- return (
-
-
-
-
Account
-
Sign in to view order history and saved checkout details.
-
- {authenticated && (
-
- Signed in
-
- {customer?.name ?? customer?.email ?? customer?.phone ?? customer?.id ?? 'Your customer session is active.'}
-
-
- View orders
- Sign out
-
-
- )}
-
-
-
-
- {!authenticated && Clear session }
- {status && {status}
}
-
-
- );
-}
diff --git a/features/customer/login-page-client.tsx b/features/customer/login-page-client.tsx
new file mode 100644
index 0000000..b642047
--- /dev/null
+++ b/features/customer/login-page-client.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter, useSearchParams } from 'next/navigation';
+import React, { useState } from 'react';
+import { AuthShell } from '@/components/auth/auth-shell';
+import { StatusMessage } from '@/components/auth/status-message';
+
+export function LoginPageClient() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const next = searchParams.get('next') ?? '/account';
+ const [identity, setIdentity] = useState('');
+ const [password, setPassword] = useState('');
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function login(event: React.FormEvent) {
+ event.preventDefault();
+ setLoading(true);
+ setStatus(null);
+ const response = await fetch('/api/storefront/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ identity, password })
+ });
+ const payload = await response.json();
+ setLoading(false);
+
+ if (!response.ok) {
+ setStatus(payload.error ?? 'Unable to sign in.');
+ return;
+ }
+
+ router.push(next);
+ router.refresh();
+ }
+
+ return (
+ New customer? Create an account}
+ >
+
+
+ Login via SMS
+ Continue shopping
+
+
+ );
+}
diff --git a/features/customer/register-page-client.tsx b/features/customer/register-page-client.tsx
new file mode 100644
index 0000000..3b3391b
--- /dev/null
+++ b/features/customer/register-page-client.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter, useSearchParams } from 'next/navigation';
+import React, { useState } from 'react';
+import { AuthShell } from '@/components/auth/auth-shell';
+import { StatusMessage } from '@/components/auth/status-message';
+
+export function RegisterPageClient() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const next = searchParams.get('next') ?? '/account';
+ const [identity, setIdentity] = useState('');
+ const [name, setName] = useState('');
+ const [password, setPassword] = useState('');
+ const [code, setCode] = useState('');
+ const [requested, setRequested] = useState(false);
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ setLoading(true);
+ setStatus(null);
+ const response = await fetch('/api/storefront/auth/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requested ? { identity, code, name, password } : { identity, mode: identity.includes('@') ? 'email' : 'sms' })
+ });
+ const payload = await response.json();
+ setLoading(false);
+
+ if (!response.ok) {
+ setStatus(payload.error ?? 'Unable to create account.');
+ return;
+ }
+
+ if (!requested) {
+ setRequested(true);
+ setStatus('Verification code sent.');
+ return;
+ }
+
+ router.push(next);
+ router.refresh();
+ }
+
+ return (
+ Already have an account? Sign in}
+ >
+
+
+ );
+}
diff --git a/features/customer/sms-login-page-client.tsx b/features/customer/sms-login-page-client.tsx
new file mode 100644
index 0000000..fe6a40b
--- /dev/null
+++ b/features/customer/sms-login-page-client.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter, useSearchParams } from 'next/navigation';
+import React, { useState } from 'react';
+import { AuthShell } from '@/components/auth/auth-shell';
+import { StatusMessage } from '@/components/auth/status-message';
+
+export function SmsLoginPageClient() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const next = searchParams.get('next') ?? '/account';
+ const [phone, setPhone] = useState('');
+ const [code, setCode] = useState('');
+ const [requested, setRequested] = useState(false);
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ setLoading(true);
+ setStatus(null);
+ const response = await fetch('/api/storefront/auth/sms', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requested ? { phone, code } : { phone })
+ });
+ const payload = await response.json();
+ setLoading(false);
+
+ if (!response.ok) {
+ setStatus(payload.error ?? 'Unable to process SMS login.');
+ return;
+ }
+
+ if (!requested) {
+ setRequested(true);
+ setStatus('Verification code sent.');
+ return;
+ }
+
+ router.push(next);
+ router.refresh();
+ }
+
+ return (
+ Prefer password? Sign in with password}
+ >
+
+
+ );
+}
diff --git a/lib/customer-session.ts b/lib/customer-session.ts
new file mode 100644
index 0000000..c9baf40
--- /dev/null
+++ b/lib/customer-session.ts
@@ -0,0 +1,24 @@
+import type { NextResponse } from 'next/server';
+import type { Customer } from '@/lib/types';
+import { sessionCookieOptions } from '@/lib/runtime';
+
+export function setCustomerCookies(response: NextResponse, customer?: Customer | null) {
+ if (!customer?.id) {
+ return;
+ }
+
+ const options = sessionCookieOptions();
+ response.cookies.set('customer_id', customer.id, options);
+
+ if (customer.name) {
+ response.cookies.set('customer_name', customer.name, options);
+ }
+
+ if (customer.email) {
+ response.cookies.set('customer_email', customer.email, options);
+ }
+
+ if (customer.phone) {
+ response.cookies.set('customer_phone', customer.phone, options);
+ }
+}
diff --git a/lib/fleetbase/storefront.ts b/lib/fleetbase/storefront.ts
index 129547d..6d4adcb 100644
--- a/lib/fleetbase/storefront.ts
+++ b/lib/fleetbase/storefront.ts
@@ -1,6 +1,7 @@
import type {
Cart,
Customer,
+ CustomerPlace,
NetworkStore,
Order,
PaymentGateway,
@@ -71,6 +72,18 @@ export async function verifyCustomerCode(body: unknown) {
return storefrontRequest<{ token: string; customer: Customer }>('customers/verify-code', { method: 'POST', body, customerToken: null });
}
+export async function requestCustomerCreationCode(body: unknown) {
+ return storefrontRequest<{ ok: boolean }>('customers/request-creation-code', { method: 'POST', body, customerToken: null });
+}
+
+export async function createCustomer(body: unknown) {
+ return storefrontRequest<{ token: string; customer: Customer }>('customers', { method: 'POST', body, customerToken: null });
+}
+
+export async function getCustomerPlaces() {
+ return storefrontRequest('customers/places', { cache: 'no-store' });
+}
+
export async function getCustomerOrders() {
return storefrontRequest('customers/orders', { cache: 'no-store' });
}
diff --git a/lib/types.ts b/lib/types.ts
index 3903a23..713884f 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -115,6 +115,20 @@ export type Customer = {
token?: string;
};
+export type CustomerPlace = {
+ id: string;
+ name?: string;
+ address?: string;
+ street1?: string;
+ city?: string;
+ province?: string;
+ postal_code?: string;
+ country?: string;
+ latitude?: number;
+ longitude?: number;
+ meta?: Record;
+};
+
export type PaymentGateway = {
id: string;
code?: string;
diff --git a/tests/customer-ui.test.tsx b/tests/customer-ui.test.tsx
new file mode 100644
index 0000000..ecde1ef
--- /dev/null
+++ b/tests/customer-ui.test.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it, vi } from 'vitest';
+import { AccountDashboardClient } from '@/features/customer/account-dashboard-client';
+import { LoginPageClient } from '@/features/customer/login-page-client';
+import { SmsLoginPageClient } from '@/features/customer/sms-login-page-client';
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ refresh: vi.fn()
+ }),
+ useSearchParams: () => new URLSearchParams()
+}));
+
+describe('customer auth and account UI', () => {
+ it('renders password login as the focused default auth page', () => {
+ const html = renderToStaticMarkup( );
+
+ expect(html).toContain('Sign in');
+ expect(html).toContain('Password');
+ expect(html).toContain('Login via SMS');
+ expect(html).not.toContain('Verification code');
+ });
+
+ it('renders SMS login as a separate phone-first flow', () => {
+ const html = renderToStaticMarkup( );
+
+ expect(html).toContain('Login via SMS');
+ expect(html).toContain('Phone number');
+ expect(html).not.toContain('Password');
+ expect(html).not.toContain('Verification code');
+ });
+
+ it('renders account dashboard tabs instead of login forms', () => {
+ const html = renderToStaticMarkup( );
+
+ expect(html).toContain('Your account');
+ expect(html).toContain('Overview');
+ expect(html).toContain('Orders');
+ expect(html).toContain('Addresses');
+ expect(html).toContain('Profile');
+ expect(html).not.toContain('Password sign in');
+ });
+});
diff --git a/tests/storefront-flow.test.ts b/tests/storefront-flow.test.ts
index 684e8be..5bd626a 100644
--- a/tests/storefront-flow.test.ts
+++ b/tests/storefront-flow.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { addCartItem, beforeCheckout, captureCheckout, getCart, getProduct, getProducts } from '@/lib/fleetbase/storefront';
+import { addCartItem, beforeCheckout, captureCheckout, createCustomer, getCart, getProduct, getProducts, requestCustomerCreationCode, verifyCustomerCode } from '@/lib/fleetbase/storefront';
vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({
@@ -41,6 +41,18 @@ describe('mocked storefront customer flow', () => {
return Response.json({ id: 'order_1', status: 'created' });
}
+ if (url.endsWith('/storefront/v1/customers/request-creation-code') && method === 'POST') {
+ return Response.json({ ok: true });
+ }
+
+ if (url.endsWith('/storefront/v1/customers/verify-code') && method === 'POST') {
+ return Response.json({ token: 'customer_token', customer: { id: 'customer_1' } });
+ }
+
+ if (url.endsWith('/storefront/v1/customers') && method === 'POST') {
+ return Response.json({ token: 'customer_token', customer: { id: 'customer_1' } });
+ }
+
return Response.json({ message: `Unhandled ${method} ${url}` }, { status: 404 });
}));
});
@@ -57,5 +69,8 @@ describe('mocked storefront customer flow', () => {
await expect(addCartItem('cart_1', 'prod_1', { quantity: 1 })).resolves.toMatchObject({ items: [{ id: 'line_1' }] });
await expect(beforeCheckout({ gateway: 'stripe', cart: 'cart_1', pickup: true })).resolves.toMatchObject({ token: 'checkout_token' });
await expect(captureCheckout({ token: 'checkout_token' })).resolves.toMatchObject({ id: 'order_1' });
+ await expect(requestCustomerCreationCode({ identity: 'test@example.com', mode: 'email' })).resolves.toMatchObject({ ok: true });
+ await expect(verifyCustomerCode({ phone: '+15555550100', code: '123456' })).resolves.toMatchObject({ customer: { id: 'customer_1' } });
+ await expect(createCustomer({ identity: 'test@example.com', code: '123456' })).resolves.toMatchObject({ customer: { id: 'customer_1' } });
});
});
diff --git a/tests/storefront-routes.test.ts b/tests/storefront-routes.test.ts
index 0b47f12..cdb8902 100644
--- a/tests/storefront-routes.test.ts
+++ b/tests/storefront-routes.test.ts
@@ -18,11 +18,16 @@ vi.mock('@/lib/fleetbase/storefront', () => ({
addCartItem: vi.fn(),
beforeCheckout: vi.fn(),
captureCheckout: vi.fn(),
+ createCustomer: vi.fn(),
getCart: vi.fn(),
getCustomerOrder: vi.fn(),
+ getCustomerPlaces: vi.fn(),
loginCustomer: vi.fn(),
removeCartItem: vi.fn(),
- updateCartItem: vi.fn()
+ requestCustomerCreationCode: vi.fn(),
+ requestSmsLogin: vi.fn(),
+ updateCartItem: vi.fn(),
+ verifyCustomerCode: vi.fn()
}));
describe('storefront route handlers', () => {
@@ -77,6 +82,103 @@ describe('storefront route handlers', () => {
expect(payload.customer.id).toBe('customer_1');
expect(response.headers.get('set-cookie')).toContain('customer_token=customer_token');
+ expect(response.headers.get('set-cookie')).toContain('customer_email=test%40example.com');
+ });
+
+ it('requests SMS login without setting session cookies', async () => {
+ const { requestSmsLogin } = await import('@/lib/fleetbase/storefront');
+ const { POST } = await import('@/app/api/storefront/auth/sms/route');
+ vi.mocked(requestSmsLogin).mockResolvedValue({ ok: true });
+
+ const response = await POST(new Request('http://localhost/api/storefront/auth/sms', {
+ method: 'POST',
+ body: JSON.stringify({ phone: '+15555550100' })
+ }));
+ const payload = await response.json();
+
+ expect(payload.ok).toBe(true);
+ expect(response.headers.get('set-cookie')).toBeNull();
+ });
+
+ it('sets customer cookies after SMS verification', async () => {
+ const { verifyCustomerCode } = await import('@/lib/fleetbase/storefront');
+ const { POST } = await import('@/app/api/storefront/auth/sms/route');
+ vi.mocked(verifyCustomerCode).mockResolvedValue({ token: 'customer_token', customer: { id: 'customer_1', phone: '+15555550100' } });
+
+ const response = await POST(new Request('http://localhost/api/storefront/auth/sms', {
+ method: 'POST',
+ body: JSON.stringify({ phone: '+15555550100', code: '123456' })
+ }));
+
+ expect(response.headers.get('set-cookie')).toContain('customer_token=customer_token');
+ expect(response.headers.get('set-cookie')).toContain('customer_phone=%2B15555550100');
+ });
+
+ it('requests an account creation code', async () => {
+ const { requestCustomerCreationCode } = await import('@/lib/fleetbase/storefront');
+ const { POST } = await import('@/app/api/storefront/auth/register/route');
+ vi.mocked(requestCustomerCreationCode).mockResolvedValue({ ok: true });
+
+ const response = await POST(new Request('http://localhost/api/storefront/auth/register', {
+ method: 'POST',
+ body: JSON.stringify({ identity: 'test@example.com', mode: 'email' })
+ }));
+ const payload = await response.json();
+
+ expect(payload.ok).toBe(true);
+ expect(requestCustomerCreationCode).toHaveBeenCalledWith({ identity: 'test@example.com', mode: 'email' });
+ });
+
+ it('creates an account and sets customer cookies', async () => {
+ const { createCustomer } = await import('@/lib/fleetbase/storefront');
+ const { POST } = await import('@/app/api/storefront/auth/register/route');
+ vi.mocked(createCustomer).mockResolvedValue({ token: 'customer_token', customer: { id: 'customer_1', name: 'Test Customer' } });
+
+ const response = await POST(new Request('http://localhost/api/storefront/auth/register', {
+ method: 'POST',
+ body: JSON.stringify({ identity: 'test@example.com', code: '123456', name: 'Test Customer' })
+ }));
+ const payload = await response.json();
+
+ expect(payload.customer.id).toBe('customer_1');
+ expect(response.headers.get('set-cookie')).toContain('customer_token=customer_token');
+ expect(response.headers.get('set-cookie')).toContain('customer_name=Test%20Customer');
+ });
+
+ it('reports authenticated session state from cookies', async () => {
+ const { GET } = await import('@/app/api/storefront/auth/session/route');
+ cookieValues.set('customer_token', 'customer_token');
+ cookieValues.set('customer_id', 'customer_1');
+ cookieValues.set('customer_email', 'test@example.com');
+
+ const response = await GET();
+ const payload = await response.json();
+
+ expect(payload).toMatchObject({
+ authenticated: true,
+ customer: { id: 'customer_1', email: 'test@example.com' }
+ });
+ });
+
+ it('clears customer cookies on logout', async () => {
+ const { POST } = await import('@/app/api/storefront/auth/logout/route');
+
+ const response = await POST();
+
+ expect(response.headers.get('set-cookie')).toContain('customer_token=');
+ expect(response.headers.get('set-cookie')).toContain('customer_id=');
+ expect(response.headers.get('set-cookie')).toContain('customer_email=');
+ });
+
+ it('loads saved customer places', async () => {
+ const { getCustomerPlaces } = await import('@/lib/fleetbase/storefront');
+ const { GET } = await import('@/app/api/storefront/customer/places/route');
+ vi.mocked(getCustomerPlaces).mockResolvedValue([{ id: 'place_1', name: 'Home', address: '100 Main Street' }]);
+
+ const response = await GET();
+ const payload = await response.json();
+
+ expect(payload.places).toEqual([{ id: 'place_1', name: 'Home', address: '100 Main Street' }]);
});
it('prepares checkout with the customer cookie fallback', async () => {
From 35d98feae7532334b2ed79da6b497c50f40bffb6 Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Sun, 30 Aug 2026 21:02:18 +0800
Subject: [PATCH 3/7] Harden storefront web for production release
---
.dockerignore | 15 +-
.env.example | 9 +-
.github/workflows/ci.yml | 118 +-
.github/workflows/codeql.yml | 22 +
.github/workflows/create-release.yml | 24 +
.github/workflows/dependency-review.yml | 15 +
.github/workflows/docker.yml | 34 +
.github/workflows/release.yml | 20 +
.gitignore | 2 +
CHANGELOG.md | 7 +
Dockerfile | 26 +-
README.md | 36 +-
RELEASE.md | 13 +
app/api/health/route.ts | 2 +-
app/api/storefront/auth/login/route.ts | 20 +-
app/api/storefront/auth/logout/route.ts | 7 +-
app/api/storefront/auth/register/route.ts | 29 +-
app/api/storefront/auth/session/route.ts | 21 +-
app/api/storefront/auth/sms/route.ts | 26 +-
app/api/storefront/cart/items/route.ts | 17 +-
app/api/storefront/cart/route.ts | 17 +-
app/api/storefront/checkout/capture/route.ts | 15 +-
app/api/storefront/checkout/prepare/route.ts | 11 +-
app/api/storefront/customer/places/route.ts | 3 +-
app/api/storefront/customer/profile/route.ts | 23 +
app/api/storefront/gateways/route.ts | 3 +-
app/api/storefront/orders/[id]/route.ts | 3 +-
app/api/storefront/orders/route.ts | 13 +-
app/api/storefront/service-quote/route.ts | 6 +-
app/api/storefront/store-locations/route.ts | 16 +
app/error.tsx | 17 +
app/loading.tsx | 3 +
app/not-found.tsx | 11 +
app/page.tsx | 14 +-
app/products/[id]/page.tsx | 3 +
app/robots.ts | 4 +-
app/search/page.tsx | 6 +-
app/sitemap.ts | 21 +-
app/stores/[id]/page.tsx | 2 +
app/stores/page.tsx | 3 +
codecov.yml | 19 +
components/catalog/product-card.tsx | 2 +-
components/layout/header.tsx | 2 +-
docker-compose.example.yml | 14 +-
docs/architecture.md | 11 +
docs/deployment.md | 17 +-
docs/operations.md | 13 +
docs/release.md | 11 +-
docs/releasing.md | 11 +
docs/validation.md | 45 +-
eslint.config.mjs | 35 +-
features/cart/add-to-cart-form.tsx | 10 +-
features/cart/cart-page-client.tsx | 14 +
features/checkout/checkout-page-client.tsx | 257 +-
features/checkout/stripe-payment-form.tsx | 62 +
.../customer/account-dashboard-client.tsx | 55 +-
features/customer/login-page-client.tsx | 9 +-
features/customer/register-page-client.tsx | 9 +-
features/customer/sms-login-page-client.tsx | 9 +-
lib/api.ts | 56 +
lib/contracts.ts | 175 ++
lib/customer-session.ts | 8 +
lib/fleetbase/server.ts | 32 +-
lib/fleetbase/storefront.ts | 101 +-
lib/format.ts | 8 +-
lib/http.ts | 11 +-
lib/navigation.ts | 10 +
lib/rate-limit.ts | 55 +
lib/runtime.ts | 18 +-
lib/types.ts | 24 +
middleware.ts | 9 +-
next.config.mjs | 13 +-
package.json | 45 +-
playwright.config.ts | 44 +
plugins/analytics.tsx | 13 +-
plugins/registry.ts | 38 +-
pnpm-lock.yaml | 2500 +++++++++++------
pnpm-workspace.yaml | 11 +-
scripts/coverage-uncovered.mjs | 12 +
storefront.config.ts | 4 +-
tests/checkout-interactions.test.tsx | 211 ++
tests/client-interactions.test.tsx | 273 ++
tests/component-rendering.test.tsx | 70 +
tests/customer-ui.test.tsx | 1 +
tests/e2e/mock-fleetbase.mjs | 56 +
tests/e2e/storefront.spec.ts | 155 +
tests/format.test.ts | 8 +-
tests/health-route.test.ts | 16 +-
tests/lib-hardening.test.ts | 163 ++
tests/middleware.test.ts | 39 +
tests/next-config.test.ts | 57 +
tests/page-rendering.test.tsx | 189 ++
tests/plugins.test.tsx | 63 +-
tests/route-boundaries.test.ts | 159 ++
tests/runtime.test.ts | 7 +-
tests/storefront-client.test.ts | 62 +
tests/storefront-flow.test.ts | 18 +-
tests/storefront-routes.test.ts | 27 +-
vitest.config.ts | 22 +-
99 files changed, 4840 insertions(+), 1205 deletions(-)
create mode 100644 .github/workflows/codeql.yml
create mode 100644 .github/workflows/create-release.yml
create mode 100644 .github/workflows/dependency-review.yml
create mode 100644 .github/workflows/docker.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 RELEASE.md
create mode 100644 app/api/storefront/customer/profile/route.ts
create mode 100644 app/api/storefront/store-locations/route.ts
create mode 100644 app/error.tsx
create mode 100644 app/loading.tsx
create mode 100644 app/not-found.tsx
create mode 100644 codecov.yml
create mode 100644 docs/architecture.md
create mode 100644 docs/operations.md
create mode 100644 docs/releasing.md
create mode 100644 features/checkout/stripe-payment-form.tsx
create mode 100644 lib/api.ts
create mode 100644 lib/contracts.ts
create mode 100644 lib/navigation.ts
create mode 100644 lib/rate-limit.ts
create mode 100644 playwright.config.ts
create mode 100644 scripts/coverage-uncovered.mjs
create mode 100644 tests/checkout-interactions.test.tsx
create mode 100644 tests/client-interactions.test.tsx
create mode 100644 tests/component-rendering.test.tsx
create mode 100644 tests/e2e/mock-fleetbase.mjs
create mode 100644 tests/e2e/storefront.spec.ts
create mode 100644 tests/lib-hardening.test.ts
create mode 100644 tests/middleware.test.ts
create mode 100644 tests/next-config.test.ts
create mode 100644 tests/page-rendering.test.tsx
create mode 100644 tests/route-boundaries.test.ts
create mode 100644 tests/storefront-client.test.ts
diff --git a/.dockerignore b/.dockerignore
index 17695a0..9300c79 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,11 +1,12 @@
+.git
+.github
.next
-node_modules
coverage
-dist
+node_modules
+playwright-report
+test-results
.env
-.env*.local
-npm-debug.log*
-yarn-debug.log*
-pnpm-debug.log*
-.DS_Store
+.env.*
+!.env.example
+*.log
*.tsbuildinfo
diff --git a/.env.example b/.env.example
index d3e3fe6..db7cf21 100644
--- a/.env.example
+++ b/.env.example
@@ -1,17 +1,22 @@
# Required Fleetbase Storefront settings
FLEETBASE_HOST=https://api.fleetbase.io
-STOREFRONT_KEY=store_xxx_or_network_xxx
+STOREFRONT_KEY=store_xxx
# Public website settings
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_DEFAULT_LOCALE=en
NEXT_PUBLIC_DEFAULT_THEME=default
-NEXT_PUBLIC_IMAGE_HOSTS=api.fleetbase.io
+NEXT_PUBLIC_IMAGE_HOSTS=api.fleetbase.io,flb-assets.s3.ap-southeast-1.amazonaws.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
NEXT_PUBLIC_PROMO_BANNER=
NEXT_PUBLIC_CHECKOUT_MESSAGE=
STOREFRONT_ANALYTICS_WEBHOOK_URL=
+# Required for rate limiting when production runs more than one replica.
+# The service must accept the JSON contract documented in docs/operations.md.
+RATE_LIMIT_REST_URL=
+RATE_LIMIT_REST_TOKEN=
+
# Optional real-time tracking
NEXT_PUBLIC_SOCKETCLUSTER_HOST=
NEXT_PUBLIC_SOCKETCLUSTER_PORT=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cf8f68a..eae8c84 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,24 +2,120 @@ name: CI
on:
pull_request:
+ branches: [main, 'dev-v*']
push:
- branches:
- - main
+ branches: [main, 'dev-v*']
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ id-token: write
+
+env:
+ PNPM_VERSION: 10.15.1
+ NODE_VERSION: 22
jobs:
- verify:
+ install:
+ name: install-reproducibility
runs-on: ubuntu-latest
+ timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- with:
- version: 10
+ with: { version: '${{ env.PNPM_VERSION }}' }
- uses: actions/setup-node@v4
+ with: { node-version: '${{ env.NODE_VERSION }}', cache: pnpm }
+ - run: pnpm install --frozen-lockfile
+ - run: git diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
+
+ quality:
+ name: ${{ matrix.check }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ check: [lint, typecheck, unit-tests, production-build]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with: { version: '${{ env.PNPM_VERSION }}' }
+ - uses: actions/setup-node@v4
+ with: { node-version: '${{ env.NODE_VERSION }}', cache: pnpm }
+ - run: pnpm install --frozen-lockfile
+ - if: matrix.check == 'lint'
+ run: pnpm lint
+ - if: matrix.check == 'typecheck'
+ run: pnpm typecheck
+ - if: matrix.check == 'unit-tests'
+ run: pnpm test
+ - if: matrix.check == 'production-build'
+ env:
+ STOREFRONT_KEY: store_ci_fixture
+ FLEETBASE_HOST: http://127.0.0.1:4010
+ NEXT_PUBLIC_SITE_URL: http://127.0.0.1:3000
+ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: pk_test_fixture
+ run: pnpm build
+
+ coverage:
+ name: coverage-100
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with: { version: '${{ env.PNPM_VERSION }}' }
+ - uses: actions/setup-node@v4
+ with: { node-version: '${{ env.NODE_VERSION }}', cache: pnpm }
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm coverage:check
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with: { name: coverage-report, path: coverage, if-no-files-found: error }
+ - uses: codecov/codecov-action@v5
with:
- node-version: 22
- cache: pnpm
+ use_oidc: true
+ files: coverage/lcov.info
+ flags: web
+ disable_search: true
+ fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+
+ e2e:
+ name: playwright-e2e
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with: { version: '${{ env.PNPM_VERSION }}' }
+ - uses: actions/setup-node@v4
+ with: { node-version: '${{ env.NODE_VERSION }}', cache: pnpm }
- run: pnpm install --frozen-lockfile
- - run: pnpm lint
- - run: pnpm typecheck
- - run: pnpm test
- - run: pnpm build
+ - run: pnpm exec playwright install --with-deps chromium
+ - run: pnpm test:e2e
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with: { name: playwright-report, path: playwright-report, if-no-files-found: ignore }
+
+ docker:
+ name: docker-smoke
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - run: docker build --build-arg NEXT_PUBLIC_SITE_URL=https://storefront.example --build-arg NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_fixture --tag storefront-web:ci .
+ - run: test "$(docker image inspect storefront-web:ci --format '{{.Config.User}}')" = storefront
+ - run: docker run --detach --name storefront-web-ci --publish 3000:3000 --env STOREFRONT_KEY=store_ci_fixture --env FLEETBASE_HOST=https://api.fleetbase.io --env NEXT_PUBLIC_SITE_URL=https://storefront.example storefront-web:ci
+ - name: Wait for container health
+ run: |
+ for _ in {1..30}; do
+ curl --fail --silent --show-error http://127.0.0.1:3000/api/health && exit 0
+ sleep 1
+ done
+ docker logs storefront-web-ci
+ exit 1
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..b1ba2c6
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,22 @@
+name: CodeQL
+on:
+ pull_request:
+ branches: [main, 'dev-v*']
+ push:
+ branches: [main, 'dev-v*']
+ schedule:
+ - cron: '23 4 * * 1'
+ workflow_dispatch:
+permissions:
+ contents: read
+ security-events: write
+jobs:
+ analyze:
+ name: codeql-javascript-typescript
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: github/codeql-action/init@v3
+ with: { languages: javascript-typescript }
+ - uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
new file mode 100644
index 0000000..3c06d36
--- /dev/null
+++ b/.github/workflows/create-release.yml
@@ -0,0 +1,24 @@
+name: Create Release
+on:
+ push:
+ tags: ['v*']
+permissions:
+ contents: write
+jobs:
+ create:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - name: Determine prerelease status
+ id: version
+ env: { TAG: '${{ github.ref_name }}' }
+ run: if [[ "$TAG" == *-* ]]; then echo 'prerelease=true' >> "$GITHUB_OUTPUT"; else echo 'prerelease=false' >> "$GITHUB_OUTPUT"; fi
+ - uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ name: ${{ github.ref_name }}
+ body_path: RELEASE.md
+ generate_release_notes: true
+ draft: false
+ prerelease: ${{ steps.version.outputs.prerelease }}
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
new file mode 100644
index 0000000..6b190d0
--- /dev/null
+++ b/.github/workflows/dependency-review.yml
@@ -0,0 +1,15 @@
+name: Dependency Review
+on:
+ pull_request:
+permissions:
+ contents: read
+jobs:
+ dependency-review:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/dependency-review-action@v4
+ with:
+ fail-on-severity: high
+ deny-licenses: GPL-2.0, LGPL-2.0, SSPL-1.0
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..0d35826
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,34 @@
+name: Container Security
+on:
+ pull_request:
+ branches: [main, 'dev-v*']
+ push:
+ branches: [main, 'dev-v*']
+ workflow_dispatch:
+permissions:
+ contents: read
+ security-events: write
+jobs:
+ scan:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ load: true
+ push: false
+ tags: storefront-web:scan
+ - uses: aquasecurity/trivy-action@0.33.1
+ with:
+ image-ref: storefront-web:scan
+ format: sarif
+ output: trivy-results.sarif
+ severity: HIGH,CRITICAL
+ ignore-unfixed: true
+ exit-code: '1'
+ - uses: github/codeql-action/upload-sarif@v3
+ if: always()
+ with: { sarif_file: trivy-results.sarif }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..23d6d2c
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,20 @@
+name: Release Tag
+on:
+ pull_request:
+ types: [closed]
+ branches: [main]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to tag, e.g. 0.1.0 (recovery only)'
+ required: true
+permissions:
+ contents: read
+jobs:
+ tag:
+ if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'dev-v'))
+ uses: fleetbase/fleetbase/.github/workflows/release-tag.yml@main
+ with:
+ version-files: package.json
+ version: ${{ github.event.inputs.version || '' }}
+ secrets: inherit
diff --git a/.gitignore b/.gitignore
index cdc725c..d544b0c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
node_modules
dist
coverage
+playwright-report
+test-results
.env
.env*.local
!.env.example
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b066c5..6666059 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,3 +8,10 @@
- Added a stores page, cart quantity updates, SMS sign-in UI, logout route, and example promo banner plugin.
- Added session awareness, order detail/tracking pages, catalog filters, category/store pages, plugin/theme/deployment docs, and security headers.
- Refactored customer authentication into dedicated login, SMS login, registration, and account dashboard surfaces.
+- Corrected backend authentication and SMS contracts, validated runtime API payloads, and added stale-session clearing, rate limits, body limits, and safe redirects.
+- Added customer-facing delivery selectors and Stripe Payment Element confirmation.
+- Added 100% coverage gates, Codecov, Playwright, CodeQL, dependency review, Docker security checks, and Fleetbase `dev-v*` release workflows.
+- Disabled incomplete marketplace mode for the single-store v0.1 scope and documented upstream checkout blockers.
+- Enabled authenticated profile editing and cash pickup after the Storefront API added token-owner authorization and quote-free cash pickup support.
+- Verified Stripe capture against the checkout-owned PaymentIntent and documented idempotent concurrent capture behavior.
+- Corrected Fleetbase asset image allowlisting, sale-price selection, and canonical product links to use API-resolvable public IDs.
diff --git a/Dockerfile b/Dockerfile
index 40c2568..3bd3326 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,6 +9,26 @@ RUN pnpm install --frozen-lockfile
FROM base AS builder
RUN corepack enable
+ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
+ARG NEXT_PUBLIC_DEFAULT_LOCALE=en
+ARG NEXT_PUBLIC_DEFAULT_THEME=default
+ARG NEXT_PUBLIC_IMAGE_HOSTS=api.fleetbase.io,flb-assets.s3.ap-southeast-1.amazonaws.com
+ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
+ARG NEXT_PUBLIC_PROMO_BANNER
+ARG NEXT_PUBLIC_CHECKOUT_MESSAGE
+ARG NEXT_PUBLIC_SOCKETCLUSTER_HOST
+ARG NEXT_PUBLIC_SOCKETCLUSTER_PORT
+ARG NEXT_PUBLIC_SOCKETCLUSTER_SECURE=true
+ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL} \
+ NEXT_PUBLIC_DEFAULT_LOCALE=${NEXT_PUBLIC_DEFAULT_LOCALE} \
+ NEXT_PUBLIC_DEFAULT_THEME=${NEXT_PUBLIC_DEFAULT_THEME} \
+ NEXT_PUBLIC_IMAGE_HOSTS=${NEXT_PUBLIC_IMAGE_HOSTS} \
+ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} \
+ NEXT_PUBLIC_PROMO_BANNER=${NEXT_PUBLIC_PROMO_BANNER} \
+ NEXT_PUBLIC_CHECKOUT_MESSAGE=${NEXT_PUBLIC_CHECKOUT_MESSAGE} \
+ NEXT_PUBLIC_SOCKETCLUSTER_HOST=${NEXT_PUBLIC_SOCKETCLUSTER_HOST} \
+ NEXT_PUBLIC_SOCKETCLUSTER_PORT=${NEXT_PUBLIC_SOCKETCLUSTER_PORT} \
+ NEXT_PUBLIC_SOCKETCLUSTER_SECURE=${NEXT_PUBLIC_SOCKETCLUSTER_SECURE}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
@@ -16,7 +36,11 @@ RUN pnpm build
FROM base AS runner
ENV NODE_ENV=production
ENV PORT=3000
-RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 storefront
+RUN apk upgrade --no-cache \
+ && rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
+ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg \
+ && addgroup --system --gid 1001 nodejs \
+ && adduser --system --uid 1001 storefront
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
diff --git a/README.md b/README.md
index 3e3419f..0d3cdc0 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# Fleetbase Storefront Web
[](https://github.com/fleetbase/storefront-web/actions/workflows/ci.yml)
+[](https://codecov.io/gh/fleetbase/storefront-web)
[](./LICENSE)
[](https://nextjs.org/)
[](./Dockerfile)
@@ -13,8 +14,9 @@ Think of it as a themeable storefront layer for businesses that want Shopify-lik
- Next.js App Router storefront with catalog, search, product detail pages, cart, checkout, account login, order history, sitemap, and robots routes.
- Server-side Fleetbase API proxy so `STOREFRONT_KEY` stays private.
-- Delivery and pickup checkout flow with Fleetbase service quotes and order capture.
-- Stripe-ready payment flow and gateway discovery.
+- Delivery and pickup selection with Fleetbase store locations, saved places, service quotes, and cash pickup.
+- Stripe Payment Element checkout; only gateways with complete browser support are shown.
+- Authenticated customer profile editing with token-owner enforcement in the Storefront API.
- Theme tokens and presets for brand customization.
- Build-time plugin slots, client providers, and checkout/order hooks.
- Docker and Compose examples for self-hosted deployments.
@@ -42,7 +44,7 @@ Required configuration:
```bash
FLEETBASE_HOST=https://api.fleetbase.io
-STOREFRONT_KEY=store_xxx_or_network_xxx
+STOREFRONT_KEY=store_xxx
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```
@@ -60,7 +62,7 @@ Open `http://localhost:3000`.
| --- | --- | --- |
| `FLEETBASE_HOST` | Yes | Fleetbase API host. Defaults to `https://api.fleetbase.io` when omitted in local development paths. |
| `STOREFRONT_KEY` | Yes | Fleetbase Storefront key for a store or network. Never expose this to the browser. |
-| `NEXT_PUBLIC_SITE_URL` | Recommended | Public storefront URL used for metadata, sitemap, and redirects. |
+| `NEXT_PUBLIC_SITE_URL` | Yes | Public storefront URL used for metadata and sitemap; HTTPS is required in production. |
| `NEXT_PUBLIC_DEFAULT_LOCALE` | No | Default page locale. Defaults to `en`. |
| `NEXT_PUBLIC_DEFAULT_THEME` | No | Theme preset from `lib/theme`. Defaults to `default`. |
| `NEXT_PUBLIC_IMAGE_HOSTS` | Recommended | Comma-separated image host allowlist for Next Image. Include your Fleetbase asset/CDN hosts. |
@@ -68,13 +70,17 @@ Open `http://localhost:3000`.
| `NEXT_PUBLIC_PROMO_BANNER` | No | Example plugin banner text rendered above the header. |
| `NEXT_PUBLIC_CHECKOUT_MESSAGE` | No | Example plugin message rendered in checkout. |
| `STOREFRONT_ANALYTICS_WEBHOOK_URL` | No | Server-side webhook URL for the analytics plugin. |
+| `RATE_LIMIT_REST_URL` / `RATE_LIMIT_REST_TOKEN` | Production scaling | Shared rate-limit service endpoint and bearer token. Required for multiple replicas. |
## Self Hosting
Build and run with Docker:
```bash
-docker build -t fleetbase/storefront-web .
+docker build \
+ --build-arg NEXT_PUBLIC_SITE_URL=https://store.example.com \
+ --build-arg NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx \
+ -t fleetbase/storefront-web .
docker run --env-file .env.local -p 3000:3000 fleetbase/storefront-web
```
@@ -82,9 +88,11 @@ Or use the example Compose file:
```bash
cp .env.example .env.local
-docker compose -f docker-compose.example.yml up --build
+docker compose --env-file .env.local -f docker-compose.example.yml up --build
```
+Next.js compiles every `NEXT_PUBLIC_*` setting into the browser bundle. Docker builds must therefore receive those public values as build arguments; changing them requires rebuilding the image. `STOREFRONT_KEY`, rate-limit credentials, and analytics credentials are secrets and must only be supplied at runtime.
+
Health checks are available at:
```text
@@ -130,6 +138,8 @@ Current slots:
Current server hooks:
+- `beforeFleetbaseRequest(request)`
+- `afterFleetbaseResponse(response)`
- `beforeCheckoutCapture(payload)`
- `afterOrderPlaced(order)`
@@ -143,7 +153,7 @@ Use [docs/fleetbase-setup.md](./docs/fleetbase-setup.md) to connect a Fleetbase
## Release Checklist
-Use [docs/validation.md](./docs/validation.md) for demo-store validation and [docs/release.md](./docs/release.md) before tagging `v0.1.0`.
+Use [docs/validation.md](./docs/validation.md) for release evidence and [docs/releasing.md](./docs/releasing.md) for the Fleetbase `dev-v*` process.
## Development
@@ -151,6 +161,8 @@ Use [docs/validation.md](./docs/validation.md) for demo-store validation and [do
pnpm lint
pnpm typecheck
pnpm test
+pnpm test:coverage
+pnpm test:e2e
pnpm build
```
@@ -160,15 +172,9 @@ Run the full verification chain:
pnpm verify
```
-## Roadmap
+## Release scope
-- Customer registration, password reset, logout/session status, saved addresses, and richer account management.
-- Order detail and tracking pages once the public Storefront API endpoint contract is finalized.
-- Catalog pagination, filters, sorting, collections, store profile pages, product slugs, and related products.
-- Expanded payment plugin examples and analytics provider examples.
-- End-to-end browser tests with mocked Fleetbase responses.
-- Theme preview tooling and a documented theme package convention.
-- Deployment recipes for common hosts and reverse proxies.
+v0.1 is single-store only. Marketplace/network ordering is deliberately disabled until merchant/location-aware carts, multi-origin quoting, and multi-merchant checkout are complete. QPay, PayPal, live tracking, password reset, address mutation, account deletion, and saved payment management are also excluded. See [docs/architecture.md](./docs/architecture.md) and the remaining validation gates in [docs/validation.md](./docs/validation.md).
## License
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000..6f44306
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,13 @@
+# v0.1.0
+
+Initial production-readiness release of Fleetbase Storefront Web, focused on a secure single-store customer journey.
+
+- Adds contract-validated Fleetbase authentication, catalog, cart, checkout, saved-place, gateway, and order requests.
+- Adds password, SMS, and registration flows with server-validated sessions, safe redirects, rate limits, secure cookies, and bounded request bodies.
+- Adds customer-facing delivery location controls and Stripe Payment Element checkout.
+- Adds cash pickup and authenticated customer profile editing against the hardened Storefront API contracts.
+- Adds isolated plugin hooks and privacy-safe analytics events.
+- Adds 100% coverage enforcement, Playwright accessibility/journey tests, Codecov, CodeQL, dependency review, Docker smoke/Trivy, and Fleetbase `dev-v*` release automation.
+- Marketplace mode remains disabled for this single-store release.
+
+The Storefront API capture, Stripe verification, cash pickup, and customer-ownership blockers are resolved on `dev-v0.4.20`. Do not tag this release until every remaining gate in `docs/validation.md` passes.
diff --git a/app/api/health/route.ts b/app/api/health/route.ts
index 27281fc..6c21626 100644
--- a/app/api/health/route.ts
+++ b/app/api/health/route.ts
@@ -9,7 +9,7 @@ export async function GET() {
ok: env.ok,
service: 'storefront-web',
missing: env.missing,
- fleetbaseHost: env.values.FLEETBASE_HOST
+ invalid: env.invalid
},
{ status: env.ok ? 200 : 503 }
);
diff --git a/app/api/storefront/auth/login/route.ts b/app/api/storefront/auth/login/route.ts
index a4f9ff6..f39fb1a 100644
--- a/app/api/storefront/auth/login/route.ts
+++ b/app/api/storefront/auth/login/route.ts
@@ -2,16 +2,24 @@ import { NextResponse } from 'next/server';
import { loginCustomer } from '@/lib/fleetbase/storefront';
import { sessionCookieOptions } from '@/lib/runtime';
import { setCustomerCookies } from '@/lib/customer-session';
+import { apiError, getRequestIp, readJsonBody } from '@/lib/api';
+import { loginInputSchema } from '@/lib/contracts';
+import { checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit';
export async function POST(request: Request) {
try {
- const body = await request.json();
- const result = await loginCustomer(body);
- const response = NextResponse.json({ customer: result.customer });
- response.cookies.set('customer_token', result.token, sessionCookieOptions());
- setCustomerCookies(response, result.customer);
+ const rateLimit = await checkRateLimit({ namespace: 'auth-login', identifier: getRequestIp(request), limit: 10, windowMs: 15 * 60_000 });
+ if (!rateLimit.allowed) {
+ return NextResponse.json({ error: 'Too many sign-in attempts. Try again later.' }, { status: 429, headers: rateLimitHeaders(rateLimit) });
+ }
+
+ const body = await readJsonBody(request, loginInputSchema);
+ const customer = await loginCustomer(body);
+ const response = NextResponse.json({ customer: { ...customer, token: undefined } }, { headers: rateLimitHeaders(rateLimit) });
+ response.cookies.set('customer_token', customer.token, sessionCookieOptions());
+ setCustomerCookies(response, customer);
return response;
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to sign in.' }, { status: 401 });
+ return apiError(error, 'Unable to sign in with those credentials.', 401);
}
}
diff --git a/app/api/storefront/auth/logout/route.ts b/app/api/storefront/auth/logout/route.ts
index 9dbc9fa..802d1ab 100644
--- a/app/api/storefront/auth/logout/route.ts
+++ b/app/api/storefront/auth/logout/route.ts
@@ -1,11 +1,8 @@
import { NextResponse } from 'next/server';
+import { clearCustomerCookies } from '@/lib/customer-session';
export async function POST() {
const response = NextResponse.json({ ok: true });
- response.cookies.delete('customer_token');
- response.cookies.delete('customer_id');
- response.cookies.delete('customer_name');
- response.cookies.delete('customer_email');
- response.cookies.delete('customer_phone');
+ clearCustomerCookies(response);
return response;
}
diff --git a/app/api/storefront/auth/register/route.ts b/app/api/storefront/auth/register/route.ts
index bef4d22..fd93238 100644
--- a/app/api/storefront/auth/register/route.ts
+++ b/app/api/storefront/auth/register/route.ts
@@ -2,25 +2,30 @@ import { NextResponse } from 'next/server';
import { createCustomer, requestCustomerCreationCode } from '@/lib/fleetbase/storefront';
import { setCustomerCookies } from '@/lib/customer-session';
import { sessionCookieOptions } from '@/lib/runtime';
+import { apiError, getRequestIp, readJsonBody } from '@/lib/api';
+import { registrationInputSchema } from '@/lib/contracts';
+import { checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit';
export async function POST(request: Request) {
try {
- const body = await request.json();
+ const rateLimit = await checkRateLimit({ namespace: 'auth-register', identifier: getRequestIp(request), limit: 8, windowMs: 15 * 60_000 });
+ if (!rateLimit.allowed) {
+ return NextResponse.json({ error: 'Too many registration attempts. Try again later.' }, { status: 429, headers: rateLimitHeaders(rateLimit) });
+ }
+
+ const body = await readJsonBody(request, registrationInputSchema);
- if (!body.code) {
- const result = await requestCustomerCreationCode({
- identity: body.identity,
- mode: body.mode ?? 'email'
- });
- return NextResponse.json(result);
+ if (!('code' in body)) {
+ const result = await requestCustomerCreationCode(body.identity, body.mode);
+ return NextResponse.json(result, { headers: rateLimitHeaders(rateLimit) });
}
- const result = await createCustomer(body);
- const response = NextResponse.json({ customer: result.customer });
- response.cookies.set('customer_token', result.token, sessionCookieOptions());
- setCustomerCookies(response, result.customer);
+ const customer = await createCustomer(body);
+ const response = NextResponse.json({ customer: { ...customer, token: undefined } }, { headers: rateLimitHeaders(rateLimit) });
+ response.cookies.set('customer_token', customer.token, sessionCookieOptions());
+ setCustomerCookies(response, customer);
return response;
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to create account.' }, { status: 400 });
+ return apiError(error, 'Unable to create account.', 400);
}
}
diff --git a/app/api/storefront/auth/session/route.ts b/app/api/storefront/auth/session/route.ts
index 17fdd74..c477e6b 100644
--- a/app/api/storefront/auth/session/route.ts
+++ b/app/api/storefront/auth/session/route.ts
@@ -1,17 +1,32 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
+import { getCustomerPlaces } from '@/lib/fleetbase/storefront';
+import { clearCustomerCookies } from '@/lib/customer-session';
export async function GET() {
const cookieStore = await cookies();
+ const token = cookieStore.get('customer_token')?.value;
const customerId = cookieStore.get('customer_id')?.value ?? null;
+ if (!token || !customerId) {
+ return NextResponse.json({ authenticated: false, customer: null });
+ }
+
+ try {
+ await getCustomerPlaces();
+ } catch {
+ const response = NextResponse.json({ authenticated: false, customer: null }, { status: 401 });
+ clearCustomerCookies(response);
+ return response;
+ }
+
return NextResponse.json({
- authenticated: Boolean(cookieStore.get('customer_token')?.value),
- customer: customerId ? {
+ authenticated: true,
+ customer: {
id: customerId,
name: cookieStore.get('customer_name')?.value,
email: cookieStore.get('customer_email')?.value,
phone: cookieStore.get('customer_phone')?.value
- } : null
+ }
});
}
diff --git a/app/api/storefront/auth/sms/route.ts b/app/api/storefront/auth/sms/route.ts
index 556d1fb..c0f1792 100644
--- a/app/api/storefront/auth/sms/route.ts
+++ b/app/api/storefront/auth/sms/route.ts
@@ -2,20 +2,28 @@ import { NextResponse } from 'next/server';
import { requestSmsLogin, verifyCustomerCode } from '@/lib/fleetbase/storefront';
import { sessionCookieOptions } from '@/lib/runtime';
import { setCustomerCookies } from '@/lib/customer-session';
+import { apiError, getRequestIp, readJsonBody } from '@/lib/api';
+import { smsInputSchema } from '@/lib/contracts';
+import { checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit';
export async function POST(request: Request) {
try {
- const body = await request.json();
- if (body.code) {
- const result = await verifyCustomerCode(body);
- const response = NextResponse.json({ customer: result.customer });
- response.cookies.set('customer_token', result.token, sessionCookieOptions());
- setCustomerCookies(response, result.customer);
+ const rateLimit = await checkRateLimit({ namespace: 'auth-sms', identifier: getRequestIp(request), limit: 8, windowMs: 15 * 60_000 });
+ if (!rateLimit.allowed) {
+ return NextResponse.json({ error: 'Too many verification attempts. Try again later.' }, { status: 429, headers: rateLimitHeaders(rateLimit) });
+ }
+
+ const body = await readJsonBody(request, smsInputSchema);
+ if ('code' in body) {
+ const customer = await verifyCustomerCode(body.phone, body.code);
+ const response = NextResponse.json({ customer: { ...customer, token: undefined } }, { headers: rateLimitHeaders(rateLimit) });
+ response.cookies.set('customer_token', customer.token, sessionCookieOptions());
+ setCustomerCookies(response, customer);
return response;
}
- const result = await requestSmsLogin(body);
- return NextResponse.json(result);
+ const result = await requestSmsLogin(body.phone);
+ return NextResponse.json(result, { headers: rateLimitHeaders(rateLimit) });
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to process SMS auth.' }, { status: 400 });
+ return apiError(error, 'Unable to process SMS sign-in.', 400);
}
}
diff --git a/app/api/storefront/cart/items/route.ts b/app/api/storefront/cart/items/route.ts
index e29fc11..2df9219 100644
--- a/app/api/storefront/cart/items/route.ts
+++ b/app/api/storefront/cart/items/route.ts
@@ -2,6 +2,8 @@ import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { addCartItem, getCart, removeCartItem, updateCartItem } from '@/lib/fleetbase/storefront';
import { sessionCookieOptions } from '@/lib/runtime';
+import { apiError, readJsonBody } from '@/lib/api';
+import { cartItemDeleteSchema, cartItemInputSchema, cartItemUpdateSchema } from '@/lib/contracts';
async function resolveCartId() {
const cookieStore = await cookies();
@@ -15,40 +17,41 @@ async function resolveCartId() {
export async function POST(request: Request) {
try {
- const body = await request.json();
+ const body = await readJsonBody(request, cartItemInputSchema);
const cartId = await resolveCartId();
const cart = await addCartItem(cartId, body.productId, {
quantity: body.quantity,
variants: body.variants,
addons: body.addons,
- store_location: body.store_location
+ store_location: body.store_location,
+ store: body.store
});
const response = NextResponse.json({ cart });
response.cookies.set('cart_id', cart.id, sessionCookieOptions());
return response;
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to add item.' }, { status: 500 });
+ return apiError(error, 'Unable to add item.');
}
}
export async function PUT(request: Request) {
try {
- const body = await request.json();
+ const body = await readJsonBody(request, cartItemUpdateSchema);
const cartId = await resolveCartId();
const cart = await updateCartItem(cartId, body.lineItemId, { quantity: body.quantity, variants: body.variants, addons: body.addons });
return NextResponse.json({ cart });
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to update item.' }, { status: 500 });
+ return apiError(error, 'Unable to update item.');
}
}
export async function DELETE(request: Request) {
try {
- const body = await request.json();
+ const body = await readJsonBody(request, cartItemDeleteSchema);
const cartId = await resolveCartId();
const cart = await removeCartItem(cartId, body.lineItemId);
return NextResponse.json({ cart });
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to remove item.' }, { status: 500 });
+ return apiError(error, 'Unable to remove item.');
}
}
diff --git a/app/api/storefront/cart/route.ts b/app/api/storefront/cart/route.ts
index 86496c4..801f319 100644
--- a/app/api/storefront/cart/route.ts
+++ b/app/api/storefront/cart/route.ts
@@ -1,7 +1,8 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
-import { getCart } from '@/lib/fleetbase/storefront';
+import { emptyCart, getCart } from '@/lib/fleetbase/storefront';
import { sessionCookieOptions } from '@/lib/runtime';
+import { apiError } from '@/lib/api';
export async function GET() {
try {
@@ -14,6 +15,18 @@ export async function GET() {
}
return response;
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to load cart.' }, { status: 500 });
+ return apiError(error, 'Unable to load cart.');
+ }
+}
+
+export async function DELETE() {
+ try {
+ const cookieStore = await cookies();
+ const cartId = cookieStore.get('cart_id')?.value;
+ if (!cartId) return NextResponse.json({ cart: null });
+ const cart = await emptyCart(cartId);
+ return NextResponse.json({ cart });
+ } catch (error) {
+ return apiError(error, 'Unable to empty cart.');
}
}
diff --git a/app/api/storefront/checkout/capture/route.ts b/app/api/storefront/checkout/capture/route.ts
index 957fc08..78bfab1 100644
--- a/app/api/storefront/checkout/capture/route.ts
+++ b/app/api/storefront/checkout/capture/route.ts
@@ -1,18 +1,23 @@
import { NextResponse } from 'next/server';
import { captureCheckout } from '@/lib/fleetbase/storefront';
import { runAfterOrderPlacedHooks, runBeforeCheckoutCaptureHooks } from '@/plugins/registry';
+import { apiError, readJsonBody } from '@/lib/api';
+import { checkoutCaptureInputSchema } from '@/lib/contracts';
export async function POST(request: Request) {
try {
- const body = await request.json();
- const payload = { token: body.token, notes: body.notes, transactionDetails: body.transactionDetails };
- const finalPayload = await runBeforeCheckoutCaptureHooks(payload);
+ const body = await readJsonBody(request, checkoutCaptureInputSchema);
+ const payload = { token: body.token, notes: body.notes, transactionDetails: body.transactionDetails, idempotencyKey: body.idempotencyKey };
+ const finalPayload = checkoutCaptureInputSchema.parse(await runBeforeCheckoutCaptureHooks(payload));
const order = await captureCheckout(finalPayload);
- await runAfterOrderPlacedHooks(order);
+ const hookErrors = await runAfterOrderPlacedHooks(order);
const response = NextResponse.json({ order });
response.cookies.delete('cart_id');
+ if (hookErrors.length) {
+ response.headers.set('X-Storefront-Hook-Warnings', String(hookErrors.length));
+ }
return response;
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to capture checkout.' }, { status: 500 });
+ return apiError(error, 'Unable to capture checkout.');
}
}
diff --git a/app/api/storefront/checkout/prepare/route.ts b/app/api/storefront/checkout/prepare/route.ts
index 2e7bc4e..f2eb814 100644
--- a/app/api/storefront/checkout/prepare/route.ts
+++ b/app/api/storefront/checkout/prepare/route.ts
@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { beforeCheckout } from '@/lib/fleetbase/storefront';
+import { apiError, readJsonBody } from '@/lib/api';
+import { checkoutPrepareInputSchema } from '@/lib/contracts';
export async function POST(request: Request) {
try {
- const body = await request.json();
- const customerId = body.customer ?? (await cookies()).get('customer_id')?.value;
+ const body = await readJsonBody(request, checkoutPrepareInputSchema);
+ const customerId = (await cookies()).get('customer_id')?.value;
+ if (!customerId) {
+ return NextResponse.json({ error: 'Sign in before checkout.' }, { status: 401 });
+ }
const checkout = await beforeCheckout({
gateway: body.gateway,
customer: customerId,
@@ -18,6 +23,6 @@ export async function POST(request: Request) {
});
return NextResponse.json(checkout);
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to prepare checkout.' }, { status: 500 });
+ return apiError(error, 'Unable to prepare checkout.');
}
}
diff --git a/app/api/storefront/customer/places/route.ts b/app/api/storefront/customer/places/route.ts
index 6c2a5e5..9461494 100644
--- a/app/api/storefront/customer/places/route.ts
+++ b/app/api/storefront/customer/places/route.ts
@@ -1,11 +1,12 @@
import { NextResponse } from 'next/server';
import { getCustomerPlaces } from '@/lib/fleetbase/storefront';
+import { apiError } from '@/lib/api';
export async function GET() {
try {
const places = await getCustomerPlaces();
return NextResponse.json({ places });
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Sign in to view saved addresses.' }, { status: 401 });
+ return apiError(error, 'Sign in to view saved addresses.', 401);
}
}
diff --git a/app/api/storefront/customer/profile/route.ts b/app/api/storefront/customer/profile/route.ts
new file mode 100644
index 0000000..0b75dd8
--- /dev/null
+++ b/app/api/storefront/customer/profile/route.ts
@@ -0,0 +1,23 @@
+import { cookies } from 'next/headers';
+import { NextResponse } from 'next/server';
+import { apiError, readJsonBody } from '@/lib/api';
+import { customerUpdateInputSchema } from '@/lib/contracts';
+import { setCustomerCookies } from '@/lib/customer-session';
+import { updateCustomer } from '@/lib/fleetbase/storefront';
+
+export async function PATCH(request: Request) {
+ try {
+ const customerId = (await cookies()).get('customer_id')?.value;
+ if (!customerId) {
+ return NextResponse.json({ error: 'Sign in to update your profile.' }, { status: 401 });
+ }
+
+ const body = await readJsonBody(request, customerUpdateInputSchema);
+ const customer = await updateCustomer(customerId, body);
+ const response = NextResponse.json({ customer });
+ setCustomerCookies(response, customer);
+ return response;
+ } catch (error) {
+ return apiError(error, 'Unable to update your profile.');
+ }
+}
diff --git a/app/api/storefront/gateways/route.ts b/app/api/storefront/gateways/route.ts
index 21d2599..360a91a 100644
--- a/app/api/storefront/gateways/route.ts
+++ b/app/api/storefront/gateways/route.ts
@@ -1,11 +1,12 @@
import { NextResponse } from 'next/server';
import { getGateways } from '@/lib/fleetbase/storefront';
+import { apiError } from '@/lib/api';
export async function GET() {
try {
const gateways = await getGateways();
return NextResponse.json({ gateways });
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to load gateways.' }, { status: 500 });
+ return apiError(error, 'Unable to load payment methods.');
}
}
diff --git a/app/api/storefront/orders/[id]/route.ts b/app/api/storefront/orders/[id]/route.ts
index f021166..00e9171 100644
--- a/app/api/storefront/orders/[id]/route.ts
+++ b/app/api/storefront/orders/[id]/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { getCustomerOrder } from '@/lib/fleetbase/storefront';
+import { apiError } from '@/lib/api';
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
@@ -7,6 +8,6 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const order = await getCustomerOrder(id);
return NextResponse.json({ order });
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to load order.' }, { status: 404 });
+ return apiError(error, 'Unable to load order.', 404);
}
}
diff --git a/app/api/storefront/orders/route.ts b/app/api/storefront/orders/route.ts
index 85fe124..38b5f31 100644
--- a/app/api/storefront/orders/route.ts
+++ b/app/api/storefront/orders/route.ts
@@ -1,11 +1,18 @@
import { NextResponse } from 'next/server';
import { getCustomerOrders } from '@/lib/fleetbase/storefront';
+import { apiError } from '@/lib/api';
-export async function GET() {
+export async function GET(request: Request) {
try {
- const orders = await getCustomerOrders();
+ const searchParams = new URL(request.url).searchParams;
+ const orders = await getCustomerOrders({
+ page: searchParams.get('page') ?? undefined,
+ limit: searchParams.get('limit') ?? 25,
+ sort: searchParams.get('sort') ?? 'created_at',
+ query: searchParams.get('query') ?? undefined
+ });
return NextResponse.json({ orders });
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Sign in to view orders.' }, { status: 401 });
+ return apiError(error, 'Sign in to view orders.', 401);
}
}
diff --git a/app/api/storefront/service-quote/route.ts b/app/api/storefront/service-quote/route.ts
index 7dd5053..101df1d 100644
--- a/app/api/storefront/service-quote/route.ts
+++ b/app/api/storefront/service-quote/route.ts
@@ -1,9 +1,11 @@
import { NextResponse } from 'next/server';
import { getServiceQuoteFromCart } from '@/lib/fleetbase/storefront';
+import { apiError, readJsonBody } from '@/lib/api';
+import { serviceQuoteInputSchema } from '@/lib/contracts';
export async function POST(request: Request) {
try {
- const body = await request.json();
+ const body = await readJsonBody(request, serviceQuoteInputSchema);
const quote = await getServiceQuoteFromCart({
origin: body.origin,
destination: body.destination,
@@ -12,6 +14,6 @@ export async function POST(request: Request) {
});
return NextResponse.json({ quote });
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Unable to fetch service quote.' }, { status: 500 });
+ return apiError(error, 'Unable to fetch service quote.');
}
}
diff --git a/app/api/storefront/store-locations/route.ts b/app/api/storefront/store-locations/route.ts
new file mode 100644
index 0000000..ec46828
--- /dev/null
+++ b/app/api/storefront/store-locations/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+import { apiError } from '@/lib/api';
+import { getStoreLocations } from '@/lib/fleetbase/storefront';
+
+export async function GET(request: Request) {
+ try {
+ const searchParams = new URL(request.url).searchParams;
+ const locations = await getStoreLocations({
+ with_store: true,
+ limit: Math.min(Number(searchParams.get('limit') ?? 100), 100)
+ });
+ return NextResponse.json({ locations });
+ } catch (error) {
+ return apiError(error, 'Unable to load store locations.');
+ }
+}
diff --git a/app/error.tsx b/app/error.tsx
new file mode 100644
index 0000000..eac1f8a
--- /dev/null
+++ b/app/error.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ useEffect(() => {
+ console.error('[storefront-web] page request failed', { digest: error.digest, name: error.name });
+ }, [error]);
+
+ return (
+
+ Storefront temporarily unavailable
+ We could not load the latest store information. Your cart has not been changed.
+ Try again
+
+ );
+}
diff --git a/app/loading.tsx b/app/loading.tsx
new file mode 100644
index 0000000..71ec06f
--- /dev/null
+++ b/app/loading.tsx
@@ -0,0 +1,3 @@
+export default function Loading() {
+ return Loading storefront...
;
+}
diff --git a/app/not-found.tsx b/app/not-found.tsx
new file mode 100644
index 0000000..0b79af4
--- /dev/null
+++ b/app/not-found.tsx
@@ -0,0 +1,11 @@
+import Link from 'next/link';
+
+export default function NotFound() {
+ return (
+
+ Page not found
+ The requested storefront page is unavailable or no longer exists.
+ Return to the storefront
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 7f0d312..a4de520 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -5,13 +5,14 @@ import { StoreHero } from '@/components/store-hero';
import { CategoryPills } from '@/components/catalog/category-pills';
import { StoreDirectory } from '@/components/catalog/store-directory';
import { getCategories, getNetworkStores, getProducts, getStorefrontAbout } from '@/lib/fleetbase/storefront';
+import config from '@/storefront.config';
export default async function HomePage() {
const [about, categories, products, networkStores] = await Promise.all([
- getStorefrontAbout().catch(() => null),
- getCategories().catch(() => []),
- getProducts({ limit: 16 }).catch(() => []),
- getNetworkStores().catch(() => [])
+ getStorefrontAbout(),
+ getCategories(),
+ getProducts({ limit: 16 }),
+ config.features.marketplace ? getNetworkStores() : Promise.resolve([])
]);
return (
@@ -20,7 +21,7 @@ export default async function HomePage() {
+ {about?.is_network && !config.features.marketplace && (
+ Marketplace ordering is not enabled in this release. Configure this deployment with a single-store Storefront key.
+ )}
{networkStores.length > 0 && (
diff --git a/app/products/[id]/page.tsx b/app/products/[id]/page.tsx
index f6f0fb4..05e2da5 100644
--- a/app/products/[id]/page.tsx
+++ b/app/products/[id]/page.tsx
@@ -14,6 +14,9 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
return {
title: product.name,
description: product.description,
+ alternates: {
+ canonical: `/products/${product.id}`
+ },
openGraph: {
title: product.name,
description: product.description,
diff --git a/app/robots.ts b/app/robots.ts
index b284756..753c949 100644
--- a/app/robots.ts
+++ b/app/robots.ts
@@ -5,8 +5,8 @@ export default function robots(): MetadataRoute.Robots {
return {
rules: {
- userAgent: '*',
- allow: '/'
+ userAgent: '*', allow: '/',
+ disallow: ['/account', '/api/', '/cart', '/checkout', '/login', '/orders', '/register']
},
sitemap: `${siteUrl}/sitemap.xml`
};
diff --git a/app/search/page.tsx b/app/search/page.tsx
index e38aa2d..d4e4907 100644
--- a/app/search/page.tsx
+++ b/app/search/page.tsx
@@ -14,8 +14,8 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi
limit
};
const [categories, products] = await Promise.all([
- getCategories().catch(() => []),
- query ? searchProducts(query, options).catch(() => []) : getProducts(options).catch(() => [])
+ getCategories(),
+ query ? searchProducts(query, options) : getProducts(options)
]);
const nextParams = new URLSearchParams();
Object.entries({ ...params, page: String(page + 1) }).forEach(([key, value]) => {
@@ -64,7 +64,7 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi
Apply filters
-
+
Previous
Page {page}
Next
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 59d6ae2..c2852f1 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,18 +1,29 @@
import type { MetadataRoute } from 'next';
import { getCategories, getNetworkStores, getProducts } from '@/lib/fleetbase/storefront';
+import config from '@/storefront.config';
+
+async function getSitemapProducts() {
+ const products = [];
+ for (let page = 1; page <= 100; page += 1) {
+ const result = await getProducts({ page, limit: 100 });
+ products.push(...result);
+ if (result.length < 100) break;
+ }
+ return products;
+}
export default async function sitemap(): Promise {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const [products, categories, stores] = await Promise.all([
- getProducts({ limit: 100 }).catch(() => []),
- getCategories().catch(() => []),
- getNetworkStores().catch(() => [])
+ getSitemapProducts(),
+ getCategories(),
+ config.features.marketplace ? getNetworkStores() : Promise.resolve([])
]);
return [
{ url: siteUrl, lastModified: new Date() },
{ url: `${siteUrl}/search`, lastModified: new Date() },
- { url: `${siteUrl}/stores`, lastModified: new Date() },
+ ...(config.features.marketplace ? [{ url: `${siteUrl}/stores`, lastModified: new Date() }] : []),
...categories.map((category) => ({
url: `${siteUrl}/categories/${category.slug ?? category.id}`,
lastModified: new Date()
@@ -22,7 +33,7 @@ export default async function sitemap(): Promise {
lastModified: new Date()
})),
...products.map((product) => ({
- url: `${siteUrl}/products/${product.slug ?? product.id}`,
+ url: `${siteUrl}/products/${product.id}`,
lastModified: product.updated_at ? new Date(product.updated_at) : new Date()
}))
];
diff --git a/app/stores/[id]/page.tsx b/app/stores/[id]/page.tsx
index c54d917..345ae40 100644
--- a/app/stores/[id]/page.tsx
+++ b/app/stores/[id]/page.tsx
@@ -2,8 +2,10 @@ import { notFound } from 'next/navigation';
import { ProductGrid } from '@/components/catalog/product-grid';
import { StoreHero } from '@/components/store-hero';
import { getNetworkStores, getProducts } from '@/lib/fleetbase/storefront';
+import config from '@/storefront.config';
export default async function StoreProfilePage({ params }: { params: Promise<{ id: string }> }) {
+ if (!config.features.marketplace) notFound();
const { id } = await params;
const stores = await getNetworkStores().catch(() => []);
const store = stores.find((candidate) => candidate.slug === id || candidate.id === id);
diff --git a/app/stores/page.tsx b/app/stores/page.tsx
index 4288cb7..16a6c46 100644
--- a/app/stores/page.tsx
+++ b/app/stores/page.tsx
@@ -1,7 +1,10 @@
import { StoreDirectory } from '@/components/catalog/store-directory';
import { getNetworkStores, getStorefrontAbout } from '@/lib/fleetbase/storefront';
+import { notFound } from 'next/navigation';
+import config from '@/storefront.config';
export default async function StoresPage() {
+ if (!config.features.marketplace) notFound();
const [about, stores] = await Promise.all([
getStorefrontAbout().catch(() => null),
getNetworkStores().catch(() => [])
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..bd453da
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,19 @@
+coverage:
+ precision: 2
+ round: down
+ range: 100..100
+ status:
+ project:
+ default:
+ target: 100%
+ threshold: 0%
+ patch:
+ default:
+ target: 100%
+ threshold: 0%
+comment:
+ layout: reach,diff,flags,files
+ require_changes: true
+flags:
+ web:
+ carryforward: false
diff --git a/components/catalog/product-card.tsx b/components/catalog/product-card.tsx
index 348cac2..9544b37 100644
--- a/components/catalog/product-card.tsx
+++ b/components/catalog/product-card.tsx
@@ -5,7 +5,7 @@ import { productPrice } from '@/lib/format';
import { Slot } from '@/components/plugin-slot';
export function ProductCard({ product }: { product: StorefrontProduct }) {
- const href = `/products/${product.slug ?? product.id}`;
+ const href = `/products/${product.id}`;
return (
diff --git a/components/layout/header.tsx b/components/layout/header.tsx
index 0a679a3..f650565 100644
--- a/components/layout/header.tsx
+++ b/components/layout/header.tsx
@@ -13,7 +13,7 @@ export function Header({ about }: { about?: StorefrontAbout | null }) {
{about?.logo_url ? : }
{about?.name ?? 'Storefront Web'}
-
+
Search
Account
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
index 9d003c2..d0a8f5b 100644
--- a/docker-compose.example.yml
+++ b/docker-compose.example.yml
@@ -1,6 +1,18 @@
services:
storefront-web:
- build: .
+ build:
+ context: .
+ args:
+ NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
+ NEXT_PUBLIC_DEFAULT_LOCALE: ${NEXT_PUBLIC_DEFAULT_LOCALE:-en}
+ NEXT_PUBLIC_DEFAULT_THEME: ${NEXT_PUBLIC_DEFAULT_THEME:-default}
+ NEXT_PUBLIC_IMAGE_HOSTS: ${NEXT_PUBLIC_IMAGE_HOSTS:-api.fleetbase.io,flb-assets.s3.ap-southeast-1.amazonaws.com}
+ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}
+ NEXT_PUBLIC_PROMO_BANNER: ${NEXT_PUBLIC_PROMO_BANNER:-}
+ NEXT_PUBLIC_CHECKOUT_MESSAGE: ${NEXT_PUBLIC_CHECKOUT_MESSAGE:-}
+ NEXT_PUBLIC_SOCKETCLUSTER_HOST: ${NEXT_PUBLIC_SOCKETCLUSTER_HOST:-}
+ NEXT_PUBLIC_SOCKETCLUSTER_PORT: ${NEXT_PUBLIC_SOCKETCLUSTER_PORT:-}
+ NEXT_PUBLIC_SOCKETCLUSTER_SECURE: ${NEXT_PUBLIC_SOCKETCLUSTER_SECURE:-true}
ports:
- "3000:3000"
env_file:
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..020173d
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,11 @@
+# Architecture
+
+Browsers call only same-origin Next.js routes. Those server routes add the server-only `STOREFRONT_KEY`, and add `Customer-Token` only for authenticated customer operations. Fleetbase remains the system of record for catalog, carts, customers, quotes, checkout, payments, and orders.
+
+The release target is stateless horizontal scaling. Cart and customer state use opaque HttpOnly cookies; business state stays in Fleetbase. Authentication rate limiting uses an in-memory fallback for local/single-instance use and requires a shared rate-limit service in multi-replica production.
+
+Plugins are compiled with the application. UI slots and client providers are browser-safe. Fleetbase request/response hooks, checkout capture hooks, and order webhooks run on the server. Plugin IDs must be unique. Pre-capture failures are critical; post-order failures are isolated and reported without converting a completed order into a failed checkout.
+
+## v0.1 boundaries
+
+v0.1 supports a single Storefront store. Authenticated profile editing, delivery checkout, cash delivery, cash pickup, and Stripe checkout are supported. Network marketplace mode, multi-merchant carts, QPay, PayPal, live SocketCluster tracking, address mutation, password reset, account deletion, and saved payment management are excluded. A network key must not be used for this release.
diff --git a/docs/deployment.md b/docs/deployment.md
index 81276e2..6158761 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -6,11 +6,15 @@ Storefront Web is a standard Next.js app with standalone output enabled.
```bash
cp .env.example .env.local
-docker compose -f docker-compose.example.yml up --build
+docker compose --env-file .env.local -f docker-compose.example.yml up --build
```
Use `/api/health` for container health checks.
+The image runs as the non-root `storefront` user. Production requires HTTPS, an HTTPS `NEXT_PUBLIC_SITE_URL`, a single-store `STOREFRONT_KEY`, and a shared rate-limit service when more than one replica is used. Do not bake `.env` files into the image.
+
+`NEXT_PUBLIC_*` values are public build configuration in Next.js. The Dockerfile declares explicit build arguments for the site URL, locale, theme, image hosts, Stripe publishable key, public plugin messages, and SocketCluster client settings. Compose forwards them from `--env-file .env.local`. Rebuild the image whenever one changes. Never pass `STOREFRONT_KEY`, `RATE_LIMIT_REST_TOKEN`, or analytics credentials as build arguments; provide secrets only to the running container.
+
## Vercel
1. Import `fleetbase/storefront-web`.
@@ -34,3 +38,14 @@ When running behind Nginx, Caddy, Traefik, or another proxy:
- Forward `Host`, `X-Forwarded-Proto`, and `X-Forwarded-For`.
- Keep `NEXT_PUBLIC_SITE_URL` aligned with the public storefront URL.
- Restrict `NEXT_PUBLIC_IMAGE_HOSTS` to Fleetbase/CDN asset hosts used by the store.
+- Set HSTS at the TLS edge after HTTPS is verified.
+- Trust forwarding headers only from the known proxy; authentication limits use the forwarded client IP.
+- Preserve or create `X-Request-ID` for correlation.
+
+## Stripe
+
+Configure an enabled Stripe gateway in Fleetbase and set its matching publishable key as `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`. The built-in CSP permits only Stripe.js, Stripe API connections, and Stripe frames. Validate with Stripe test mode before production. If the key or backend gateway is absent, Stripe is not a usable checkout method.
+
+## Health failure
+
+`/api/health` returns 503 and only the names of missing/invalid settings. It never returns secrets or configured values. A production orchestrator should keep an unhealthy replica out of service.
diff --git a/docs/operations.md b/docs/operations.md
new file mode 100644
index 0000000..91b4efa
--- /dev/null
+++ b/docs/operations.md
@@ -0,0 +1,13 @@
+# Operations
+
+Use `GET /api/health` for liveness and configuration readiness. It returns 503 with missing or invalid variable names, never values. Terminate TLS at the edge, enable HSTS there, forward trusted `Host`, `X-Forwarded-Proto`, `X-Forwarded-For`, and a correlation `X-Request-ID`, and restrict which proxies may set forwarding headers.
+
+Run at least two replicas only after configuring a shared rate-limit service. Monitor 5xx rate, Fleetbase latency/timeouts, checkout preparation/capture errors, health status, and isolated plugin warnings. Logs intentionally contain request IDs, status, plugin IDs, and opaque resource IDs only; do not add tokens, codes, addresses, full customers, carts, or orders.
+
+The configured `RATE_LIMIT_REST_URL` receives an authenticated POST body containing `namespace`, `identifier`, `limit`, and `windowMs`, and must atomically return `allowed`, `remaining`, and `resetAt` (Unix milliseconds). Treat service failure as an authentication-service failure; never fail open. Restrict the endpoint to the deployment and rotate `RATE_LIMIT_REST_TOKEN` like any other secret.
+
+Rotate `STOREFRONT_KEY`, analytics webhook credentials, Stripe keys, and the rate-limit token through the deployment secret manager. Restart all replicas after rotation. For rollback, redeploy the prior immutable image and verify health, login, cart restoration, and order lookup. Fleetbase backups and payment-provider recovery are operated separately because this app stores no business database.
+
+During an incident, disable checkout at the edge if payment/order state is uncertain, retain correlation IDs, reconcile Stripe PaymentIntents against Fleetbase checkouts/orders, and avoid retrying capture until authoritative state is known.
+
+Supported browsers are the latest two stable releases of Chrome, Edge, Firefox, and Safari, plus current iOS Safari and Android Chrome. JavaScript and secure cookies are required.
diff --git a/docs/release.md b/docs/release.md
index 60e1370..0fef135 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -1,4 +1,6 @@
-# Release Checklist
+# Release Checklist (legacy pointer)
+
+The authoritative release process is [releasing.md](./releasing.md). This file remains for older links.
Use this checklist for the first public `v0.1.0` release.
@@ -32,7 +34,6 @@ Recommended repository topics:
## Release
-1. Tag `v0.1.0`.
-2. Create a GitHub release using the changelog summary.
-3. Attach deployment notes and demo-store validation notes.
-4. Announce that Fleetbase Console remains the admin layer and Storefront Web is the customer-facing frontend.
+1. Merge a fully green `dev-v0.1.0` pull request into `main`.
+2. Allow the Fleetbase reusable release workflow to create `v0.1.0`.
+3. Confirm the tag workflow publishes the curated `RELEASE.md` body.
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 0000000..cd12a50
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,11 @@
+# Releasing
+
+Normal releases use the Fleetbase module convention:
+
+1. Create `dev-vX.Y.Z` from current `main`.
+2. Set `package.json` to `X.Y.Z`; update `CHANGELOG.md`; replace `RELEASE.md` so its first line is `# vX.Y.Z`.
+3. Open the release pull request into `main`. Require lint, typecheck, unit tests, coverage-100, Codecov project/patch, Playwright, production build, Docker smoke, CodeQL, and dependency review.
+4. Merge only after all checks pass. `.github/workflows/release.yml` calls `fleetbase/fleetbase/.github/workflows/release-tag.yml@main` with `version-files: package.json` and inherited `_GITHUB_AUTH_TOKEN` credentials.
+5. The resulting `vX.Y.Z` tag triggers `create-release.yml`, which publishes a non-draft GitHub Release from `RELEASE.md`. Semantic prerelease tags are marked prerelease.
+
+`workflow_dispatch` with an explicit version is recovery-only. It does not replace the normal release PR. Never create a second manual tag when the reusable workflow is pending or failed. No workflow publishes a container until registry ownership, credentials, immutable naming, SBOM, and provenance conventions are approved.
diff --git a/docs/validation.md b/docs/validation.md
index a162f28..bbf4d45 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -2,16 +2,37 @@
Use this checklist before cutting `v0.1.0`.
+## Latest local evidence (2026-08-30)
+
+- Clean `pnpm install --frozen-lockfile`: passed with pnpm 10.15.1.
+- ESLint and TypeScript: passed.
+- Vitest: 17 files and 85 tests passed.
+- Coverage: 100% statements (2690/2690), branches (900/900), functions (183/183), and lines (2690/2690). `coverage:uncovered` reports no uncovered statements.
+- Playwright: 24/24 journeys passed across desktop Chromium and Pixel 7 emulation, including catalog, registration, SMS/password auth, profile editing, cart mutations, delivery cash checkout, cash pickup, order history, session expiry, upstream recovery, safe return paths, and automated accessibility checks.
+- Next.js production build: passed cleanly for 31 routes; shared first-load JavaScript is 102 kB and checkout is 114 kB total first load.
+- Dependency audit: no known vulnerabilities at `high` severity or above.
+- Workflow YAML, Compose configuration, whitespace checks, and a repository credential-pattern scan: passed. The only live-key matches are documented `pk_live_xxx` placeholders.
+- The release-candidate container is 247,146,557 bytes and runs as `storefront`. Its configured health endpoint returned 200, missing configuration returned 503 without exposing values, npm/pnpm were absent from the runtime, and Trivy 0.69.3 reported zero HIGH/CRITICAL Alpine or Node findings.
+
+Live validation against the localhost Fleetbase API on Storefront `dev-v0.4.20` confirmed store/catalog loading, Fleetbase asset images, effective sale pricing, product detail lookup by public ID, configured variants/addons, cart creation, add-to-cart, cookie persistence, and the correct cart subtotal. The configured `.env.local` Storefront key did not match a current local database store, so validation used a process-only local key without changing or exposing the file.
+
+This evidence validates the repository-owned implementation, but it does not authorize a release. GitHub-hosted CI, Codecov ingestion, a deployable demo environment with customer credentials, a real Stripe test-mode payment, and the manual accessibility checks below remain external release gates.
+
+## Remaining parity and product validation
+
+The v0.1 web scope is intentionally smaller than `storefront-app`. Before broadening the release claim, separately implement and validate localization/locale selection, product structured data and richer store SEO, cart-expiry and concurrent-mutation recovery, address mutation and guest address entry, quote expiry, detailed receipts/status timelines, live SocketCluster tracking, QPay/PayPal, and the complete marketplace/multi-merchant flow. Marketplace remains disabled until that full contract is supported.
+
+Automated axe checks passed, but WCAG 2.2 AA still requires a manual keyboard, focus, screen-reader, contrast, and reduced-motion review. Lighthouse measurements and the separately configured live/demo Fleetbase integration suite have not been run. These are release gates, not implied successes.
+
## Storefront Data
- Store name, description, logo, backdrop, currency, and network/store mode load.
-- Store and marketplace directory pages load.
-- Store profile pages show products.
+- Confirm a single-store key is used. Marketplace/network mode is disabled in v0.1.
## Catalog
- Product listing loads.
-- Product detail loads by ID or slug.
+- Product detail loads by the API public ID, and cards, canonical metadata, and sitemap entries use that same ID.
- Images render from allowed hosts.
- Categories, filters, sorting, and pagination pass through to the Storefront API.
- Variants and addons can be selected and priced.
@@ -26,13 +47,29 @@ Use this checklist before cutting `v0.1.0`.
- Orders list loads for an authenticated customer.
- Order detail and tracking pages load when the API exposes the order.
- Saved places load in the account Addresses tab when the API exposes them.
+- Profile updates require the authenticated customer's token and refresh the local profile cookies.
## Cart and Checkout
- Cart is created and persisted with the `cart_id` cookie.
- Add, update quantity, remove item, and empty-state flows work.
-- Pickup checkout prepares and captures an order.
+- Pickup checkout prepares and captures an order with a supported gateway.
- Delivery checkout fetches a service quote before capture.
- Stripe confirmation works with test keys.
Record API mismatches as frontend fixes or upstream Storefront API issues.
+
+## Mandatory automated evidence
+
+Run `pnpm install --frozen-lockfile`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm test:coverage`, `pnpm coverage:uncovered`, `pnpm test:e2e`, `pnpm build`, and `pnpm verify`. Coverage must be exactly 100% for statements, branches, functions, and lines. Build and run the Docker image, confirm its configured user is `storefront`, check `/api/health` returns 200 with valid HTTPS configuration and 503 without it, then run the Trivy high/critical scan.
+
+## Resolved upstream release blockers
+
+Storefront API `dev-v0.4.20` includes the required backend fixes:
+
+- PR #92 serializes capture with an atomic lock and returns the authoritative existing order or a retryable `409` during an in-progress capture.
+- PR #93 permits cash pickup without a delivery quote; Storefront Web now exercises that path in unit and browser tests.
+- PR #94 requires `Customer-Token` ownership for customer updates; Storefront Web now exposes authenticated profile editing through a validated same-origin route.
+- PR #95 links Stripe's PaymentIntent to the checkout, retrieves it server-side, and verifies status, amount, received amount, currency, customer, and live/test mode before order creation.
+
+The corresponding public request/authentication and checkout outcome changes are proposed in `fleetbase/postman` PR #54.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 412e0e5..4cc1cc2 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,27 +1,18 @@
-import nextPlugin from '@next/eslint-plugin-next';
-import tsParser from '@typescript-eslint/parser';
+import { defineConfig, globalIgnores } from 'eslint/config';
+import js from '@eslint/js';
+import { FlatCompat } from '@eslint/eslintrc';
-export default [
- {
- ignores: ['.next/**', 'node_modules/**']
- },
+const compat = new FlatCompat({
+ baseDirectory: import.meta.dirname,
+ recommendedConfig: js.configs.recommended
+});
+
+export default defineConfig([
+ ...compat.config({ extends: ['next/core-web-vitals', 'next/typescript'] }),
{
- files: ['**/*.{ts,tsx}'],
- languageOptions: {
- parser: tsParser,
- parserOptions: {
- ecmaFeatures: { jsx: true },
- ecmaVersion: 'latest',
- sourceType: 'module'
- }
- },
- plugins: {
- '@next/next': nextPlugin
- },
rules: {
- ...nextPlugin.configs.recommended.rules,
- ...nextPlugin.configs['core-web-vitals'].rules,
'no-duplicate-imports': 'error'
}
- }
-];
+ },
+ globalIgnores(['.next/**', 'node_modules/**', 'coverage/**', 'playwright-report/**', 'test-results/**', 'next-env.d.ts'])
+]);
diff --git a/features/cart/add-to-cart-form.tsx b/features/cart/add-to-cart-form.tsx
index c2d2baa..4934669 100644
--- a/features/cart/add-to-cart-form.tsx
+++ b/features/cart/add-to-cart-form.tsx
@@ -3,7 +3,7 @@
import { useMemo, useState } from 'react';
import { ShoppingCart } from 'lucide-react';
import type { ProductAddon, ProductVariantOption, StorefrontProduct } from '@/lib/types';
-import { formatMoney } from '@/lib/format';
+import { effectiveProductPrice, formatMoney } from '@/lib/format';
type SelectionState = Record;
@@ -15,7 +15,7 @@ export function AddToCartForm({ product }: { product: StorefrontProduct }) {
const [loading, setLoading] = useState(false);
const optionTotal = useMemo(() => {
- let total = product.sale_price ?? product.price ?? 0;
+ let total = effectiveProductPrice(product);
const selectedVariantIds = new Set(Object.values(variants).flat());
const selectedAddonIds = new Set(Object.values(addons).flat());
@@ -44,6 +44,10 @@ export function AddToCartForm({ product }: { product: StorefrontProduct }) {
setStatus(null);
try {
+ const missingRequiredAddon = product.addon_categories?.find((category) => category.required && !Object.values(addons[category.id] ? { selected: addons[category.id] } : {}).flat().length);
+ if (missingRequiredAddon) {
+ throw new Error(`Choose an option for ${missingRequiredAddon.name}.`);
+ }
const response = await fetch('/api/storefront/cart/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -104,7 +108,7 @@ export function AddToCartForm({ product }: { product: StorefrontProduct }) {
))}
Quantity
- setQuantity(Math.max(1, Number(event.target.value)))} />
+ setQuantity(Math.min(999, Math.max(1, Number(event.target.value) || 1)))} />
{loading ? 'Adding...' : `Add ${formatMoney(optionTotal, product.currency)}`}
diff --git a/features/cart/cart-page-client.tsx b/features/cart/cart-page-client.tsx
index 4ef952d..ee65e53 100644
--- a/features/cart/cart-page-client.tsx
+++ b/features/cart/cart-page-client.tsx
@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
import { Trash2 } from 'lucide-react';
import type { Cart } from '@/lib/types';
import { formatMoney } from '@/lib/format';
+import { Slot } from '@/components/plugin-slot';
export function CartPageClient() {
const [cart, setCart] = useState(null);
@@ -47,6 +48,16 @@ export function CartPageClient() {
setUpdatingItemId(null);
}
+ async function empty() {
+ if (!window.confirm('Remove every item from your cart?')) return;
+ setLoading(true);
+ const response = await fetch('/api/storefront/cart', { method: 'DELETE' });
+ const payload = await response.json();
+ setCart(payload.cart ?? null);
+ setError(payload.error ?? null);
+ setLoading(false);
+ }
+
useEffect(() => {
loadCart();
}, []);
@@ -77,6 +88,7 @@ export function CartPageClient() {
className="field"
type="number"
min={1}
+ max={999}
defaultValue={item.quantity}
disabled={updatingItemId === item.id}
onBlur={(event) => updateQuantity(item.id, Number(event.target.value))}
@@ -95,7 +107,9 @@ export function CartPageClient() {
Subtotal
{formatMoney(cart.subtotal, cart.currency)}
+
Checkout
+ Empty cart
) : !loading ? (
diff --git a/features/checkout/checkout-page-client.tsx b/features/checkout/checkout-page-client.tsx
index fd2ad4c..654619a 100644
--- a/features/checkout/checkout-page-client.tsx
+++ b/features/checkout/checkout-page-client.tsx
@@ -1,181 +1,178 @@
'use client';
-import { useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
-import type { Cart, Order, PaymentGateway, ServiceQuote } from '@/lib/types';
-import { formatMoney } from '@/lib/format';
+import { useEffect, useMemo, useState } from 'react';
import { Slot } from '@/components/plugin-slot';
+import { formatMoney } from '@/lib/format';
+import type { Cart, CustomerPlace, Order, PaymentGateway, PreparedCheckout, ServiceQuote, StoreLocation } from '@/lib/types';
+import { StripePaymentForm } from './stripe-payment-form';
+
+const stripeKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? '';
+const stripePromise = stripeKey ? loadStripe(stripeKey) : null;
+const supportedGatewayCodes = new Set(['stripe', 'cash']);
+const gatewayCode = (gateway: PaymentGateway) => gateway.code ?? gateway.id;
export function CheckoutPageClient() {
const [cart, setCart] = useState(null);
const [gateways, setGateways] = useState([]);
- const [gateway, setGateway] = useState('stripe');
+ const [locations, setLocations] = useState([]);
+ const [places, setPlaces] = useState([]);
+ const [gateway, setGateway] = useState('');
const [pickup, setPickup] = useState(false);
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
const [notes, setNotes] = useState('');
const [quote, setQuote] = useState(null);
- const [status, setStatus] = useState(null);
+ const [prepared, setPrepared] = useState(null);
+ const [status, setStatus] = useState('Loading checkout...');
const [order, setOrder] = useState(null);
const [loading, setLoading] = useState(false);
const [quoteLoading, setQuoteLoading] = useState(false);
-
const total = useMemo(() => (cart?.subtotal ?? 0) + (pickup ? 0 : quote?.amount ?? 0), [cart, pickup, quote]);
useEffect(() => {
- Promise.all([fetch('/api/storefront/cart').then((response) => response.json()), fetch('/api/storefront/gateways').then((response) => response.json())]).then(([cartPayload, gatewaysPayload]) => {
+ let active = true;
+ Promise.all([
+ fetch('/api/storefront/cart'), fetch('/api/storefront/gateways'),
+ fetch('/api/storefront/store-locations'), fetch('/api/storefront/customer/places')
+ ]).then(async (responses) => {
+ const [cartPayload, gatewaysPayload, locationsPayload, placesPayload] = await Promise.all(responses.map((response) => response.json()));
+ if (!active) return;
+ const availableGateways = ((gatewaysPayload.gateways ?? []) as PaymentGateway[]).filter((candidate) => supportedGatewayCodes.has(gatewayCode(candidate)));
setCart(cartPayload.cart ?? null);
- setGateways(gatewaysPayload.gateways ?? []);
- });
+ setGateways(availableGateways);
+ setLocations(locationsPayload.locations ?? []);
+ setPlaces(placesPayload.places ?? []);
+ setGateway(gatewayCode(availableGateways[0] ?? { id: '' }));
+ setOrigin(locationsPayload.locations?.[0]?.place?.id ?? '');
+ setDestination(placesPayload.places?.[0]?.id ?? '');
+ setStatus(cartPayload.error ?? gatewaysPayload.error ?? locationsPayload.error ?? '');
+ }).catch(() => active && setStatus('Unable to load checkout. Try again.'));
+ return () => { active = false; };
}, []);
+ useEffect(() => {
+ setQuote(null);
+ setPrepared(null);
+ }, [origin, destination, pickup, gateway]);
+
async function fetchQuote() {
- if (!cart?.id || pickup || !origin || !destination) {
+ if (!cart?.id || pickup || !origin || !destination) return;
+ setQuoteLoading(true);
+ setStatus('');
+ try {
+ const response = await fetch('/api/storefront/service-quote', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ cart: cart.id, origin, destination })
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.error ?? 'Unable to calculate delivery.');
+ setQuote(payload.quote ?? null);
+ } catch (cause) {
setQuote(null);
- return;
+ setStatus(cause instanceof Error ? cause.message : 'Unable to calculate delivery.');
+ } finally {
+ setQuoteLoading(false);
}
-
- setQuoteLoading(true);
- setStatus(null);
- const response = await fetch('/api/storefront/service-quote', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ cart: cart.id, origin, destination })
- });
- const payload = await response.json();
- setQuote(payload.quote ?? null);
- setStatus(payload.error ?? null);
- setQuoteLoading(false);
}
- async function placeOrder(event: React.FormEvent) {
+ async function prepareOrder(event: React.FormEvent) {
event.preventDefault();
+ if (loading) return;
setLoading(true);
- setStatus(null);
-
+ setStatus('');
try {
- if (!cart?.items?.length) {
- throw new Error('Your cart is empty.');
- }
-
- if (!pickup && !quote?.id) {
- throw new Error('Fetch a delivery quote before placing this order.');
- }
-
- const prepareResponse = await fetch('/api/storefront/checkout/prepare', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- gateway,
- cart: cart?.id,
- service_quote: pickup ? undefined : quote?.id,
- pickup,
- notes
- })
+ if (!cart?.items?.length) throw new Error('Your cart is empty.');
+ if (!gateway) throw new Error('No supported payment method is configured.');
+ if (!pickup && !quote?.id) throw new Error('Calculate delivery before placing this order.');
+ const response = await fetch('/api/storefront/checkout/prepare', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ gateway, cart: cart.id, service_quote: pickup ? undefined : quote?.id, pickup, cash: gateway === 'cash' })
});
- const prepared = await prepareResponse.json();
-
- if (!prepareResponse.ok) {
- throw new Error(prepared.error ?? 'Unable to prepare checkout.');
- }
-
- if (gateway === 'stripe' && prepared.clientSecret) {
- const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? '');
- if (!stripe) {
- throw new Error('Stripe is not configured.');
- }
- const confirmation = await stripe.confirmPayment({
- clientSecret: prepared.clientSecret,
- redirect: 'if_required',
- confirmParams: {
- return_url: `${window.location.origin}/checkout`
- }
+ const checkout = await response.json() as PreparedCheckout & { error?: string };
+ if (!response.ok) throw new Error(checkout.error ?? 'Unable to prepare checkout.');
+ if (gateway === 'stripe') {
+ if (!stripePromise || !checkout.clientSecret) throw new Error('Card payments are not configured correctly.');
+ setPrepared(checkout);
+ } else {
+ const captureResponse = await fetch('/api/storefront/checkout/capture', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: checkout.token, notes, idempotencyKey: crypto.randomUUID() })
});
- if (confirmation.error) {
- throw new Error(confirmation.error.message);
- }
+ const captured = await captureResponse.json();
+ if (!captureResponse.ok) throw new Error(captured.error ?? 'Unable to place order.');
+ setOrder(captured.order);
}
-
- const captureResponse = await fetch('/api/storefront/checkout/capture', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ token: prepared.token, notes })
- });
- const captured = await captureResponse.json();
-
- if (!captureResponse.ok) {
- throw new Error(captured.error ?? 'Unable to place order.');
- }
-
- setOrder(captured.order);
- setStatus('Order placed.');
- } catch (error) {
- setStatus(error instanceof Error ? error.message : 'Unable to place order.');
+ } catch (cause) {
+ setStatus(cause instanceof Error ? cause.message : 'Unable to place order.');
} finally {
setLoading(false);
}
}
- if (order) {
- return (
-
- Order placed
- Your order was created successfully.
- Order: {order.id ?? order.public_id}
-
- );
- }
+ if (order) return (
+
+ Order placed Your order was created successfully.
+ Order: {order.public_id ?? order.id}
+ View order
+
+ );
return (
diff --git a/features/checkout/stripe-payment-form.tsx b/features/checkout/stripe-payment-form.tsx
new file mode 100644
index 0000000..2f736c9
--- /dev/null
+++ b/features/checkout/stripe-payment-form.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
+import { useState } from 'react';
+import type { Order } from '@/lib/types';
+
+type StripePaymentFormProps = {
+ checkoutToken: string;
+ notes: string;
+ onCaptured: (order: Order) => void;
+ onCancel: () => void;
+};
+
+export function StripePaymentForm({ checkoutToken, notes, onCaptured, onCancel }: StripePaymentFormProps) {
+ const stripe = useStripe();
+ const elements = useElements();
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ if (!stripe || !elements || submitting) return;
+
+ setSubmitting(true);
+ setError(null);
+ try {
+ const confirmation = await stripe.confirmPayment({
+ elements,
+ redirect: 'if_required',
+ confirmParams: { return_url: `${window.location.origin}/checkout` }
+ });
+ if (confirmation.error) throw new Error(confirmation.error.message ?? 'Payment could not be confirmed.');
+ if (confirmation.paymentIntent?.status !== 'succeeded' && confirmation.paymentIntent?.status !== 'processing') {
+ throw new Error('Payment is not ready to capture.');
+ }
+
+ const response = await fetch('/api/storefront/checkout/capture', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: checkoutToken, notes, idempotencyKey: crypto.randomUUID() })
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.error ?? 'Unable to place order.');
+ onCaptured(payload.order);
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : 'Unable to complete payment.');
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ {submitting ? 'Processing payment...' : 'Pay and place order'}
+ Back
+
+ {error && {error}
}
+
+ );
+}
diff --git a/features/customer/account-dashboard-client.tsx b/features/customer/account-dashboard-client.tsx
index 7c4e60b..94335e0 100644
--- a/features/customer/account-dashboard-client.tsx
+++ b/features/customer/account-dashboard-client.tsx
@@ -4,6 +4,7 @@ import Link from 'next/link';
import React, { useEffect, useState } from 'react';
import type { Customer, CustomerPlace, Order } from '@/lib/types';
import { formatMoney } from '@/lib/format';
+import { navigateBrowser } from '@/lib/navigation';
type Tab = 'overview' | 'orders' | 'addresses' | 'profile';
@@ -39,7 +40,7 @@ export function AccountDashboardClient({ initialCustomer }: { initialCustomer?:
async function logout() {
const response = await fetch('/api/storefront/auth/logout', { method: 'POST' });
if (response.ok) {
- window.location.href = '/login';
+ navigateBrowser('/login');
return;
}
setStatus('Unable to sign out.');
@@ -122,13 +123,8 @@ export function AccountDashboardClient({ initialCustomer }: { initialCustomer?:
{tab === 'profile' && (
<>
Profile
-
- Profile editing, password reset, and saved payment methods will be enabled once the Storefront API exposes those customer management endpoints.
+ {customer ? : Customer details are unavailable.
}
+ Password reset and saved payment management are outside this release.
{status && {status}
}
>
)}
@@ -157,12 +153,45 @@ function EmptyState({ title, description }: { title: string; description: string
);
}
-function ProfileField({ label, value }: { label: string; value?: string }) {
+function ProfileForm({ customer, onSaved }: { customer: Customer; onSaved: (customer: Customer) => void }) {
+ const [name, setName] = useState(customer.name ?? '');
+ const [email, setEmail] = useState(customer.email ?? '');
+ const [phone, setPhone] = useState(customer.phone ?? '');
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useState(null);
+
+ async function save(event: React.FormEvent) {
+ event.preventDefault();
+ if (saving) return;
+ setSaving(true);
+ setMessage(null);
+ try {
+ const response = await fetch('/api/storefront/customer/profile', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, email, phone })
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.error ?? 'Unable to update your profile.');
+ onSaved(payload.customer);
+ setMessage('Profile updated.');
+ } catch (cause) {
+ setMessage(cause instanceof Error ? cause.message : 'Unable to update your profile.');
+ } finally {
+ setSaving(false);
+ }
+ }
+
return (
-
- {label}
- {value ?? 'Not provided'}
-
+
+
+ Name setName(event.target.value)} />
+ Email setEmail(event.target.value)} />
+ Phone setPhone(event.target.value)} />
+
+ {saving ? 'Saving...' : 'Save profile'}
+ {message && {message}
}
+
);
}
diff --git a/features/customer/login-page-client.tsx b/features/customer/login-page-client.tsx
index b642047..398ae11 100644
--- a/features/customer/login-page-client.tsx
+++ b/features/customer/login-page-client.tsx
@@ -1,15 +1,15 @@
'use client';
import Link from 'next/link';
-import { useRouter, useSearchParams } from 'next/navigation';
+import { useSearchParams } from 'next/navigation';
import React, { useState } from 'react';
import { AuthShell } from '@/components/auth/auth-shell';
import { StatusMessage } from '@/components/auth/status-message';
+import { navigateBrowser, safeRedirectPath } from '@/lib/navigation';
export function LoginPageClient() {
- const router = useRouter();
const searchParams = useSearchParams();
- const next = searchParams.get('next') ?? '/account';
+ const next = safeRedirectPath(searchParams.get('next'));
const [identity, setIdentity] = useState('');
const [password, setPassword] = useState('');
const [status, setStatus] = useState(null);
@@ -32,8 +32,7 @@ export function LoginPageClient() {
return;
}
- router.push(next);
- router.refresh();
+ navigateBrowser(next);
}
return (
diff --git a/features/customer/register-page-client.tsx b/features/customer/register-page-client.tsx
index 3b3391b..1c527c0 100644
--- a/features/customer/register-page-client.tsx
+++ b/features/customer/register-page-client.tsx
@@ -1,15 +1,15 @@
'use client';
import Link from 'next/link';
-import { useRouter, useSearchParams } from 'next/navigation';
+import { useSearchParams } from 'next/navigation';
import React, { useState } from 'react';
import { AuthShell } from '@/components/auth/auth-shell';
import { StatusMessage } from '@/components/auth/status-message';
+import { navigateBrowser, safeRedirectPath } from '@/lib/navigation';
export function RegisterPageClient() {
- const router = useRouter();
const searchParams = useSearchParams();
- const next = searchParams.get('next') ?? '/account';
+ const next = safeRedirectPath(searchParams.get('next'));
const [identity, setIdentity] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
@@ -41,8 +41,7 @@ export function RegisterPageClient() {
return;
}
- router.push(next);
- router.refresh();
+ navigateBrowser(next);
}
return (
diff --git a/features/customer/sms-login-page-client.tsx b/features/customer/sms-login-page-client.tsx
index fe6a40b..8ae1eca 100644
--- a/features/customer/sms-login-page-client.tsx
+++ b/features/customer/sms-login-page-client.tsx
@@ -1,15 +1,15 @@
'use client';
import Link from 'next/link';
-import { useRouter, useSearchParams } from 'next/navigation';
+import { useSearchParams } from 'next/navigation';
import React, { useState } from 'react';
import { AuthShell } from '@/components/auth/auth-shell';
import { StatusMessage } from '@/components/auth/status-message';
+import { navigateBrowser, safeRedirectPath } from '@/lib/navigation';
export function SmsLoginPageClient() {
- const router = useRouter();
const searchParams = useSearchParams();
- const next = searchParams.get('next') ?? '/account';
+ const next = safeRedirectPath(searchParams.get('next'));
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [requested, setRequested] = useState(false);
@@ -39,8 +39,7 @@ export function SmsLoginPageClient() {
return;
}
- router.push(next);
- router.refresh();
+ navigateBrowser(next);
}
return (
diff --git a/lib/api.ts b/lib/api.ts
new file mode 100644
index 0000000..3b6b055
--- /dev/null
+++ b/lib/api.ts
@@ -0,0 +1,56 @@
+import { NextResponse } from 'next/server';
+import type { ZodType } from 'zod';
+import { UpstreamError } from '@/lib/http';
+
+export const DEFAULT_MAX_JSON_BYTES = 16 * 1024;
+
+export class RequestValidationError extends Error {
+ constructor(message: string, readonly status = 400) {
+ super(message);
+ this.name = 'RequestValidationError';
+ }
+}
+
+export async function readJsonBody(request: Request, schema: ZodType, maxBytes = DEFAULT_MAX_JSON_BYTES): Promise {
+ const declaredLength = Number(request.headers.get('content-length') ?? 0);
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
+ throw new RequestValidationError('Request body is too large.', 413);
+ }
+
+ const text = await request.text();
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
+ throw new RequestValidationError('Request body is too large.', 413);
+ }
+
+ let payload: unknown;
+ try {
+ payload = JSON.parse(text);
+ } catch {
+ throw new RequestValidationError('Request body must be valid JSON.');
+ }
+
+ const result = schema.safeParse(payload);
+ if (!result.success) {
+ throw new RequestValidationError(result.error.issues[0]?.message ?? 'Request body is invalid.');
+ }
+
+ return result.data;
+}
+
+export function apiError(error: unknown, fallback: string, fallbackStatus = 500) {
+ if (error instanceof RequestValidationError) {
+ return NextResponse.json({ error: error.message }, { status: error.status });
+ }
+
+ if (error instanceof UpstreamError) {
+ const status = error.status >= 400 && error.status < 500 ? error.status : fallbackStatus;
+ return NextResponse.json({ error: fallback }, { status });
+ }
+
+ return NextResponse.json({ error: fallback }, { status: fallbackStatus });
+}
+
+export function getRequestIp(request: Request) {
+ const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
+ return forwarded || request.headers.get('x-real-ip') || 'unknown';
+}
diff --git a/lib/contracts.ts b/lib/contracts.ts
new file mode 100644
index 0000000..12fa329
--- /dev/null
+++ b/lib/contracts.ts
@@ -0,0 +1,175 @@
+import { z } from 'zod';
+
+const identifier = z.string().trim().min(1).max(191);
+const optionalText = z.string().trim().max(10_000).nullish();
+const money = z.coerce.number().finite();
+const nullableString = z.string().nullish();
+
+const variantOptionSchema = z.object({ id: identifier, name: z.string(), price: money.nullish(), description: nullableString }).passthrough();
+const variantSchema = z.object({ id: identifier, name: z.string(), description: nullableString, required: z.boolean().optional(), options: z.array(variantOptionSchema).optional() }).passthrough();
+const addonSchema = z.object({ id: identifier, name: z.string(), price: money.nullish(), description: nullableString }).passthrough();
+const addonCategorySchema = z.object({ id: identifier, name: z.string(), required: z.boolean().optional(), multi_select: z.boolean().optional(), addons: z.array(addonSchema).optional() }).passthrough();
+
+export const storefrontProductSchema = z.object({
+ id: identifier,
+ name: z.string(),
+ description: nullableString,
+ price: money,
+ sale_price: money.nullish(),
+ currency: nullableString,
+ primary_image_url: nullableString,
+ is_on_sale: z.boolean().optional(),
+ is_available: z.boolean().optional(),
+ tags: z.array(z.string()).optional(),
+ addon_categories: z.array(addonCategorySchema).optional(),
+ variants: z.array(variantSchema).optional(),
+ images: z.array(z.string().url()).optional(),
+ videos: z.array(z.string().url()).optional()
+}).passthrough();
+
+export const storefrontAboutSchema = z.object({
+ id: identifier,
+ name: z.string(),
+ description: nullableString,
+ logo_url: nullableString,
+ backdrop_url: nullableString,
+ is_network: z.boolean().optional(),
+ is_store: z.boolean().optional(),
+ online: z.boolean().optional()
+}).passthrough();
+
+export const storefrontCategorySchema = z.object({
+ id: identifier,
+ name: z.string(),
+ description: nullableString,
+ slug: nullableString,
+ products: z.array(storefrontProductSchema).optional()
+}).passthrough();
+
+export const cartItemSchema = z.object({
+ id: identifier, product_id: identifier, name: z.string(), quantity: z.coerce.number().int().positive(), subtotal: money
+}).passthrough();
+
+export const cartSchema = z.object({
+ id: identifier,
+ currency: nullableString,
+ subtotal: money.nullish(),
+ total_items: z.coerce.number().int().nonnegative().optional(),
+ total_unique_items: z.coerce.number().int().nonnegative().optional(),
+ items: z.array(cartItemSchema),
+ expires_at: nullableString
+}).passthrough();
+
+export const customerPlaceSchema = z.object({ id: identifier, name: nullableString, address: nullableString }).passthrough();
+export const storeLocationSchema = z.object({ id: identifier, name: z.string(), store: nullableString, place: customerPlaceSchema.nullish(), hours: z.array(z.record(z.string(), z.unknown())).optional() }).passthrough();
+export const paymentGatewaySchema = z.object({ id: identifier, code: nullableString, name: nullableString, type: nullableString, sandbox: z.boolean().optional() }).passthrough();
+export const serviceQuoteSchema = z.object({ id: identifier, amount: money, currency: nullableString, distance: money.optional(), time: money.optional() }).passthrough();
+export const preparedCheckoutSchema = z.object({ checkout: identifier, token: z.string().min(1), paymentIntent: z.string().optional(), clientSecret: z.string().optional(), customerId: z.string().optional() }).passthrough();
+export const orderSchema = z.object({ id: identifier, public_id: z.string().optional(), status: z.string().optional(), items: z.array(cartItemSchema).optional(), total: money.optional(), subtotal: money.optional(), currency: z.string().optional() }).passthrough();
+
+export const customerSchema = z.object({
+ id: identifier,
+ name: z.string().max(191).nullish(),
+ email: z.string().email().max(254).nullish(),
+ phone: z.string().max(64).nullish(),
+ token: z.string().min(1).max(4096).optional()
+}).passthrough();
+
+export const authCustomerSchema = customerSchema.extend({
+ token: z.string().min(1).max(4096)
+});
+
+export const loginInputSchema = z.object({
+ identity: z.string().trim().min(1).max(254),
+ password: z.string().min(1).max(1024)
+}).strict();
+
+export const smsInputSchema = z.union([
+ z.object({ phone: z.string().trim().min(3).max(64) }).strict(),
+ z.object({ phone: z.string().trim().min(3).max(64), code: z.string().trim().min(1).max(32) }).strict()
+]);
+
+export const registrationInputSchema = z.union([
+ z.object({
+ identity: z.string().trim().min(1).max(254),
+ mode: z.enum(['email', 'sms']).default('email')
+ }).strict(),
+ z.object({
+ identity: z.string().trim().min(1).max(254),
+ code: z.string().trim().min(1).max(32),
+ name: z.string().trim().max(191).optional(),
+ password: z.string().max(1024).optional()
+ }).strict()
+]);
+
+export const customerUpdateInputSchema = z.object({
+ name: z.string().trim().min(1).max(191),
+ email: z.string().trim().email().max(254),
+ phone: z.string().trim().max(64)
+}).strict();
+
+export const cartItemInputSchema = z.object({
+ productId: identifier,
+ quantity: z.coerce.number().int().min(1).max(999).default(1),
+ variants: z.array(z.record(z.string(), z.unknown())).max(100).optional(),
+ addons: z.array(z.record(z.string(), z.unknown())).max(100).optional(),
+ store_location: identifier.optional(),
+ store: identifier.optional()
+}).strict();
+
+export const cartItemUpdateSchema = z.object({
+ lineItemId: identifier,
+ quantity: z.coerce.number().int().min(1).max(999),
+ variants: z.array(z.record(z.string(), z.unknown())).max(100).optional(),
+ addons: z.array(z.record(z.string(), z.unknown())).max(100).optional()
+}).strict();
+
+export const cartItemDeleteSchema = z.object({ lineItemId: identifier }).strict();
+
+export const serviceQuoteInputSchema = z.object({
+ origin: z.union([identifier, z.object({ latitude: z.number(), longitude: z.number() }).strict()]),
+ destination: z.union([identifier, z.object({ latitude: z.number(), longitude: z.number() }).strict()]),
+ cart: identifier
+}).strict();
+
+export const checkoutPrepareInputSchema = z.object({
+ gateway: identifier,
+ cart: identifier,
+ service_quote: identifier.optional(),
+ pickup: z.boolean().default(false),
+ tip: z.union([money, z.string().regex(/^\d+(?:\.\d+)?%$/)]).optional(),
+ delivery_tip: z.union([money, z.string().regex(/^\d+(?:\.\d+)?%$/)]).optional(),
+ cash: z.boolean().optional()
+}).strict().superRefine((value, context) => {
+ if (!value.pickup && !value.service_quote) {
+ context.addIssue({ code: 'custom', path: ['service_quote'], message: 'A delivery quote is required.' });
+ }
+});
+
+export const checkoutCaptureInputSchema = z.object({
+ token: z.string().trim().min(1).max(4096),
+ notes: optionalText,
+ transactionDetails: z.record(z.string(), z.unknown()).optional(),
+ idempotencyKey: z.string().uuid().optional()
+}).strict();
+
+export const statusResponseSchema = z.object({
+ status: z.string().optional(),
+ method: z.string().optional(),
+ ok: z.boolean().optional()
+}).passthrough();
+
+export const paginationMetaSchema = z.object({
+ current_page: z.coerce.number().int().positive().optional(),
+ last_page: z.coerce.number().int().positive().optional(),
+ per_page: z.coerce.number().int().positive().optional(),
+ total: z.coerce.number().int().nonnegative().optional()
+}).passthrough();
+
+export type AuthCustomer = z.infer;
+export type LoginInput = z.infer;
+export type SmsInput = z.infer;
+export type RegistrationInput = z.infer;
+export type CustomerUpdateInput = z.infer;
+export type CheckoutPrepareInput = z.infer;
+export type CheckoutCaptureInput = z.infer;
diff --git a/lib/customer-session.ts b/lib/customer-session.ts
index c9baf40..071e3dd 100644
--- a/lib/customer-session.ts
+++ b/lib/customer-session.ts
@@ -22,3 +22,11 @@ export function setCustomerCookies(response: NextResponse, customer?: Customer |
response.cookies.set('customer_phone', customer.phone, options);
}
}
+
+export function clearCustomerCookies(response: NextResponse) {
+ response.cookies.delete('customer_token');
+ response.cookies.delete('customer_id');
+ response.cookies.delete('customer_name');
+ response.cookies.delete('customer_email');
+ response.cookies.delete('customer_phone');
+}
diff --git a/lib/fleetbase/server.ts b/lib/fleetbase/server.ts
index f1f0ba1..44add00 100644
--- a/lib/fleetbase/server.ts
+++ b/lib/fleetbase/server.ts
@@ -1,14 +1,19 @@
import { cookies, headers } from 'next/headers';
import { requireEnv } from '@/lib/config';
import { parseJsonResponse } from '@/lib/http';
+import type { ZodType } from 'zod';
+import { runAfterFleetbaseResponseHooks, runBeforeFleetbaseRequestHooks } from '@/plugins/registry';
type FleetbaseRequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
query?: Record;
body?: unknown;
customerToken?: string | null;
+ authenticated?: boolean;
cache?: RequestCache;
next?: NextFetchRequestConfig;
+ timeoutMs?: number;
+ schema?: ZodType;
};
function endpoint(path: string, query?: Record) {
@@ -34,11 +39,13 @@ async function getLocale() {
export async function storefrontRequest(path: string, options: FleetbaseRequestOptions = {}): Promise {
const method = options.method ?? 'GET';
- const customerToken = options.customerToken === undefined ? await getCustomerTokenFromCookies() : options.customerToken;
+ const customerToken = options.customerToken === undefined && options.authenticated ? await getCustomerTokenFromCookies() : options.customerToken;
+ const requestId = (await headers()).get('x-request-id') ?? crypto.randomUUID();
const requestHeaders: Record = {
Authorization: `Bearer ${requireEnv('STOREFRONT_KEY')}`,
'Accept-Language': await getLocale(),
- Accept: 'application/json'
+ Accept: 'application/json',
+ 'X-Request-ID': requestId
};
if (customerToken) {
@@ -49,13 +56,24 @@ export async function storefrontRequest(path: string, options: FleetbaseReque
requestHeaders['Content-Type'] = 'application/json';
}
- const response = await fetch(endpoint(path, options.query), {
+ const fetchOptions: RequestInit & { next?: NextFetchRequestConfig } = {
method,
headers: requestHeaders,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
- cache: options.cache ?? (method === 'GET' ? 'no-store' : 'no-store'),
- next: options.next
- });
+ signal: AbortSignal.timeout(options.timeoutMs ?? Number(process.env.FLEETBASE_REQUEST_TIMEOUT_MS ?? 10_000))
+ };
+
+ if (options.cache) {
+ fetchOptions.cache = options.cache;
+ } else if (options.next) {
+ fetchOptions.next = options.next;
+ } else {
+ fetchOptions.cache = 'no-store';
+ }
+
+ const request = await runBeforeFleetbaseRequestHooks(new Request(endpoint(path, options.query), fetchOptions));
+ const response = await runAfterFleetbaseResponseHooks(await fetch(request));
+ const payload = await parseJsonResponse(response);
- return parseJsonResponse(response);
+ return options.schema ? options.schema.parse(payload) as T : payload;
}
diff --git a/lib/fleetbase/storefront.ts b/lib/fleetbase/storefront.ts
index 6d4adcb..f1d13b8 100644
--- a/lib/fleetbase/storefront.ts
+++ b/lib/fleetbase/storefront.ts
@@ -6,104 +6,143 @@ import type {
Order,
PaymentGateway,
ServiceQuote,
+ StoreLocation,
+ PreparedCheckout,
StorefrontAbout,
StorefrontCategory,
StorefrontProduct
} from '@/lib/types';
import { storefrontRequest } from './server';
+import {
+ authCustomerSchema,
+ cartSchema,
+ customerSchema,
+ customerPlaceSchema,
+ orderSchema,
+ paymentGatewaySchema,
+ preparedCheckoutSchema,
+ serviceQuoteSchema,
+ statusResponseSchema,
+ storeLocationSchema,
+ storefrontAboutSchema,
+ storefrontCategorySchema,
+ storefrontProductSchema,
+ type CheckoutCaptureInput,
+ type CheckoutPrepareInput,
+ type CustomerUpdateInput,
+ type LoginInput,
+ type RegistrationInput
+} from '@/lib/contracts';
export async function getStorefrontAbout() {
- return storefrontRequest('about', { next: { revalidate: 120, tags: ['storefront-about'] } });
+ return storefrontRequest('about', { next: { revalidate: 120, tags: ['storefront-about'] }, schema: storefrontAboutSchema });
}
export async function getProducts(query: Record = {}) {
- return storefrontRequest('products', { query, next: { revalidate: 60, tags: ['storefront-products'] } });
+ return storefrontRequest('products', { query, next: { revalidate: 60, tags: ['storefront-products'] }, schema: storefrontProductSchema.array() });
}
export async function getProduct(id: string) {
- return storefrontRequest(`products/${id}`, { next: { revalidate: 60, tags: [`storefront-product-${id}`] } });
+ return storefrontRequest(`products/${id}`, { next: { revalidate: 60, tags: [`storefront-product-${id}`] }, schema: storefrontProductSchema });
}
export async function getCategories(query: Record = {}) {
- return storefrontRequest('categories', { query, next: { revalidate: 120, tags: ['storefront-categories'] } });
+ return storefrontRequest('categories', { query, next: { revalidate: 120, tags: ['storefront-categories'] }, schema: storefrontCategorySchema.array() });
}
export async function searchProducts(query: string, options: Record = {}) {
- return storefrontRequest('search', { query: { query, ...options }, cache: 'no-store' });
+ return storefrontRequest('search', { query: { query, ...options }, cache: 'no-store', schema: storefrontProductSchema.array() });
}
export async function getNetworkStores() {
- return storefrontRequest('stores', { next: { revalidate: 120, tags: ['storefront-network-stores'] } });
+ return storefrontRequest('stores', { next: { revalidate: 120, tags: ['storefront-network-stores'] }, schema: storefrontAboutSchema.array() });
}
export async function getGateways() {
- return storefrontRequest('gateways', { cache: 'no-store' });
+ return storefrontRequest('gateways', { cache: 'no-store', schema: paymentGatewaySchema.array() });
+}
+
+export async function getStoreLocations(query: Record = {}) {
+ return storefrontRequest('store-locations', { query, cache: 'no-store', schema: storeLocationSchema.array() });
}
export async function getCart(cartId?: string | null) {
- return storefrontRequest(cartId ? `carts/${cartId}` : 'carts', { cache: 'no-store' });
+ return storefrontRequest(cartId ? `carts/${cartId}` : 'carts', { cache: 'no-store', schema: cartSchema });
}
export async function addCartItem(cartId: string, productId: string, body: unknown) {
- return storefrontRequest(`carts/${cartId}/${productId}`, { method: 'POST', body });
+ return storefrontRequest(`carts/${cartId}/${productId}`, { method: 'POST', body, schema: cartSchema });
}
export async function updateCartItem(cartId: string, lineItemId: string, body: unknown) {
- return storefrontRequest(`carts/${cartId}/${lineItemId}`, { method: 'PUT', body });
+ return storefrontRequest(`carts/${cartId}/${lineItemId}`, { method: 'PUT', body, schema: cartSchema });
}
export async function removeCartItem(cartId: string, lineItemId: string) {
- return storefrontRequest(`carts/${cartId}/${lineItemId}`, { method: 'DELETE' });
+ return storefrontRequest(`carts/${cartId}/${lineItemId}`, { method: 'DELETE', schema: cartSchema });
}
export async function emptyCart(cartId: string) {
- return storefrontRequest(`carts/${cartId}/empty`, { method: 'PUT', body: {} });
+ return storefrontRequest(`carts/${cartId}/empty`, { method: 'PUT', body: {}, schema: cartSchema });
+}
+
+export async function loginCustomer(body: LoginInput) {
+ return storefrontRequest('customers/login', { method: 'POST', body, customerToken: null, schema: authCustomerSchema });
+}
+
+export async function requestSmsLogin(phone: string) {
+ return storefrontRequest>('customers/login-with-sms', { method: 'POST', body: { phone }, customerToken: null, schema: statusResponseSchema });
}
-export async function loginCustomer(body: unknown) {
- return storefrontRequest<{ token: string; customer: Customer }>('customers/login', { method: 'POST', body, customerToken: null });
+export async function verifyCustomerCode(identity: string, code: string) {
+ return storefrontRequest('customers/verify-code', { method: 'POST', body: { identity, code }, customerToken: null, schema: authCustomerSchema });
}
-export async function requestSmsLogin(body: unknown) {
- return storefrontRequest<{ ok: boolean }>('customers/login-with-sms', { method: 'POST', body, customerToken: null });
+export async function requestCustomerCreationCode(identity: string, mode: 'email' | 'sms') {
+ return storefrontRequest>('customers/request-creation-code', { method: 'POST', body: { identity, mode }, customerToken: null, schema: statusResponseSchema });
}
-export async function verifyCustomerCode(body: unknown) {
- return storefrontRequest<{ token: string; customer: Customer }>('customers/verify-code', { method: 'POST', body, customerToken: null });
+export async function createCustomer(body: Extract) {
+ return storefrontRequest('customers', { method: 'POST', body, customerToken: null, schema: authCustomerSchema });
}
-export async function requestCustomerCreationCode(body: unknown) {
- return storefrontRequest<{ ok: boolean }>('customers/request-creation-code', { method: 'POST', body, customerToken: null });
+export async function getCustomer(id: string) {
+ return storefrontRequest(`customers/${id}`, { authenticated: true, cache: 'no-store', schema: customerSchema });
}
-export async function createCustomer(body: unknown) {
- return storefrontRequest<{ token: string; customer: Customer }>('customers', { method: 'POST', body, customerToken: null });
+export async function updateCustomer(id: string, body: CustomerUpdateInput) {
+ return storefrontRequest(`customers/${id}`, { method: 'PUT', body, authenticated: true, schema: customerSchema });
}
export async function getCustomerPlaces() {
- return storefrontRequest('customers/places', { cache: 'no-store' });
+ return storefrontRequest('customers/places', { authenticated: true, cache: 'no-store', schema: customerPlaceSchema.array() });
}
-export async function getCustomerOrders() {
- return storefrontRequest('customers/orders', { cache: 'no-store' });
+export async function getCustomerOrders(query: Record = {}) {
+ return storefrontRequest('customers/orders', { query, authenticated: true, cache: 'no-store', schema: orderSchema.array() });
}
export async function getCustomerOrder(id: string) {
- return storefrontRequest(`customers/orders/${id}`, { cache: 'no-store' });
+ const orders = await getCustomerOrders({ query: id, limit: 100 });
+ const order = orders.find((candidate) => candidate.id === id || candidate.public_id === id);
+ if (!order) {
+ throw new Error('Order not found.');
+ }
+ return order;
}
export async function getServiceQuoteFromCart(query: Record) {
- return storefrontRequest('service-quotes/from-cart', { query, cache: 'no-store' });
+ return storefrontRequest('service-quotes/from-cart', { query, cache: 'no-store', schema: serviceQuoteSchema });
}
-export async function beforeCheckout(query: Record) {
- return storefrontRequest>('checkouts/before', { query, cache: 'no-store' });
+export async function beforeCheckout(query: CheckoutPrepareInput & { customer: string }) {
+ return storefrontRequest('checkouts/before', { query, authenticated: true, cache: 'no-store', schema: preparedCheckoutSchema });
}
export async function updateStripePaymentIntent(body: unknown) {
return storefrontRequest>('checkouts/stripe-update-payment-intent', { method: 'PUT', body });
}
-export async function captureCheckout(body: unknown) {
- return storefrontRequest('checkouts/capture', { method: 'POST', body });
+export async function captureCheckout(body: CheckoutCaptureInput) {
+ return storefrontRequest('checkouts/capture', { method: 'POST', body, authenticated: true, schema: orderSchema });
}
diff --git a/lib/format.ts b/lib/format.ts
index afa4814..d7801f9 100644
--- a/lib/format.ts
+++ b/lib/format.ts
@@ -7,8 +7,12 @@ export function formatMoney(value?: number | null, currency = 'USD') {
}).format(amount);
}
-export function productPrice(product: { price?: number; sale_price?: number | null; currency?: string }) {
- return formatMoney(product.sale_price ?? product.price ?? 0, product.currency ?? 'USD');
+export function effectiveProductPrice(product: { price?: number; sale_price?: number | null; is_on_sale?: boolean }) {
+ return product.is_on_sale === true && product.sale_price != null ? product.sale_price : product.price ?? 0;
+}
+
+export function productPrice(product: { price?: number; sale_price?: number | null; is_on_sale?: boolean; currency?: string }) {
+ return formatMoney(effectiveProductPrice(product), product.currency ?? 'USD');
}
export function compact(items: Array): T[] {
diff --git a/lib/http.ts b/lib/http.ts
index 422ba6b..e9dc92f 100644
--- a/lib/http.ts
+++ b/lib/http.ts
@@ -3,12 +3,19 @@ export type ApiResult = {
error?: string;
};
+export class UpstreamError extends Error {
+ constructor(readonly status: number, readonly publicMessage?: string) {
+ super(publicMessage ?? `Upstream request failed with ${status}`);
+ this.name = 'UpstreamError';
+ }
+}
+
export async function parseJsonResponse(response: Response): Promise {
const payload = await response.json().catch(() => null);
if (!response.ok) {
- const message = payload?.message ?? payload?.error ?? `Request failed with ${response.status}`;
- throw new Error(message);
+ const message = typeof payload?.message === 'string' ? payload.message : typeof payload?.error === 'string' ? payload.error : undefined;
+ throw new UpstreamError(response.status, message);
}
return payload as T;
diff --git a/lib/navigation.ts b/lib/navigation.ts
new file mode 100644
index 0000000..23dda28
--- /dev/null
+++ b/lib/navigation.ts
@@ -0,0 +1,10 @@
+export function safeRedirectPath(value: string | null | undefined, fallback = '/account') {
+ if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\') || /[\u0000-\u001f]/.test(value)) {
+ return fallback;
+ }
+ return value;
+}
+
+export function navigateBrowser(path: string, location: Pick = window.location) {
+ location.assign(path);
+}
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
new file mode 100644
index 0000000..b0bca2a
--- /dev/null
+++ b/lib/rate-limit.ts
@@ -0,0 +1,55 @@
+type LimitState = { count: number; resetAt: number };
+type RateLimitOptions = { namespace: string; identifier: string; limit: number; windowMs: number };
+
+const memoryStore = new Map();
+
+function memoryRateLimit({ namespace, identifier, limit, windowMs }: RateLimitOptions, now = Date.now()) {
+ const key = `${namespace}:${identifier}`;
+ const current = memoryStore.get(key);
+ const state = !current || current.resetAt <= now ? { count: 0, resetAt: now + windowMs } : current;
+ state.count += 1;
+ memoryStore.set(key, state);
+
+ return {
+ allowed: state.count <= limit,
+ limit,
+ remaining: Math.max(0, limit - state.count),
+ resetAt: state.resetAt
+ };
+}
+
+export async function checkRateLimit(options: RateLimitOptions) {
+ const endpoint = process.env.RATE_LIMIT_REST_URL;
+ const token = process.env.RATE_LIMIT_REST_TOKEN;
+
+ if (!endpoint || !token) {
+ return memoryRateLimit(options);
+ }
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify(options),
+ cache: 'no-store',
+ signal: AbortSignal.timeout(3_000)
+ });
+
+ if (!response.ok) {
+ throw new Error('The configured rate limit service is unavailable.');
+ }
+
+ const result = await response.json() as { allowed: boolean; remaining?: number; resetAt?: number };
+ return { allowed: result.allowed, limit: options.limit, remaining: result.remaining ?? 0, resetAt: result.resetAt ?? Date.now() + options.windowMs };
+}
+
+export function rateLimitHeaders(result: Awaited>) {
+ return {
+ 'RateLimit-Limit': String(result.limit),
+ 'RateLimit-Remaining': String(result.remaining),
+ 'RateLimit-Reset': String(Math.ceil(result.resetAt / 1000))
+ };
+}
+
+export function resetMemoryRateLimits() {
+ memoryStore.clear();
+}
diff --git a/lib/runtime.ts b/lib/runtime.ts
index de75d7d..06028c3 100644
--- a/lib/runtime.ts
+++ b/lib/runtime.ts
@@ -27,7 +27,8 @@ export function sessionCookieOptions(env: RuntimeEnv = process.env): CookieOptio
export function getRequiredServerEnv(env: RuntimeEnv = process.env) {
return {
FLEETBASE_HOST: env.FLEETBASE_HOST ?? 'https://api.fleetbase.io',
- STOREFRONT_KEY: env.STOREFRONT_KEY
+ STOREFRONT_KEY: env.STOREFRONT_KEY,
+ NEXT_PUBLIC_SITE_URL: env.NEXT_PUBLIC_SITE_URL
};
}
@@ -37,9 +38,22 @@ export function validateServerEnv(env: RuntimeEnv = process.env) {
.filter(([, value]) => !value)
.map(([key]) => key);
+ const invalid: string[] = [];
+ for (const key of ['FLEETBASE_HOST', 'NEXT_PUBLIC_SITE_URL'] as const) {
+ const value = values[key];
+ if (!value) continue;
+ try {
+ const url = new URL(value);
+ if (!['http:', 'https:'].includes(url.protocol) || (isProduction(env) && url.protocol !== 'https:')) invalid.push(key);
+ } catch {
+ invalid.push(key);
+ }
+ }
+
return {
- ok: missing.length === 0,
+ ok: missing.length === 0 && invalid.length === 0,
missing,
+ invalid,
values
};
}
diff --git a/lib/types.ts b/lib/types.ts
index 713884f..12e4462 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -129,11 +129,35 @@ export type CustomerPlace = {
meta?: Record;
};
+export type StoreLocation = {
+ id: string;
+ store?: string;
+ name: string;
+ place?: CustomerPlace | null;
+ hours?: Array<{
+ id?: string;
+ day?: string;
+ open?: string;
+ close?: string;
+ closed?: boolean;
+ }>;
+};
+
export type PaymentGateway = {
id: string;
code?: string;
name?: string;
public_key?: string;
+ type?: string;
+ sandbox?: boolean;
+};
+
+export type PreparedCheckout = {
+ checkout: string;
+ token: string;
+ paymentIntent?: string;
+ clientSecret?: string;
+ customerId?: string;
};
export type ServiceQuote = {
diff --git a/middleware.ts b/middleware.ts
index 96c25de..4d71816 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -17,11 +17,12 @@ export function middleware(request: NextRequest) {
'Content-Security-Policy',
[
"default-src 'self'",
- `script-src 'self' ${isDev ? "'unsafe-eval'" : ''} 'unsafe-inline'`,
+ `script-src 'self' https://js.stripe.com ${isDev ? "'unsafe-eval'" : ''} 'unsafe-inline'`,
"style-src 'self' 'unsafe-inline'",
`img-src 'self' data: blob: https: ${imageHosts}`,
- `connect-src 'self' ${process.env.FLEETBASE_HOST ?? 'https://api.fleetbase.io'}`,
+ `connect-src 'self' ${process.env.FLEETBASE_HOST || 'https://api.fleetbase.io'} https://api.stripe.com https://*.stripe.com`,
"font-src 'self' data:",
+ "frame-src https://js.stripe.com https://hooks.stripe.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
@@ -32,6 +33,10 @@ export function middleware(request: NextRequest) {
response.headers.set('Cache-Control', 'no-store');
}
+ if (/^\/(?:account|api|cart|checkout|login|orders|register)(?:\/|$)/.test(request.nextUrl.pathname)) {
+ response.headers.set('X-Robots-Tag', 'noindex, nofollow');
+ }
+
return response;
}
diff --git a/next.config.mjs b/next.config.mjs
index dce7de0..7503ad4 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,11 +1,14 @@
/** @type {import('next').NextConfig} */
-function imageHosts() {
- const hosts = new Set(
+export function imageHosts() {
+ const hosts = new Set([
+ 'flb-assets.s3.ap-southeast-1.amazonaws.com',
+ ...(
(process.env.NEXT_PUBLIC_IMAGE_HOSTS ?? '')
.split(',')
.map((host) => host.trim())
.filter(Boolean)
- );
+ )
+ ]);
try {
hosts.add(new URL(process.env.FLEETBASE_HOST ?? 'https://api.fleetbase.io').hostname);
@@ -27,9 +30,7 @@ function imageHosts() {
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
- eslint: {
- ignoreDuringBuilds: true
- },
+ allowedDevOrigins: ['127.0.0.1'],
images: {
remotePatterns: imageHosts()
}
diff --git a/package.json b/package.json
index 9bfc9f5..2912789 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"name": "storefront-web",
"version": "0.1.0",
+ "packageManager": "pnpm@10.15.1",
"description": "Open-source self-hostable storefront frontend powered by Fleetbase Storefront API.",
"license": "AGPL-3.0-only",
"homepage": "https://github.com/fleetbase/storefront-web#readme",
@@ -28,29 +29,55 @@
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
- "verify": "npm run typecheck && npm run lint && npm run test && npm run build"
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "coverage:check": "vitest run --coverage",
+ "coverage:uncovered": "node scripts/coverage-uncovered.mjs",
+ "audit": "pnpm audit --audit-level high",
+ "test:e2e": "playwright test",
+ "verify": "pnpm lint && pnpm typecheck && pnpm coverage:check && pnpm build && pnpm test:e2e"
},
"dependencies": {
"@fleetbase/storefront": "^1.1.14",
- "@stripe/stripe-js": "^7.0.0",
+ "@stripe/react-stripe-js": "^6.8.2",
+ "@stripe/stripe-js": "^9.14.0",
"lucide-react": "^0.468.0",
- "next": "^15.3.0",
+ "next": "^15.5.24",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "zod": "^4.5.4"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
+ "@eslint/eslintrc": "^3.3.6",
+ "@eslint/js": "^9.39.4",
+ "@next/eslint-plugin-next": "^15.5.24",
+ "@playwright/test": "^1.62.1",
"@testing-library/react": "^16.1.0",
- "@next/eslint-plugin-next": "^15.3.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
- "@typescript-eslint/parser": "^8.59.4",
+ "@typescript-eslint/parser": "^8.68.0",
+ "@vitest/coverage-v8": "3.2.7",
"eslint": "^9.17.0",
- "eslint-config-next": "^15.3.0",
+ "eslint-config-next": "^15.5.24",
+ "jsdom": "^30.0.1",
"typescript": "^5.8.0",
- "vitest": "^2.1.8"
+ "vitest": "^3.2.7"
},
"engines": {
- "node": ">=20.17.0"
+ "node": ">=22.0.0 <25"
+ },
+ "pnpm": {
+ "overrides": {
+ "axios": ">=1.18.0",
+ "form-data": ">=4.0.6",
+ "sharp": ">=0.35.0",
+ "brace-expansion@<1.1.18": "1.1.18",
+ "brace-expansion@>=3.0.0 <5.0.9": "5.0.9",
+ "js-yaml": ">=4.3.1",
+ "nanoid@<3.3.18": ">=3.3.18",
+ "postcss": ">=8.5.18"
+ }
}
}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..768682b
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ timeout: 90_000,
+ expect: { timeout: 20_000 },
+ fullyParallel: true,
+ forbidOnly: Boolean(process.env.CI),
+ retries: process.env.CI ? 2 : 0,
+ workers: 1,
+ reporter: [['list'], ['html', { open: 'never' }]],
+ use: {
+ baseURL: 'http://127.0.0.1:3000',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure'
+ },
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ { name: 'mobile-chromium', use: { ...devices['Pixel 7'] } }
+ ],
+ webServer: [
+ {
+ command: 'node tests/e2e/mock-fleetbase.mjs',
+ url: 'http://127.0.0.1:4010/health',
+ reuseExistingServer: !process.env.CI,
+ timeout: 20_000
+ },
+ {
+ command: 'pnpm dev',
+ url: 'http://127.0.0.1:3000/api/health',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ env: {
+ STOREFRONT_KEY: 'store_e2e_fixture',
+ FLEETBASE_HOST: 'http://127.0.0.1:4010',
+ NEXT_PUBLIC_SITE_URL: 'http://127.0.0.1:3000',
+ NEXT_PUBLIC_DEFAULT_LOCALE: 'en',
+ RATE_LIMIT_REST_URL: '',
+ RATE_LIMIT_REST_TOKEN: ''
+ }
+ }
+ ]
+});
diff --git a/plugins/analytics.tsx b/plugins/analytics.tsx
index 620bb59..e36f0a7 100644
--- a/plugins/analytics.tsx
+++ b/plugins/analytics.tsx
@@ -4,6 +4,11 @@ function endpoint() {
return process.env.STOREFRONT_ANALYTICS_WEBHOOK_URL;
}
+function analyticsEvent(order: unknown) {
+ const value = order && typeof order === 'object' ? order as Record : {};
+ return { event: 'order.placed', orderId: value.public_id ?? value.id, status: value.status };
+}
+
export const analyticsPlugin: StorefrontPlugin = {
id: 'analytics',
name: 'Analytics Hooks',
@@ -12,15 +17,17 @@ export const analyticsPlugin: StorefrontPlugin = {
const webhookUrl = endpoint();
if (webhookUrl) {
- await fetch(webhookUrl, {
+ const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ event: 'order.placed', order })
+ body: JSON.stringify(analyticsEvent(order)),
+ signal: AbortSignal.timeout(3_000)
});
+ if (!response.ok) throw new Error(`Analytics webhook returned ${response.status}.`);
return;
}
- console.info('[storefront-web] order placed', order);
+ console.info('[storefront-web] order placed', analyticsEvent(order));
}
}
};
diff --git a/plugins/registry.ts b/plugins/registry.ts
index b6e27dc..3f65321 100644
--- a/plugins/registry.ts
+++ b/plugins/registry.ts
@@ -2,6 +2,11 @@ import config from '@/storefront.config';
import type { SlotName, StorefrontPlugin } from './types';
export function getPlugins(): StorefrontPlugin[] {
+ const ids = new Set();
+ for (const plugin of config.plugins) {
+ if (ids.has(plugin.id)) throw new Error(`Duplicate Storefront plugin id: ${plugin.id}`);
+ ids.add(plugin.id);
+ }
return config.plugins;
}
@@ -21,8 +26,39 @@ export async function runBeforeCheckoutCaptureHooks(payload: unknown) {
return finalPayload;
}
+export async function runBeforeFleetbaseRequestHooks(request: Request) {
+ let result = request;
+ for (const plugin of getPlugins()) {
+ if (plugin.serverHooks?.beforeFleetbaseRequest) result = await plugin.serverHooks.beforeFleetbaseRequest(result);
+ }
+ return result;
+}
+
+export async function runAfterFleetbaseResponseHooks(response: Response) {
+ let result = response;
+ for (const plugin of getPlugins()) {
+ if (plugin.serverHooks?.afterFleetbaseResponse) result = await plugin.serverHooks.afterFleetbaseResponse(result);
+ }
+ return result;
+}
+
export async function runAfterOrderPlacedHooks(order: unknown) {
+ const errors: Array<{ pluginId: string; message: string }> = [];
for (const plugin of getPlugins()) {
- await plugin.serverHooks?.afterOrderPlaced?.(order);
+ if (!plugin.serverHooks?.afterOrderPlaced) {
+ continue;
+ }
+
+ try {
+ await Promise.race([
+ plugin.serverHooks.afterOrderPlaced(order),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Hook timed out.')), 3_000))
+ ]);
+ } catch (error) {
+ errors.push({ pluginId: plugin.id, message: error instanceof Error ? error.message : 'Hook failed.' });
+ console.error(`[storefront-web] afterOrderPlaced hook failed: ${plugin.id}`);
+ }
}
+
+ return errors;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 853678d..aa9bf7c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,16 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+overrides:
+ axios: '>=1.18.0'
+ form-data: '>=4.0.6'
+ sharp: '>=0.35.0'
+ brace-expansion@<1.1.18: 1.1.18
+ brace-expansion@>=3.0.0 <5.0.9: 5.0.9
+ js-yaml: '>=4.3.1'
+ nanoid@<3.3.18: '>=3.3.18'
+ postcss: '>=8.5.18'
+
importers:
.:
@@ -11,25 +21,43 @@ importers:
'@fleetbase/storefront':
specifier: ^1.1.14
version: 1.1.14
+ '@stripe/react-stripe-js':
+ specifier: ^6.8.2
+ version: 6.8.2(@stripe/stripe-js@9.14.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@stripe/stripe-js':
- specifier: ^7.0.0
- version: 7.9.0
+ specifier: ^9.14.0
+ version: 9.14.0
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.6)
next:
- specifier: ^15.3.0
- version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ specifier: ^15.5.24
+ version: 15.5.24(@playwright/test@1.62.1)(@types/node@22.19.19)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react:
specifier: ^19.0.0
version: 19.2.6
react-dom:
specifier: ^19.0.0
version: 19.2.6(react@19.2.6)
+ zod:
+ specifier: ^4.5.4
+ version: 4.5.4
devDependencies:
+ '@axe-core/playwright':
+ specifier: ^4.13.0
+ version: 4.13.0(playwright-core@1.62.1)
+ '@eslint/eslintrc':
+ specifier: ^3.3.6
+ version: 3.3.6
+ '@eslint/js':
+ specifier: ^9.39.4
+ version: 9.39.4
'@next/eslint-plugin-next':
- specifier: ^15.3.0
- version: 15.5.18
+ specifier: ^15.5.24
+ version: 15.5.24
+ '@playwright/test':
+ specifier: ^1.62.1
+ version: 1.62.1
'@testing-library/react':
specifier: ^16.1.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -43,182 +71,293 @@ importers:
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.15)
'@typescript-eslint/parser':
- specifier: ^8.59.4
- version: 8.59.4(eslint@9.39.4)(typescript@5.9.3)
+ specifier: ^8.68.0
+ version: 8.68.0(eslint@9.39.4)(typescript@5.9.3)
+ '@vitest/coverage-v8':
+ specifier: 3.2.7
+ version: 3.2.7(vitest@3.2.7(@types/node@22.19.19)(jsdom@30.0.1))
eslint:
specifier: ^9.17.0
version: 9.39.4
eslint-config-next:
- specifier: ^15.3.0
- version: 15.5.18(eslint@9.39.4)(typescript@5.9.3)
+ specifier: ^15.5.24
+ version: 15.5.24(eslint@9.39.4)(typescript@5.9.3)
+ jsdom:
+ specifier: ^30.0.1
+ version: 30.0.1
typescript:
specifier: ^5.8.0
version: 5.9.3
vitest:
- specifier: ^2.1.8
- version: 2.1.9(@types/node@22.19.19)
+ specifier: ^3.2.7
+ version: 3.2.7(@types/node@22.19.19)(jsdom@30.0.1)
packages:
- '@babel/code-frame@7.29.0':
- resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
+ '@ampproject/remapping@2.3.0':
+ resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
+ engines: {node: '>=6.0.0'}
+
+ '@asamuzakjp/css-color@6.0.7':
+ resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
+ engines: {node: ^22.13.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@8.3.2':
+ resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
+ engines: {node: ^22.13.0 || >=24.0.0}
+
+ '@axe-core/playwright@4.13.0':
+ resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
+ peerDependencies:
+ playwright-core: '>= 1.0.0'
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-validator-identifier@7.28.5':
- resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
+ '@babel/parser@7.29.8':
+ resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/color-helpers@6.1.1':
+ resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.3.0':
+ resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.2.1':
+ resolution: {integrity: sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.9':
+ resolution: {integrity: sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
- '@esbuild/aix-ppc64@0.21.5':
- resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
- engines: {node: '>=12'}
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
- '@esbuild/android-arm64@0.21.5':
- resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
- engines: {node: '>=12'}
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
cpu: [arm64]
os: [android]
- '@esbuild/android-arm@0.21.5':
- resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
- engines: {node: '>=12'}
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
cpu: [arm]
os: [android]
- '@esbuild/android-x64@0.21.5':
- resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
- engines: {node: '>=12'}
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
cpu: [x64]
os: [android]
- '@esbuild/darwin-arm64@0.21.5':
- resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
- engines: {node: '>=12'}
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
- '@esbuild/darwin-x64@0.21.5':
- resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
- engines: {node: '>=12'}
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
- '@esbuild/freebsd-arm64@0.21.5':
- resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
- engines: {node: '>=12'}
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
- '@esbuild/freebsd-x64@0.21.5':
- resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
- engines: {node: '>=12'}
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
- '@esbuild/linux-arm64@0.21.5':
- resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
- engines: {node: '>=12'}
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
- '@esbuild/linux-arm@0.21.5':
- resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
- engines: {node: '>=12'}
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
cpu: [arm]
os: [linux]
- '@esbuild/linux-ia32@0.21.5':
- resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
- engines: {node: '>=12'}
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
- '@esbuild/linux-loong64@0.21.5':
- resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
- engines: {node: '>=12'}
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
- '@esbuild/linux-mips64el@0.21.5':
- resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
- engines: {node: '>=12'}
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
- '@esbuild/linux-ppc64@0.21.5':
- resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
- engines: {node: '>=12'}
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
- '@esbuild/linux-riscv64@0.21.5':
- resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
- engines: {node: '>=12'}
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
- '@esbuild/linux-s390x@0.21.5':
- resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
- engines: {node: '>=12'}
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
- '@esbuild/linux-x64@0.21.5':
- resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
- engines: {node: '>=12'}
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
cpu: [x64]
os: [linux]
- '@esbuild/netbsd-x64@0.21.5':
- resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
- engines: {node: '>=12'}
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
- '@esbuild/openbsd-x64@0.21.5':
- resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
- engines: {node: '>=12'}
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
- '@esbuild/sunos-x64@0.21.5':
- resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
- engines: {node: '>=12'}
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
- '@esbuild/win32-arm64@0.21.5':
- resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
- engines: {node: '>=12'}
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
- '@esbuild/win32-ia32@0.21.5':
- resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
- engines: {node: '>=12'}
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
- '@esbuild/win32-x64@0.21.5':
- resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
- engines: {node: '>=12'}
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
cpu: [x64]
os: [win32]
+ '@eslint-community/eslint-utils@4.10.1':
+ resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -241,8 +380,8 @@ packages:
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/eslintrc@3.3.5':
- resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
+ '@eslint/eslintrc@3.3.6':
+ resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.4':
@@ -257,6 +396,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@exodus/bytes@1.15.1':
+ resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@fleetbase/sdk@1.2.13':
resolution: {integrity: sha512-PeOKCBMp5FEQsf+ms9NlXAAZsRI7MJikBDfN3s6iLcy85RsqIJTQH+uam1Fldj6SuUvOPhdcRkkdoDvgt4ZMPQ==}
@@ -287,218 +435,232 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.34.5':
- resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-arm64@0.35.4':
+ resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.34.5':
- resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-x64@0.35.4':
+ resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-darwin-arm64@1.2.4':
- resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ '@img/sharp-freebsd-wasm32@0.35.4':
+ resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
+ engines: {node: '>=20.9.0'}
+ os: [freebsd]
+
+ '@img/sharp-libvips-darwin-arm64@1.3.3':
+ resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.2.4':
- resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ '@img/sharp-libvips-darwin-x64@1.3.3':
+ resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.2.4':
- resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ '@img/sharp-libvips-linux-arm64@1.3.3':
+ resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
- '@img/sharp-libvips-linux-arm@1.2.4':
- resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ '@img/sharp-libvips-linux-arm@1.3.3':
+ resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
cpu: [arm]
os: [linux]
- libc: [glibc]
- '@img/sharp-libvips-linux-ppc64@1.2.4':
- resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ '@img/sharp-libvips-linux-ppc64@1.3.3':
+ resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
- '@img/sharp-libvips-linux-riscv64@1.2.4':
- resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ '@img/sharp-libvips-linux-riscv64@1.3.3':
+ resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
- '@img/sharp-libvips-linux-s390x@1.2.4':
- resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ '@img/sharp-libvips-linux-s390x@1.3.3':
+ resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
- '@img/sharp-libvips-linux-x64@1.2.4':
- resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ '@img/sharp-libvips-linux-x64@1.3.3':
+ resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
cpu: [x64]
os: [linux]
- libc: [glibc]
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
- resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
+ resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
cpu: [arm64]
os: [linux]
- libc: [musl]
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
- resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ '@img/sharp-libvips-linuxmusl-x64@1.3.3':
+ resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
cpu: [x64]
os: [linux]
- libc: [musl]
- '@img/sharp-linux-arm64@0.34.5':
- resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm64@0.35.4':
+ resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
- '@img/sharp-linux-arm@0.34.5':
- resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm@0.35.4':
+ resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
+ engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
- libc: [glibc]
- '@img/sharp-linux-ppc64@0.34.5':
- resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-ppc64@0.35.4':
+ resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
+ engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
- '@img/sharp-linux-riscv64@0.34.5':
- resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-riscv64@0.35.4':
+ resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
+ engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
- '@img/sharp-linux-s390x@0.34.5':
- resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-s390x@0.35.4':
+ resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
+ engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
- libc: [glibc]
- '@img/sharp-linux-x64@0.34.5':
- resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-x64@0.35.4':
+ resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
- '@img/sharp-linuxmusl-arm64@0.34.5':
- resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-arm64@0.35.4':
+ resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
- '@img/sharp-linuxmusl-x64@0.34.5':
- resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-x64@0.35.4':
+ resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
- '@img/sharp-wasm32@0.34.5':
- resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-wasm32@0.35.4':
+ resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
+ engines: {node: '>=20.9.0'}
+
+ '@img/sharp-webcontainers-wasm32@0.35.4':
+ resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
+ engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.34.5':
- resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-arm64@0.35.4':
+ resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.34.5':
- resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-ia32@0.35.4':
+ resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
+ engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.34.5':
- resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-x64@0.35.4':
+ resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
- '@jridgewell/sourcemap-codec@1.5.5':
- resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@isaacs/cliui@8.0.2':
+ resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
+ engines: {node: '>=12'}
+
+ '@istanbuljs/schema@0.1.6':
+ resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==}
+ engines: {node: '>=8'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
- '@napi-rs/wasm-runtime@1.1.4':
- resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+ engines: {node: ^22.20 || ^24.12 || >=25}
+ cpu: [x64]
+ os: [linux]
+
+ '@napi-rs/wasm-runtime@1.2.3':
+ resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
peerDependencies:
- '@emnapi/core': ^1.7.1
- '@emnapi/runtime': ^1.7.1
+ '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
+ '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
- '@next/env@15.5.18':
- resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==}
+ '@next/env@15.5.24':
+ resolution: {integrity: sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==}
- '@next/eslint-plugin-next@15.5.18':
- resolution: {integrity: sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==}
+ '@next/eslint-plugin-next@15.5.24':
+ resolution: {integrity: sha512-K6jcYhwMY73qpeJ5SP+rrXs7Fuvv94HEvOCEen/zjwG7aq83utZY8n+8YdKHZR1lv0n5cB4z9C6QqwpE9acN+A==}
- '@next/swc-darwin-arm64@15.5.18':
- resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==}
+ '@next/swc-darwin-arm64@15.5.24':
+ resolution: {integrity: sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@15.5.18':
- resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==}
+ '@next/swc-darwin-x64@15.5.24':
+ resolution: {integrity: sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@15.5.18':
- resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==}
+ '@next/swc-linux-arm64-gnu@15.5.24':
+ resolution: {integrity: sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
- '@next/swc-linux-arm64-musl@15.5.18':
- resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==}
+ '@next/swc-linux-arm64-musl@15.5.24':
+ resolution: {integrity: sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
- '@next/swc-linux-x64-gnu@15.5.18':
- resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==}
+ '@next/swc-linux-x64-gnu@15.5.24':
+ resolution: {integrity: sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
- '@next/swc-linux-x64-musl@15.5.18':
- resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==}
+ '@next/swc-linux-x64-musl@15.5.24':
+ resolution: {integrity: sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
- '@next/swc-win32-arm64-msvc@15.5.18':
- resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==}
+ '@next/swc-win32-arm64-msvc@15.5.24':
+ resolution: {integrity: sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@15.5.18':
- resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==}
+ '@next/swc-win32-x64-msvc@15.5.24':
+ resolution: {integrity: sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -519,141 +681,137 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
- '@rollup/rollup-android-arm-eabi@4.60.4':
- resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
+ '@pkgjs/parseargs@0.11.0':
+ resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
+ engines: {node: '>=14'}
+
+ '@playwright/test@1.62.1':
+ resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ '@rollup/rollup-android-arm-eabi@4.63.1':
+ resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==}
cpu: [arm]
os: [android]
- '@rollup/rollup-android-arm64@4.60.4':
- resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==}
+ '@rollup/rollup-android-arm64@4.63.1':
+ resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.60.4':
- resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==}
+ '@rollup/rollup-darwin-arm64@4.63.1':
+ resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.60.4':
- resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==}
+ '@rollup/rollup-darwin-x64@4.63.1':
+ resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-freebsd-arm64@4.60.4':
- resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==}
+ '@rollup/rollup-freebsd-arm64@4.63.1':
+ resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==}
cpu: [arm64]
os: [freebsd]
- '@rollup/rollup-freebsd-x64@4.60.4':
- resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==}
+ '@rollup/rollup-freebsd-x64@4.63.1':
+ resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==}
cpu: [x64]
os: [freebsd]
- '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
- resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
+ '@rollup/rollup-linux-arm-gnueabihf@4.63.1':
+ resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==}
cpu: [arm]
os: [linux]
- libc: [glibc]
- '@rollup/rollup-linux-arm-musleabihf@4.60.4':
- resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
+ '@rollup/rollup-linux-arm-musleabihf@4.63.1':
+ resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==}
cpu: [arm]
os: [linux]
- libc: [musl]
- '@rollup/rollup-linux-arm64-gnu@4.60.4':
- resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
+ '@rollup/rollup-linux-arm64-gnu@4.63.1':
+ resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
- '@rollup/rollup-linux-arm64-musl@4.60.4':
- resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
+ '@rollup/rollup-linux-arm64-musl@4.63.1':
+ resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==}
cpu: [arm64]
os: [linux]
- libc: [musl]
- '@rollup/rollup-linux-loong64-gnu@4.60.4':
- resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
+ '@rollup/rollup-linux-loong64-gnu@4.63.1':
+ resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
- '@rollup/rollup-linux-loong64-musl@4.60.4':
- resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
+ '@rollup/rollup-linux-loong64-musl@4.63.1':
+ resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==}
cpu: [loong64]
os: [linux]
- libc: [musl]
- '@rollup/rollup-linux-ppc64-gnu@4.60.4':
- resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
+ '@rollup/rollup-linux-ppc64-gnu@4.63.1':
+ resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
- '@rollup/rollup-linux-ppc64-musl@4.60.4':
- resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
+ '@rollup/rollup-linux-ppc64-musl@4.63.1':
+ resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
- '@rollup/rollup-linux-riscv64-gnu@4.60.4':
- resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
+ '@rollup/rollup-linux-riscv64-gnu@4.63.1':
+ resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
- '@rollup/rollup-linux-riscv64-musl@4.60.4':
- resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
+ '@rollup/rollup-linux-riscv64-musl@4.63.1':
+ resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
- '@rollup/rollup-linux-s390x-gnu@4.60.4':
- resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
+ '@rollup/rollup-linux-s390x-gnu@4.63.1':
+ resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
- '@rollup/rollup-linux-x64-gnu@4.60.4':
- resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
+ '@rollup/rollup-linux-x64-gnu@4.63.1':
+ resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==}
cpu: [x64]
os: [linux]
- libc: [glibc]
- '@rollup/rollup-linux-x64-musl@4.60.4':
- resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
+ '@rollup/rollup-linux-x64-musl@4.63.1':
+ resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==}
cpu: [x64]
os: [linux]
- libc: [musl]
- '@rollup/rollup-openbsd-x64@4.60.4':
- resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
+ '@rollup/rollup-openbsd-x64@4.63.1':
+ resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==}
cpu: [x64]
os: [openbsd]
- '@rollup/rollup-openharmony-arm64@4.60.4':
- resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==}
+ '@rollup/rollup-openharmony-arm64@4.63.1':
+ resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==}
cpu: [arm64]
os: [openharmony]
- '@rollup/rollup-win32-arm64-msvc@4.60.4':
- resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==}
+ '@rollup/rollup-win32-arm64-msvc@4.63.1':
+ resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.60.4':
- resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==}
+ '@rollup/rollup-win32-ia32-msvc@4.63.1':
+ resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==}
cpu: [ia32]
os: [win32]
- '@rollup/rollup-win32-x64-gnu@4.60.4':
- resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==}
+ '@rollup/rollup-win32-x64-gnu@4.63.1':
+ resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==}
cpu: [x64]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.60.4':
- resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==}
+ '@rollup/rollup-win32-x64-msvc@4.63.1':
+ resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==}
cpu: [x64]
os: [win32]
@@ -663,8 +821,15 @@ packages:
'@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
- '@stripe/stripe-js@7.9.0':
- resolution: {integrity: sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==}
+ '@stripe/react-stripe-js@6.8.2':
+ resolution: {integrity: sha512-6qxosv6c+LqtdML2R7nCDpFNowuh4ohIM894V9eRkbFJANUKbMKoXBd8AKOQAn1wjEfuYVI2NYtFnqFLOKbPjw==}
+ peerDependencies:
+ '@stripe/stripe-js': '>=9.5.0 <10.0.0'
+ react: '>=16.8.0 <20.0.0'
+ react-dom: '>=16.8.0 <20.0.0'
+
+ '@stripe/stripe-js@9.14.0':
+ resolution: {integrity: sha512-eCeVT2ee5YWyzz+m/cGwuatcPobe0MxzUGJtat8vUuZo4vNg1bzhoImKaTG2uB1ytIO03MrYAP4d6PTiGl+IZQ==}
engines: {node: '>=12.16'}
'@swc/helpers@0.5.15':
@@ -689,14 +854,17 @@ packages:
'@types/react-dom':
optional: true
- '@tybys/wasm-util@0.10.2':
- resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
- '@types/estree@1.0.8':
- resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -718,63 +886,63 @@ packages:
'@types/react@19.2.15':
resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==}
- '@typescript-eslint/eslint-plugin@8.59.4':
- resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==}
+ '@typescript-eslint/eslint-plugin@8.68.0':
+ resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.59.4
+ '@typescript-eslint/parser': ^8.68.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.59.4':
- resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==}
+ '@typescript-eslint/parser@8.68.0':
+ resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.59.4':
- resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==}
+ '@typescript-eslint/project-service@8.68.0':
+ resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.59.4':
- resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==}
+ '@typescript-eslint/scope-manager@8.68.0':
+ resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.59.4':
- resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==}
+ '@typescript-eslint/tsconfig-utils@8.68.0':
+ resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.59.4':
- resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==}
+ '@typescript-eslint/type-utils@8.68.0':
+ resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.59.4':
- resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==}
+ '@typescript-eslint/types@8.68.0':
+ resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.59.4':
- resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==}
+ '@typescript-eslint/typescript-estree@8.68.0':
+ resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/utils@8.59.4':
- resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==}
+ '@typescript-eslint/utils@8.68.0':
+ resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.59.4':
- resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==}
+ '@typescript-eslint/visitor-keys@8.68.0':
+ resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -816,61 +984,51 @@ packages:
resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.12.2':
resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-loong64-musl@1.12.2':
resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.12.2':
resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.12.2':
resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-openharmony-arm64@1.12.2':
resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
@@ -897,34 +1055,43 @@ packages:
cpu: [x64]
os: [win32]
- '@vitest/expect@2.1.9':
- resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
+ '@vitest/coverage-v8@3.2.7':
+ resolution: {integrity: sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==}
+ peerDependencies:
+ '@vitest/browser': 3.2.7
+ vitest: 3.2.7
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
+ '@vitest/expect@3.2.7':
+ resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
- '@vitest/mocker@2.1.9':
- resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
+ '@vitest/mocker@3.2.7':
+ resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
peerDependencies:
msw: ^2.4.9
- vite: ^5.0.0
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
- '@vitest/pretty-format@2.1.9':
- resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
+ '@vitest/pretty-format@3.2.7':
+ resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
- '@vitest/runner@2.1.9':
- resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
+ '@vitest/runner@3.2.7':
+ resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
- '@vitest/snapshot@2.1.9':
- resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
+ '@vitest/snapshot@3.2.7':
+ resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
- '@vitest/spy@2.1.9':
- resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
+ '@vitest/spy@3.2.7':
+ resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
- '@vitest/utils@2.1.9':
- resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
+ '@vitest/utils@3.2.7':
+ resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@@ -947,6 +1114,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
+ ansi-regex@6.3.0:
+ resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
+ engines: {node: '>=12'}
+
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -955,6 +1126,10 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
+ ansi-styles@6.2.3:
+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+ engines: {node: '>=12'}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1004,6 +1179,9 @@ packages:
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+ ast-v8-to-istanbul@0.3.12:
+ resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
+
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -1015,12 +1193,12 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
- axe-core@4.11.4:
- resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
+ axe-core@4.13.0:
+ resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
- axios@1.16.1:
- resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
+ axios@1.20.0:
+ resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
@@ -1033,12 +1211,18 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- brace-expansion@1.1.14:
- resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
- brace-expansion@5.0.6:
- resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
- engines: {node: 18 || 20 || >=22}
+ brace-expansion@1.1.18:
+ resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
+
+ brace-expansion@2.1.4:
+ resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
+
+ brace-expansion@5.0.9:
+ resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
+ engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -1064,8 +1248,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001793:
- resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
+ caniuse-lite@1.0.30001810:
+ resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
@@ -1103,12 +1287,20 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -1141,6 +1333,9 @@ packages:
supports-color:
optional: true
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -1179,9 +1374,23 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ eastasianwidth@0.2.0:
+ resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
+ es-abstract-get@1.0.0:
+ resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
+ engines: {node: '>= 0.4'}
+
es-abstract@1.24.2:
resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
engines: {node: '>= 0.4'}
@@ -1194,15 +1403,15 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
- es-iterator-helpers@1.3.2:
- resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==}
+ es-iterator-helpers@1.4.0:
+ resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
- es-object-atoms@1.1.1:
- resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
@@ -1213,21 +1422,21 @@ packages:
resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
engines: {node: '>= 0.4'}
- es-to-primitive@1.3.0:
- resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
+ es-to-primitive@1.3.4:
+ resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
engines: {node: '>= 0.4'}
- esbuild@0.21.5:
- resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
- engines: {node: '>=12'}
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
hasBin: true
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
- eslint-config-next@15.5.18:
- resolution: {integrity: sha512-HuoJU6uUPD00eyiud78IBnT4HLhztFj2V+ild2Uon5ZUrYZKe0Olu2QRD99e9IgL4/H1eg5Onka3BsfRW2U0Xw==}
+ eslint-config-next@15.5.24:
+ resolution: {integrity: sha512-RXne8azyj9DKV9N15elyRsnh98BtKINYHCG7bmVhpIhaOtaYvrNJ37XwWpjY9Z09f5RHYOf9vn5uU0MgV1iLrw==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
@@ -1251,8 +1460,8 @@ packages:
eslint-plugin-import-x:
optional: true
- eslint-module-utils@2.12.1:
- resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==}
+ eslint-module-utils@2.14.0:
+ resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
@@ -1349,8 +1558,8 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
- expect-type@1.3.0:
- resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
@@ -1366,8 +1575,8 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
- fastq@1.20.1:
- resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+ fastq@1.20.3:
+ resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
@@ -1410,10 +1619,19 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
- form-data@4.0.5:
- resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
+ foreground-child@3.3.1:
+ resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
+ engines: {node: '>=14'}
+
+ form-data@4.0.6:
+ resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1422,8 +1640,8 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
- function.prototype.name@1.1.8:
- resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==}
+ function.prototype.name@1.2.0:
+ resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
engines: {node: '>= 0.4'}
functions-have-names@1.2.3:
@@ -1445,8 +1663,8 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
- get-tsconfig@4.14.0:
- resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
+ get-tsconfig@4.14.3:
+ resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
@@ -1456,6 +1674,11 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
+ glob@10.5.0:
+ resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ hasBin: true
+
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@@ -1491,10 +1714,17 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
- hasown@2.0.3:
- resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
@@ -1503,8 +1733,8 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
- ignore@7.0.5:
- resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ ignore@7.0.6:
+ resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
engines: {node: '>= 4'}
import-fresh@3.3.1:
@@ -1554,6 +1784,10 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
+ is-document.all@1.0.0:
+ resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
+ engines: {node: '>= 0.4'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1562,6 +1796,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
@@ -1586,6 +1824,9 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -1628,17 +1869,51 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-lib-source-maps@5.0.6:
+ resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
+ jackspeak@3.4.3:
+ resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
+
+ js-tokens@10.0.0:
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- js-yaml@4.1.1:
- resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ js-tokens@9.0.1:
+ resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
+
+ js-yaml@5.4.1:
+ resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==}
hasBin: true
+ jsdom@30.0.1:
+ resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+ peerDependencies:
+ canvas: ^3.2.3
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -1684,6 +1959,13 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+ lru-cache@10.4.3:
+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
+ lru-cache@11.5.2:
+ resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+ engines: {node: 20 || >=22}
+
lucide-react@0.468.0:
resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
peerDependencies:
@@ -1696,10 +1978,20 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ magicast@0.3.5:
+ resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1716,22 +2008,30 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
- minimatch@10.2.5:
- resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ minimatch@10.2.6:
+ resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ minipass@7.1.3:
+ resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.12:
- resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ nanoid@6.0.1:
+ resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==}
+ engines: {node: ^22 || ^24 || >=26}
hasBin: true
napi-postinstall@0.3.4:
@@ -1742,8 +2042,8 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
- next@15.5.18:
- resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==}
+ next@15.5.24:
+ resolution: {integrity: sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@@ -1763,8 +2063,8 @@ packages:
sass:
optional: true
- node-exports-info@1.6.0:
- resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
+ node-exports-info@1.6.2:
+ resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
engines: {node: '>= 0.4'}
object-assign@4.1.1:
@@ -1803,8 +2103,8 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
- own-keys@1.0.1:
- resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
+ own-keys@1.0.2:
+ resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
engines: {node: '>= 0.4'}
p-limit@3.1.0:
@@ -1815,10 +2115,16 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ package-json-from-dist@1.0.1:
+ resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -1830,8 +2136,12 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
- pathe@1.1.2:
- resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
+ path-scurry@1.11.1:
+ resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
+ engines: {node: '>=16 || 14 >=14.18'}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
@@ -1844,20 +2154,26 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
- picomatch@4.0.4:
- resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
+ playwright-core@1.62.1:
+ resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.62.1:
+ resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
- postcss@8.4.31:
- resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
- engines: {node: ^10 || ^12 || >=14}
-
- postcss@8.5.15:
- resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ postcss@8.5.26:
+ resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -1905,6 +2221,10 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1921,8 +2241,8 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
- rollup@4.60.4:
- resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
+ rollup@4.63.1:
+ resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -1941,6 +2261,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1948,8 +2272,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- semver@7.8.0:
- resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
@@ -1965,9 +2289,14 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
- sharp@0.34.5:
- resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ sharp@0.35.4:
+ resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
+ engines: {node: '>=20.9.0'}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -1989,13 +2318,17 @@ packages:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
- side-channel@1.1.0:
- resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+ signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2013,29 +2346,45 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
+ string-width@5.1.2:
+ resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
+ engines: {node: '>=12'}
+
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
- string.prototype.matchall@4.0.12:
- resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
+ string.prototype.matchall@4.1.0:
+ resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==}
engines: {node: '>= 0.4'}
string.prototype.repeat@1.0.0:
resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
- string.prototype.trim@1.2.10:
- resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
+ string.prototype.trim@1.2.11:
+ resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
engines: {node: '>= 0.4'}
- string.prototype.trimend@1.0.9:
- resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==}
+ string.prototype.trimend@1.0.10:
+ resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
engines: {node: '>= 0.4'}
string.prototype.trimstart@1.0.8:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
+ strip-ansi@7.2.0:
+ resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+ engines: {node: '>=12'}
+
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2044,6 +2393,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ strip-literal@3.1.0:
+ resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -2065,32 +2417,54 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
+ test-exclude@7.0.2:
+ resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==}
+ engines: {node: '>=18'}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
- tinyglobby@0.2.16:
- resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
- tinyrainbow@1.2.0:
- resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
+ tinyrainbow@2.0.0:
+ resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
- tinyspy@3.0.2:
- resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
+ tinyspy@4.0.4:
+ resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
+ tldts-core@7.4.11:
+ resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==}
+
+ tldts@7.4.11:
+ resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==}
+ hasBin: true
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ tough-cookie@6.0.2:
+ resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -2119,8 +2493,8 @@ packages:
resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
engines: {node: '>= 0.4'}
- typed-array-length@1.0.7:
- resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
+ typed-array-length@1.0.8:
+ resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
engines: {node: '>= 0.4'}
typescript@5.9.3:
@@ -2135,33 +2509,42 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici@8.10.0:
+ resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
+ engines: {node: '>=22.19.0'}
+
unrs-resolver@1.12.2:
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
- vite-node@2.1.9:
- resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
- engines: {node: ^18.0.0 || >=20.0.0}
+ vite-node@3.2.4:
+ resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
- vite@5.4.21:
- resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
- engines: {node: ^18.0.0 || >=20.0.0}
+ vite@7.3.6:
+ resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
- '@types/node': ^18.0.0 || >=20.0.0
- less: '*'
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
lightningcss: ^1.21.0
- sass: '*'
- sass-embedded: '*'
- stylus: '*'
- sugarss: '*'
- terser: ^5.4.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
+ jiti:
+ optional: true
less:
optional: true
lightningcss:
@@ -2176,21 +2559,28 @@ packages:
optional: true
terser:
optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
- vitest@2.1.9:
- resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
- engines: {node: ^18.0.0 || >=20.0.0}
+ vitest@3.2.7:
+ resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
- '@types/node': ^18.0.0 || >=20.0.0
- '@vitest/browser': 2.1.9
- '@vitest/ui': 2.1.9
+ '@types/debug': ^4.1.12
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ '@vitest/browser': 3.2.7
+ '@vitest/ui': 3.2.7
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
+ '@types/debug':
+ optional: true
'@types/node':
optional: true
'@vitest/browser':
@@ -2202,6 +2592,26 @@ packages:
jsdom:
optional: true
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ whatwg-url@17.1.0:
+ resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
+ engines: {node: ^22.14.0 || >=24.0.0}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2214,8 +2624,8 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
- which-typed-array@1.1.20:
- resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
+ which-typed-array@1.1.22:
+ resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'}
which@2.0.2:
@@ -2232,22 +2642,108 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
+ wrap-ansi@8.1.0:
+ resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
+ engines: {node: '>=12'}
+
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ zod@4.5.4:
+ resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
+
snapshots:
- '@babel/code-frame@7.29.0':
+ '@ampproject/remapping@2.3.0':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@asamuzakjp/css-color@6.0.7':
+ dependencies:
+ '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ lru-cache: 11.5.2
+
+ '@asamuzakjp/dom-selector@8.3.2':
+ dependencies:
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+
+ '@axe-core/playwright@4.13.0(playwright-core@1.62.1)':
dependencies:
- '@babel/helper-validator-identifier': 7.28.5
+ axe-core: 4.13.0
+ playwright-core: 1.62.1
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
- '@babel/helper-validator-identifier@7.28.5': {}
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/parser@7.29.8':
+ dependencies:
+ '@babel/types': 7.29.8
'@babel/runtime@7.29.2': {}
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@bcoe/v8-coverage@1.0.2': {}
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.1.1': {}
+
+ '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.1.1
+ '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.9(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -2259,80 +2755,99 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
optional: true
- '@esbuild/aix-ppc64@0.21.5':
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
optional: true
- '@esbuild/android-arm64@0.21.5':
+ '@esbuild/darwin-arm64@0.28.2':
optional: true
- '@esbuild/android-arm@0.21.5':
+ '@esbuild/darwin-x64@0.28.2':
optional: true
- '@esbuild/android-x64@0.21.5':
+ '@esbuild/freebsd-arm64@0.28.2':
optional: true
- '@esbuild/darwin-arm64@0.21.5':
+ '@esbuild/freebsd-x64@0.28.2':
optional: true
- '@esbuild/darwin-x64@0.21.5':
+ '@esbuild/linux-arm64@0.28.2':
optional: true
- '@esbuild/freebsd-arm64@0.21.5':
+ '@esbuild/linux-arm@0.28.2':
optional: true
- '@esbuild/freebsd-x64@0.21.5':
+ '@esbuild/linux-ia32@0.28.2':
optional: true
- '@esbuild/linux-arm64@0.21.5':
+ '@esbuild/linux-loong64@0.28.2':
optional: true
- '@esbuild/linux-arm@0.21.5':
+ '@esbuild/linux-mips64el@0.28.2':
optional: true
- '@esbuild/linux-ia32@0.21.5':
+ '@esbuild/linux-ppc64@0.28.2':
optional: true
- '@esbuild/linux-loong64@0.21.5':
+ '@esbuild/linux-riscv64@0.28.2':
optional: true
- '@esbuild/linux-mips64el@0.21.5':
+ '@esbuild/linux-s390x@0.28.2':
optional: true
- '@esbuild/linux-ppc64@0.21.5':
+ '@esbuild/linux-x64@0.28.2':
optional: true
- '@esbuild/linux-riscv64@0.21.5':
+ '@esbuild/netbsd-arm64@0.28.2':
optional: true
- '@esbuild/linux-s390x@0.21.5':
+ '@esbuild/netbsd-x64@0.28.2':
optional: true
- '@esbuild/linux-x64@0.21.5':
+ '@esbuild/openbsd-arm64@0.28.2':
optional: true
- '@esbuild/netbsd-x64@0.21.5':
+ '@esbuild/openbsd-x64@0.28.2':
optional: true
- '@esbuild/openbsd-x64@0.21.5':
+ '@esbuild/openharmony-arm64@0.28.2':
optional: true
- '@esbuild/sunos-x64@0.21.5':
+ '@esbuild/sunos-x64@0.28.2':
optional: true
- '@esbuild/win32-arm64@0.21.5':
+ '@esbuild/win32-arm64@0.28.2':
optional: true
- '@esbuild/win32-ia32@0.21.5':
+ '@esbuild/win32-ia32@0.28.2':
optional: true
- '@esbuild/win32-x64@0.21.5':
+ '@esbuild/win32-x64@0.28.2':
optional: true
+ '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)':
+ dependencies:
+ eslint: 9.39.4
+ eslint-visitor-keys: 3.4.3
+
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
dependencies:
eslint: 9.39.4
@@ -2356,7 +2871,7 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/eslintrc@3.3.5':
+ '@eslint/eslintrc@3.3.6':
dependencies:
ajv: 6.15.0
debug: 4.4.3
@@ -2364,7 +2879,7 @@ snapshots:
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
- js-yaml: 4.1.1
+ js-yaml: 5.4.1
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -2379,10 +2894,12 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
+ '@exodus/bytes@1.15.1': {}
+
'@fleetbase/sdk@1.2.13':
dependencies:
'@babel/runtime': 7.29.2
- axios: 1.16.1
+ axios: 1.20.0
transitivePeerDependencies:
- debug
- supports-color
@@ -2415,137 +2932,173 @@ snapshots:
'@img/colour@1.1.0':
optional: true
- '@img/sharp-darwin-arm64@0.34.5':
+ '@img/sharp-darwin-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.3
optional: true
- '@img/sharp-darwin-x64@0.34.5':
+ '@img/sharp-darwin-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.3.3
+ optional: true
+
+ '@img/sharp-freebsd-wasm32@0.35.4':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.4
optional: true
- '@img/sharp-libvips-darwin-arm64@1.2.4':
+ '@img/sharp-libvips-darwin-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-darwin-x64@1.2.4':
+ '@img/sharp-libvips-darwin-x64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-arm64@1.2.4':
+ '@img/sharp-libvips-linux-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-arm@1.2.4':
+ '@img/sharp-libvips-linux-arm@1.3.3':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.2.4':
+ '@img/sharp-libvips-linux-ppc64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.2.4':
+ '@img/sharp-libvips-linux-riscv64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-s390x@1.2.4':
+ '@img/sharp-libvips-linux-s390x@1.3.3':
optional: true
- '@img/sharp-libvips-linux-x64@1.2.4':
+ '@img/sharp-libvips-linux-x64@1.3.3':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-x64@1.3.3':
optional: true
- '@img/sharp-linux-arm64@0.34.5':
+ '@img/sharp-linux-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.3.3
optional: true
- '@img/sharp-linux-arm@0.34.5':
+ '@img/sharp-linux-arm@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.3.3
optional: true
- '@img/sharp-linux-ppc64@0.34.5':
+ '@img/sharp-linux-ppc64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.3.3
optional: true
- '@img/sharp-linux-riscv64@0.34.5':
+ '@img/sharp-linux-riscv64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.3.3
optional: true
- '@img/sharp-linux-s390x@0.34.5':
+ '@img/sharp-linux-s390x@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.3.3
optional: true
- '@img/sharp-linux-x64@0.34.5':
+ '@img/sharp-linux-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.3.3
optional: true
- '@img/sharp-linuxmusl-arm64@0.34.5':
+ '@img/sharp-linuxmusl-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
optional: true
- '@img/sharp-linuxmusl-x64@0.34.5':
+ '@img/sharp-linuxmusl-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.3
optional: true
- '@img/sharp-wasm32@0.34.5':
+ '@img/sharp-wasm32@0.35.4':
dependencies:
- '@emnapi/runtime': 1.10.0
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-webcontainers-wasm32@0.35.4':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.4
optional: true
- '@img/sharp-win32-arm64@0.34.5':
+ '@img/sharp-win32-arm64@0.35.4':
optional: true
- '@img/sharp-win32-ia32@0.34.5':
+ '@img/sharp-win32-ia32@0.35.4':
optional: true
- '@img/sharp-win32-x64@0.34.5':
+ '@img/sharp-win32-x64@0.35.4':
optional: true
- '@jridgewell/sourcemap-codec@1.5.5': {}
+ '@isaacs/cliui@8.0.2':
+ dependencies:
+ string-width: 5.1.2
+ string-width-cjs: string-width@4.2.3
+ strip-ansi: 7.2.0
+ strip-ansi-cjs: strip-ansi@6.0.1
+ wrap-ansi: 8.1.0
+ wrap-ansi-cjs: wrap-ansi@7.0.0
+
+ '@istanbuljs/schema@0.1.6': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
- '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
- '@tybys/wasm-util': 0.10.2
+ '@tybys/wasm-util': 0.10.3
optional: true
- '@next/env@15.5.18': {}
+ '@next/env@15.5.24': {}
- '@next/eslint-plugin-next@15.5.18':
+ '@next/eslint-plugin-next@15.5.24':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@15.5.18':
+ '@next/swc-darwin-arm64@15.5.24':
optional: true
- '@next/swc-darwin-x64@15.5.18':
+ '@next/swc-darwin-x64@15.5.24':
optional: true
- '@next/swc-linux-arm64-gnu@15.5.18':
+ '@next/swc-linux-arm64-gnu@15.5.24':
optional: true
- '@next/swc-linux-arm64-musl@15.5.18':
+ '@next/swc-linux-arm64-musl@15.5.24':
optional: true
- '@next/swc-linux-x64-gnu@15.5.18':
+ '@next/swc-linux-x64-gnu@15.5.24':
optional: true
- '@next/swc-linux-x64-musl@15.5.18':
+ '@next/swc-linux-x64-musl@15.5.24':
optional: true
- '@next/swc-win32-arm64-msvc@15.5.18':
+ '@next/swc-win32-arm64-msvc@15.5.24':
optional: true
- '@next/swc-win32-x64-msvc@15.5.18':
+ '@next/swc-win32-x64-msvc@15.5.24':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -2558,90 +3111,104 @@ snapshots:
'@nodelib/fs.walk@1.2.8':
dependencies:
'@nodelib/fs.scandir': 2.1.5
- fastq: 1.20.1
+ fastq: 1.20.3
'@nolyfill/is-core-module@1.0.39': {}
- '@rollup/rollup-android-arm-eabi@4.60.4':
+ '@pkgjs/parseargs@0.11.0':
+ optional: true
+
+ '@playwright/test@1.62.1':
+ dependencies:
+ playwright: 1.62.1
+
+ '@rollup/rollup-android-arm-eabi@4.63.1':
optional: true
- '@rollup/rollup-android-arm64@4.60.4':
+ '@rollup/rollup-android-arm64@4.63.1':
optional: true
- '@rollup/rollup-darwin-arm64@4.60.4':
+ '@rollup/rollup-darwin-arm64@4.63.1':
optional: true
- '@rollup/rollup-darwin-x64@4.60.4':
+ '@rollup/rollup-darwin-x64@4.63.1':
optional: true
- '@rollup/rollup-freebsd-arm64@4.60.4':
+ '@rollup/rollup-freebsd-arm64@4.63.1':
optional: true
- '@rollup/rollup-freebsd-x64@4.60.4':
+ '@rollup/rollup-freebsd-x64@4.63.1':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ '@rollup/rollup-linux-arm-gnueabihf@4.63.1':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ '@rollup/rollup-linux-arm-musleabihf@4.63.1':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ '@rollup/rollup-linux-arm64-gnu@4.63.1':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.60.4':
+ '@rollup/rollup-linux-arm64-musl@4.63.1':
optional: true
- '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ '@rollup/rollup-linux-loong64-gnu@4.63.1':
optional: true
- '@rollup/rollup-linux-loong64-musl@4.60.4':
+ '@rollup/rollup-linux-loong64-musl@4.63.1':
optional: true
- '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ '@rollup/rollup-linux-ppc64-gnu@4.63.1':
optional: true
- '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ '@rollup/rollup-linux-ppc64-musl@4.63.1':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ '@rollup/rollup-linux-riscv64-gnu@4.63.1':
optional: true
- '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ '@rollup/rollup-linux-riscv64-musl@4.63.1':
optional: true
- '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ '@rollup/rollup-linux-s390x-gnu@4.63.1':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.60.4':
+ '@rollup/rollup-linux-x64-gnu@4.63.1':
optional: true
- '@rollup/rollup-linux-x64-musl@4.60.4':
+ '@rollup/rollup-linux-x64-musl@4.63.1':
optional: true
- '@rollup/rollup-openbsd-x64@4.60.4':
+ '@rollup/rollup-openbsd-x64@4.63.1':
optional: true
- '@rollup/rollup-openharmony-arm64@4.60.4':
+ '@rollup/rollup-openharmony-arm64@4.63.1':
optional: true
- '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ '@rollup/rollup-win32-arm64-msvc@4.63.1':
optional: true
- '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ '@rollup/rollup-win32-ia32-msvc@4.63.1':
optional: true
- '@rollup/rollup-win32-x64-gnu@4.60.4':
+ '@rollup/rollup-win32-x64-gnu@4.63.1':
optional: true
- '@rollup/rollup-win32-x64-msvc@4.60.4':
+ '@rollup/rollup-win32-x64-msvc@4.63.1':
optional: true
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.16.1': {}
- '@stripe/stripe-js@7.9.0': {}
+ '@stripe/react-stripe-js@6.8.2(@stripe/stripe-js@9.14.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@stripe/stripe-js': 9.14.0
+ prop-types: 15.8.1
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
+ '@stripe/stripe-js@9.14.0': {}
'@swc/helpers@0.5.15':
dependencies:
@@ -2649,8 +3216,8 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
- '@babel/code-frame': 7.29.0
- '@babel/runtime': 7.29.2
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
@@ -2668,14 +3235,19 @@ snapshots:
'@types/react': 19.2.15
'@types/react-dom': 19.2.3(@types/react@19.2.15)
- '@tybys/wasm-util@0.10.2':
+ '@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
optional: true
'@types/aria-query@5.0.4': {}
- '@types/estree@1.0.8': {}
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
@@ -2695,57 +3267,57 @@ snapshots:
dependencies:
csstype: 3.2.3
- '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.59.4
- '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3)
- '@typescript-eslint/utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.59.4
+ '@typescript-eslint/parser': 8.68.0(eslint@9.39.4)(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.68.0
+ '@typescript-eslint/type-utils': 8.68.0(eslint@9.39.4)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.68.0(eslint@9.39.4)(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.68.0
eslint: 9.39.4
- ignore: 7.0.5
+ ignore: 7.0.6
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.59.4
- '@typescript-eslint/types': 8.59.4
- '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.59.4
+ '@typescript-eslint/scope-manager': 8.68.0
+ '@typescript-eslint/types': 8.68.0
+ '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.68.0
debug: 4.4.3
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.59.4(typescript@5.9.3)':
+ '@typescript-eslint/project-service@8.68.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3)
- '@typescript-eslint/types': 8.59.4
+ '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.68.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.59.4':
+ '@typescript-eslint/scope-manager@8.68.0':
dependencies:
- '@typescript-eslint/types': 8.59.4
- '@typescript-eslint/visitor-keys': 8.59.4
+ '@typescript-eslint/types': 8.68.0
+ '@typescript-eslint/visitor-keys': 8.68.0
- '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.9.3)':
+ '@typescript-eslint/tsconfig-utils@8.68.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4)(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.68.0(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.59.4
- '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3)
- '@typescript-eslint/utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3)
+ '@typescript-eslint/types': 8.68.0
+ '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.68.0(eslint@9.39.4)(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.4
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -2753,37 +3325,37 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.59.4': {}
+ '@typescript-eslint/types@8.68.0': {}
- '@typescript-eslint/typescript-estree@8.59.4(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.68.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.59.4(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3)
- '@typescript-eslint/types': 8.59.4
- '@typescript-eslint/visitor-keys': 8.59.4
+ '@typescript-eslint/project-service': 8.68.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.68.0
+ '@typescript-eslint/visitor-keys': 8.68.0
debug: 4.4.3
- minimatch: 10.2.5
- semver: 7.8.0
- tinyglobby: 0.2.16
+ minimatch: 10.2.6
+ semver: 7.8.5
+ tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.59.4(eslint@9.39.4)(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.68.0(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
- '@typescript-eslint/scope-manager': 8.59.4
- '@typescript-eslint/types': 8.59.4
- '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3)
+ '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4)
+ '@typescript-eslint/scope-manager': 8.68.0
+ '@typescript-eslint/types': 8.68.0
+ '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3)
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.59.4':
+ '@typescript-eslint/visitor-keys@8.68.0':
dependencies:
- '@typescript-eslint/types': 8.59.4
+ '@typescript-eslint/types': 8.68.0
eslint-visitor-keys: 5.0.1
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -2844,7 +3416,7 @@ snapshots:
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
- '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
@@ -2856,45 +3428,66 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
- '@vitest/expect@2.1.9':
+ '@vitest/coverage-v8@3.2.7(vitest@3.2.7(@types/node@22.19.19)(jsdom@30.0.1))':
dependencies:
- '@vitest/spy': 2.1.9
- '@vitest/utils': 2.1.9
+ '@ampproject/remapping': 2.3.0
+ '@bcoe/v8-coverage': 1.0.2
+ ast-v8-to-istanbul: 0.3.12
+ debug: 4.4.3
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-lib-source-maps: 5.0.6
+ istanbul-reports: 3.2.0
+ magic-string: 0.30.21
+ magicast: 0.3.5
+ std-env: 3.10.0
+ test-exclude: 7.0.2
+ tinyrainbow: 2.0.0
+ vitest: 3.2.7(@types/node@22.19.19)(jsdom@30.0.1)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@vitest/expect@3.2.7':
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/spy': 3.2.7
+ '@vitest/utils': 3.2.7
chai: 5.3.3
- tinyrainbow: 1.2.0
+ tinyrainbow: 2.0.0
- '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))':
+ '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.19.19))':
dependencies:
- '@vitest/spy': 2.1.9
+ '@vitest/spy': 3.2.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 5.4.21(@types/node@22.19.19)
+ vite: 7.3.6(@types/node@22.19.19)
- '@vitest/pretty-format@2.1.9':
+ '@vitest/pretty-format@3.2.7':
dependencies:
- tinyrainbow: 1.2.0
+ tinyrainbow: 2.0.0
- '@vitest/runner@2.1.9':
+ '@vitest/runner@3.2.7':
dependencies:
- '@vitest/utils': 2.1.9
- pathe: 1.1.2
+ '@vitest/utils': 3.2.7
+ pathe: 2.0.3
+ strip-literal: 3.1.0
- '@vitest/snapshot@2.1.9':
+ '@vitest/snapshot@3.2.7':
dependencies:
- '@vitest/pretty-format': 2.1.9
+ '@vitest/pretty-format': 3.2.7
magic-string: 0.30.21
- pathe: 1.1.2
+ pathe: 2.0.3
- '@vitest/spy@2.1.9':
+ '@vitest/spy@3.2.7':
dependencies:
- tinyspy: 3.0.2
+ tinyspy: 4.0.4
- '@vitest/utils@2.1.9':
+ '@vitest/utils@3.2.7':
dependencies:
- '@vitest/pretty-format': 2.1.9
+ '@vitest/pretty-format': 3.2.7
loupe: 3.2.1
- tinyrainbow: 1.2.0
+ tinyrainbow: 2.0.0
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
@@ -2917,12 +3510,16 @@ snapshots:
ansi-regex@5.0.1: {}
+ ansi-regex@6.3.0: {}
+
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
+ ansi-styles@6.2.3: {}
+
argparse@2.0.1: {}
aria-query@5.3.0:
@@ -2942,7 +3539,7 @@ snapshots:
call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.2
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
get-intrinsic: 1.3.0
is-string: 1.1.1
math-intrinsics: 1.1.0
@@ -2953,7 +3550,7 @@ snapshots:
define-properties: 1.2.1
es-abstract: 1.24.2
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
es-shim-unscopables: 1.1.0
array.prototype.findlastindex@1.2.6:
@@ -2963,7 +3560,7 @@ snapshots:
define-properties: 1.2.1
es-abstract: 1.24.2
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
es-shim-unscopables: 1.1.0
array.prototype.flat@1.3.3:
@@ -3002,6 +3599,12 @@ snapshots:
ast-types-flow@0.0.8: {}
+ ast-v8-to-istanbul@0.3.12:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ estree-walker: 3.0.3
+ js-tokens: 10.0.0
+
async-function@1.0.0: {}
asynckit@0.4.0: {}
@@ -3010,12 +3613,12 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
- axe-core@4.11.4: {}
+ axe-core@4.13.0: {}
- axios@1.16.1:
+ axios@1.20.0:
dependencies:
follow-redirects: 1.16.0
- form-data: 4.0.5
+ form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
@@ -3028,12 +3631,20 @@ snapshots:
balanced-match@4.0.4: {}
- brace-expansion@1.1.14:
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
+ brace-expansion@1.1.18:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
- brace-expansion@5.0.6:
+ brace-expansion@2.1.4:
+ dependencies:
+ balanced-match: 1.0.2
+
+ brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -3062,7 +3673,7 @@ snapshots:
callsites@3.1.0: {}
- caniuse-lite@1.0.30001793: {}
+ caniuse-lite@1.0.30001810: {}
chai@5.3.3:
dependencies:
@@ -3101,10 +3712,22 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
csstype@3.2.3: {}
damerau-levenshtein@1.0.8: {}
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -3133,6 +3756,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decimal.js@10.6.0: {}
+
deep-eql@5.0.2: {}
deep-is@0.1.4: {}
@@ -3168,8 +3793,21 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ eastasianwidth@0.2.0: {}
+
+ emoji-regex@8.0.0: {}
+
emoji-regex@9.2.2: {}
+ entities@8.0.0: {}
+
+ es-abstract-get@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ is-callable: 1.2.7
+ object-inspect: 1.13.4
+
es-abstract@1.24.2:
dependencies:
array-buffer-byte-length: 1.0.2
@@ -3182,10 +3820,10 @@ snapshots:
data-view-byte-offset: 1.0.1
es-define-property: 1.0.1
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
es-set-tostringtag: 2.1.0
- es-to-primitive: 1.3.0
- function.prototype.name: 1.1.8
+ es-to-primitive: 1.3.4
+ function.prototype.name: 1.2.0
get-intrinsic: 1.3.0
get-proto: 1.0.1
get-symbol-description: 1.1.0
@@ -3194,7 +3832,7 @@ snapshots:
has-property-descriptors: 1.0.2
has-proto: 1.2.0
has-symbols: 1.1.0
- hasown: 2.0.3
+ hasown: 2.0.4
internal-slot: 1.1.0
is-array-buffer: 3.0.5
is-callable: 1.2.7
@@ -3210,28 +3848,28 @@ snapshots:
object-inspect: 1.13.4
object-keys: 1.1.1
object.assign: 4.1.7
- own-keys: 1.0.1
+ own-keys: 1.0.2
regexp.prototype.flags: 1.5.4
safe-array-concat: 1.1.4
safe-push-apply: 1.0.0
safe-regex-test: 1.1.0
set-proto: 1.0.0
stop-iteration-iterator: 1.1.0
- string.prototype.trim: 1.2.10
- string.prototype.trimend: 1.0.9
+ string.prototype.trim: 1.2.11
+ string.prototype.trimend: 1.0.10
string.prototype.trimstart: 1.0.8
typed-array-buffer: 1.0.3
typed-array-byte-length: 1.0.3
typed-array-byte-offset: 1.0.4
- typed-array-length: 1.0.7
+ typed-array-length: 1.0.8
unbox-primitive: 1.1.0
- which-typed-array: 1.1.20
+ which-typed-array: 1.1.22
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
- es-iterator-helpers@1.3.2:
+ es-iterator-helpers@1.4.0:
dependencies:
call-bind: 1.0.9
call-bound: 1.0.4
@@ -3252,7 +3890,7 @@ snapshots:
es-module-lexer@1.7.0: {}
- es-object-atoms@1.1.1:
+ es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
@@ -3261,56 +3899,62 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
- hasown: 2.0.3
+ hasown: 2.0.4
es-shim-unscopables@1.1.0:
dependencies:
- hasown: 2.0.3
+ hasown: 2.0.4
- es-to-primitive@1.3.0:
+ es-to-primitive@1.3.4:
dependencies:
+ es-abstract-get: 1.0.0
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
is-callable: 1.2.7
is-date-object: 1.1.0
is-symbol: 1.1.1
- esbuild@0.21.5:
+ esbuild@0.28.2:
optionalDependencies:
- '@esbuild/aix-ppc64': 0.21.5
- '@esbuild/android-arm': 0.21.5
- '@esbuild/android-arm64': 0.21.5
- '@esbuild/android-x64': 0.21.5
- '@esbuild/darwin-arm64': 0.21.5
- '@esbuild/darwin-x64': 0.21.5
- '@esbuild/freebsd-arm64': 0.21.5
- '@esbuild/freebsd-x64': 0.21.5
- '@esbuild/linux-arm': 0.21.5
- '@esbuild/linux-arm64': 0.21.5
- '@esbuild/linux-ia32': 0.21.5
- '@esbuild/linux-loong64': 0.21.5
- '@esbuild/linux-mips64el': 0.21.5
- '@esbuild/linux-ppc64': 0.21.5
- '@esbuild/linux-riscv64': 0.21.5
- '@esbuild/linux-s390x': 0.21.5
- '@esbuild/linux-x64': 0.21.5
- '@esbuild/netbsd-x64': 0.21.5
- '@esbuild/openbsd-x64': 0.21.5
- '@esbuild/sunos-x64': 0.21.5
- '@esbuild/win32-arm64': 0.21.5
- '@esbuild/win32-ia32': 0.21.5
- '@esbuild/win32-x64': 0.21.5
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
escape-string-regexp@4.0.0: {}
- eslint-config-next@15.5.18(eslint@9.39.4)(typescript@5.9.3):
+ eslint-config-next@15.5.24(eslint@9.39.4)(typescript@5.9.3):
dependencies:
- '@next/eslint-plugin-next': 15.5.18
+ '@next/eslint-plugin-next': 15.5.24
'@rushstack/eslint-patch': 1.16.1
- '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
- '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.68.0(eslint@9.39.4)(typescript@5.9.3)
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4)
eslint-plugin-react: 7.37.5(eslint@9.39.4)
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4)
@@ -3329,33 +3973,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
eslint: 9.39.4
- get-tsconfig: 4.14.0
+ get-tsconfig: 4.14.3
is-bun-module: 2.0.0
stable-hash: 0.0.5
- tinyglobby: 0.2.16
+ tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4)
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4):
+ eslint-module-utils@2.14.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.68.0(eslint@9.39.4)(typescript@5.9.3)
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4)
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -3366,8 +4010,8 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4)
- hasown: 2.0.3
+ eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4)
+ hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
minimatch: 3.1.5
@@ -3375,10 +4019,10 @@ snapshots:
object.groupby: 1.0.3
object.values: 1.2.1
semver: 6.3.1
- string.prototype.trimend: 1.0.9
+ string.prototype.trimend: 1.0.10
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.68.0(eslint@9.39.4)(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -3390,12 +4034,12 @@ snapshots:
array-includes: 3.1.9
array.prototype.flatmap: 1.3.3
ast-types-flow: 0.0.8
- axe-core: 4.11.4
+ axe-core: 4.13.0
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
eslint: 9.39.4
- hasown: 2.0.3
+ hasown: 2.0.4
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
minimatch: 3.1.5
@@ -3414,10 +4058,10 @@ snapshots:
array.prototype.flatmap: 1.3.3
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
- es-iterator-helpers: 1.3.2
+ es-iterator-helpers: 1.4.0
eslint: 9.39.4
estraverse: 5.3.0
- hasown: 2.0.3
+ hasown: 2.0.4
jsx-ast-utils: 3.3.5
minimatch: 3.1.5
object.entries: 1.1.9
@@ -3426,7 +4070,7 @@ snapshots:
prop-types: 15.8.1
resolve: 2.0.0-next.7
semver: 6.3.1
- string.prototype.matchall: 4.0.12
+ string.prototype.matchall: 4.1.0
string.prototype.repeat: 1.0.0
eslint-scope@8.4.0:
@@ -3447,7 +4091,7 @@ snapshots:
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
- '@eslint/eslintrc': 3.3.5
+ '@eslint/eslintrc': 3.3.6
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
@@ -3501,7 +4145,7 @@ snapshots:
esutils@2.0.3: {}
- expect-type@1.3.0: {}
+ expect-type@1.4.0: {}
fast-deep-equal@3.1.3: {}
@@ -3517,13 +4161,13 @@ snapshots:
fast-levenshtein@2.0.6: {}
- fastq@1.20.1:
+ fastq@1.20.3:
dependencies:
reusify: 1.1.0
- fdir@6.5.0(picomatch@4.0.4):
+ fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
- picomatch: 4.0.4
+ picomatch: 4.0.7
file-entry-cache@8.0.0:
dependencies:
@@ -3551,27 +4195,38 @@ snapshots:
dependencies:
is-callable: 1.2.7
- form-data@4.0.5:
+ foreground-child@3.3.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ signal-exit: 4.1.0
+
+ form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
- hasown: 2.0.3
+ hasown: 2.0.4
mime-types: 2.1.35
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
- function.prototype.name@1.1.8:
+ function.prototype.name@1.2.0:
dependencies:
call-bind: 1.0.9
call-bound: 1.0.4
- define-properties: 1.2.1
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
functions-have-names: 1.2.3
- hasown: 2.0.3
+ has-property-descriptors: 1.0.2
+ hasown: 2.0.4
is-callable: 1.2.7
+ is-document.all: 1.0.0
functions-have-names@1.2.3: {}
@@ -3582,18 +4237,18 @@ snapshots:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
- hasown: 2.0.3
+ hasown: 2.0.4
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
get-symbol-description@1.1.0:
dependencies:
@@ -3601,7 +4256,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
- get-tsconfig@4.14.0:
+ get-tsconfig@4.14.3:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -3613,6 +4268,15 @@ snapshots:
dependencies:
is-glob: 4.0.3
+ glob@10.5.0:
+ dependencies:
+ foreground-child: 3.3.1
+ jackspeak: 3.4.3
+ minimatch: 9.0.9
+ minipass: 7.1.3
+ package-json-from-dist: 1.0.1
+ path-scurry: 1.11.1
+
globals@14.0.0: {}
globalthis@1.0.4:
@@ -3640,10 +4304,18 @@ snapshots:
dependencies:
has-symbols: 1.1.0
- hasown@2.0.3:
+ hasown@2.0.4:
dependencies:
function-bind: 1.1.2
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ html-escaper@2.0.2: {}
+
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
@@ -3653,7 +4325,7 @@ snapshots:
ignore@5.3.2: {}
- ignore@7.0.5: {}
+ ignore@7.0.6: {}
import-fresh@3.3.1:
dependencies:
@@ -3665,8 +4337,8 @@ snapshots:
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
- hasown: 2.0.3
- side-channel: 1.1.0
+ hasown: 2.0.4
+ side-channel: 1.1.1
is-array-buffer@3.0.5:
dependencies:
@@ -3693,13 +4365,13 @@ snapshots:
is-bun-module@2.0.0:
dependencies:
- semver: 7.8.0
+ semver: 7.8.5
is-callable@1.2.7: {}
is-core-module@2.16.2:
dependencies:
- hasown: 2.0.3
+ hasown: 2.0.4
is-data-view@1.0.2:
dependencies:
@@ -3712,12 +4384,18 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-document.all@1.0.0:
+ dependencies:
+ call-bound: 1.0.4
+
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
dependencies:
call-bound: 1.0.4
+ is-fullwidth-code-point@3.0.0: {}
+
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
@@ -3741,12 +4419,14 @@ snapshots:
is-number@7.0.0: {}
+ is-potential-custom-element-name@1.0.1: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
gopd: 1.2.0
has-tostringtag: 1.0.2
- hasown: 2.0.3
+ hasown: 2.0.4
is-set@2.0.3: {}
@@ -3767,7 +4447,7 @@ snapshots:
is-typed-array@1.1.15:
dependencies:
- which-typed-array: 1.1.20
+ which-typed-array: 1.1.22
is-weakmap@2.0.2: {}
@@ -3784,21 +4464,78 @@ snapshots:
isexe@2.0.0: {}
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-lib-source-maps@5.0.6:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ debug: 4.4.3
+ istanbul-lib-coverage: 3.2.2
+ transitivePeerDependencies:
+ - supports-color
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
get-intrinsic: 1.3.0
get-proto: 1.0.1
has-symbols: 1.1.0
set-function-name: 2.0.2
+ jackspeak@3.4.3:
+ dependencies:
+ '@isaacs/cliui': 8.0.2
+ optionalDependencies:
+ '@pkgjs/parseargs': 0.11.0
+
+ js-tokens@10.0.0: {}
+
js-tokens@4.0.0: {}
- js-yaml@4.1.1:
+ js-tokens@9.0.1: {}
+
+ js-yaml@5.4.1:
dependencies:
argparse: 2.0.1
+ jsdom@30.0.1:
+ dependencies:
+ '@asamuzakjp/css-color': 6.0.7
+ '@asamuzakjp/dom-selector': 8.3.2
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.9(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.1
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.2
+ undici: 8.10.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 17.1.0
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
@@ -3843,6 +4580,10 @@ snapshots:
loupe@3.2.1: {}
+ lru-cache@10.4.3: {}
+
+ lru-cache@11.5.2: {}
+
lucide-react@0.468.0(react@19.2.6):
dependencies:
react: 19.2.6
@@ -3851,10 +4592,22 @@ snapshots:
magic-string@0.30.21:
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ magicast@0.3.5:
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+ source-map-js: 1.2.1
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.8.5
math-intrinsics@1.1.0: {}
+ mdn-data@2.27.1: {}
+
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -3868,48 +4621,56 @@ snapshots:
dependencies:
mime-db: 1.52.0
- minimatch@10.2.5:
+ minimatch@10.2.6:
dependencies:
- brace-expansion: 5.0.6
+ brace-expansion: 5.0.9
minimatch@3.1.5:
dependencies:
- brace-expansion: 1.1.14
+ brace-expansion: 1.1.18
+
+ minimatch@9.0.9:
+ dependencies:
+ brace-expansion: 2.1.4
minimist@1.2.8: {}
+ minipass@7.1.3: {}
+
ms@2.1.3: {}
- nanoid@3.3.12: {}
+ nanoid@6.0.1: {}
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
- next@15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ next@15.5.24(@playwright/test@1.62.1)(@types/node@22.19.19)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
- '@next/env': 15.5.18
+ '@next/env': 15.5.24
'@swc/helpers': 0.5.15
- caniuse-lite: 1.0.30001793
- postcss: 8.4.31
+ caniuse-lite: 1.0.30001810
+ postcss: 8.5.26
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-jsx: 5.1.6(react@19.2.6)
optionalDependencies:
- '@next/swc-darwin-arm64': 15.5.18
- '@next/swc-darwin-x64': 15.5.18
- '@next/swc-linux-arm64-gnu': 15.5.18
- '@next/swc-linux-arm64-musl': 15.5.18
- '@next/swc-linux-x64-gnu': 15.5.18
- '@next/swc-linux-x64-musl': 15.5.18
- '@next/swc-win32-arm64-msvc': 15.5.18
- '@next/swc-win32-x64-msvc': 15.5.18
- sharp: 0.34.5
+ '@next/swc-darwin-arm64': 15.5.24
+ '@next/swc-darwin-x64': 15.5.24
+ '@next/swc-linux-arm64-gnu': 15.5.24
+ '@next/swc-linux-arm64-musl': 15.5.24
+ '@next/swc-linux-x64-gnu': 15.5.24
+ '@next/swc-linux-x64-musl': 15.5.24
+ '@next/swc-win32-arm64-msvc': 15.5.24
+ '@next/swc-win32-x64-msvc': 15.5.24
+ '@playwright/test': 1.62.1
+ sharp: 0.35.4(@types/node@22.19.19)
transitivePeerDependencies:
- '@babel/core'
+ - '@types/node'
- babel-plugin-macros
- node-exports-info@1.6.0:
+ node-exports-info@1.6.2:
dependencies:
array.prototype.flatmap: 1.3.3
es-errors: 1.3.0
@@ -3927,7 +4688,7 @@ snapshots:
call-bind: 1.0.9
call-bound: 1.0.4
define-properties: 1.2.1
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
has-symbols: 1.1.0
object-keys: 1.1.1
@@ -3936,14 +4697,14 @@ snapshots:
call-bind: 1.0.9
call-bound: 1.0.4
define-properties: 1.2.1
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
object.fromentries@2.0.8:
dependencies:
call-bind: 1.0.9
define-properties: 1.2.1
es-abstract: 1.24.2
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
object.groupby@1.0.3:
dependencies:
@@ -3956,7 +4717,7 @@ snapshots:
call-bind: 1.0.9
call-bound: 1.0.4
define-properties: 1.2.1
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
optionator@0.9.4:
dependencies:
@@ -3967,8 +4728,9 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
- own-keys@1.0.1:
+ own-keys@1.0.2:
dependencies:
+ call-bound: 1.0.4
get-intrinsic: 1.3.0
object-keys: 1.1.1
safe-push-apply: 1.0.0
@@ -3981,17 +4743,28 @@ snapshots:
dependencies:
p-limit: 3.1.0
+ package-json-from-dist@1.0.1: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
path-exists@4.0.0: {}
path-key@3.1.1: {}
path-parse@1.0.7: {}
- pathe@1.1.2: {}
+ path-scurry@1.11.1:
+ dependencies:
+ lru-cache: 10.4.3
+ minipass: 7.1.3
+
+ pathe@2.0.3: {}
pathval@2.0.1: {}
@@ -3999,19 +4772,21 @@ snapshots:
picomatch@2.3.2: {}
- picomatch@4.0.4: {}
+ picomatch@4.0.7: {}
- possible-typed-array-names@1.1.0: {}
+ playwright-core@1.62.1: {}
- postcss@8.4.31:
+ playwright@1.62.1:
dependencies:
- nanoid: 3.3.12
- picocolors: 1.1.1
- source-map-js: 1.2.1
+ playwright-core: 1.62.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
+ possible-typed-array-names@1.1.0: {}
- postcss@8.5.15:
+ postcss@8.5.26:
dependencies:
- nanoid: 3.3.12
+ nanoid: 6.0.1
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -4052,7 +4827,7 @@ snapshots:
define-properties: 1.2.1
es-abstract: 1.24.2
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
get-intrinsic: 1.3.0
get-proto: 1.0.1
which-builtin-type: 1.2.1
@@ -4066,6 +4841,8 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
+ require-from-string@2.0.2: {}
+
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -4074,42 +4851,43 @@ snapshots:
dependencies:
es-errors: 1.3.0
is-core-module: 2.16.2
- node-exports-info: 1.6.0
+ node-exports-info: 1.6.2
object-keys: 1.1.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
reusify@1.1.0: {}
- rollup@4.60.4:
+ rollup@4.63.1:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.60.4
- '@rollup/rollup-android-arm64': 4.60.4
- '@rollup/rollup-darwin-arm64': 4.60.4
- '@rollup/rollup-darwin-x64': 4.60.4
- '@rollup/rollup-freebsd-arm64': 4.60.4
- '@rollup/rollup-freebsd-x64': 4.60.4
- '@rollup/rollup-linux-arm-gnueabihf': 4.60.4
- '@rollup/rollup-linux-arm-musleabihf': 4.60.4
- '@rollup/rollup-linux-arm64-gnu': 4.60.4
- '@rollup/rollup-linux-arm64-musl': 4.60.4
- '@rollup/rollup-linux-loong64-gnu': 4.60.4
- '@rollup/rollup-linux-loong64-musl': 4.60.4
- '@rollup/rollup-linux-ppc64-gnu': 4.60.4
- '@rollup/rollup-linux-ppc64-musl': 4.60.4
- '@rollup/rollup-linux-riscv64-gnu': 4.60.4
- '@rollup/rollup-linux-riscv64-musl': 4.60.4
- '@rollup/rollup-linux-s390x-gnu': 4.60.4
- '@rollup/rollup-linux-x64-gnu': 4.60.4
- '@rollup/rollup-linux-x64-musl': 4.60.4
- '@rollup/rollup-openbsd-x64': 4.60.4
- '@rollup/rollup-openharmony-arm64': 4.60.4
- '@rollup/rollup-win32-arm64-msvc': 4.60.4
- '@rollup/rollup-win32-ia32-msvc': 4.60.4
- '@rollup/rollup-win32-x64-gnu': 4.60.4
- '@rollup/rollup-win32-x64-msvc': 4.60.4
+ '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+ '@rollup/rollup-android-arm-eabi': 4.63.1
+ '@rollup/rollup-android-arm64': 4.63.1
+ '@rollup/rollup-darwin-arm64': 4.63.1
+ '@rollup/rollup-darwin-x64': 4.63.1
+ '@rollup/rollup-freebsd-arm64': 4.63.1
+ '@rollup/rollup-freebsd-x64': 4.63.1
+ '@rollup/rollup-linux-arm-gnueabihf': 4.63.1
+ '@rollup/rollup-linux-arm-musleabihf': 4.63.1
+ '@rollup/rollup-linux-arm64-gnu': 4.63.1
+ '@rollup/rollup-linux-arm64-musl': 4.63.1
+ '@rollup/rollup-linux-loong64-gnu': 4.63.1
+ '@rollup/rollup-linux-loong64-musl': 4.63.1
+ '@rollup/rollup-linux-ppc64-gnu': 4.63.1
+ '@rollup/rollup-linux-ppc64-musl': 4.63.1
+ '@rollup/rollup-linux-riscv64-gnu': 4.63.1
+ '@rollup/rollup-linux-riscv64-musl': 4.63.1
+ '@rollup/rollup-linux-s390x-gnu': 4.63.1
+ '@rollup/rollup-linux-x64-gnu': 4.63.1
+ '@rollup/rollup-linux-x64-musl': 4.63.1
+ '@rollup/rollup-openbsd-x64': 4.63.1
+ '@rollup/rollup-openharmony-arm64': 4.63.1
+ '@rollup/rollup-win32-arm64-msvc': 4.63.1
+ '@rollup/rollup-win32-ia32-msvc': 4.63.1
+ '@rollup/rollup-win32-x64-gnu': 4.63.1
+ '@rollup/rollup-win32-x64-msvc': 4.63.1
fsevents: 2.3.3
run-parallel@1.2.0:
@@ -4135,11 +4913,15 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
semver@6.3.1: {}
- semver@7.8.0: {}
+ semver@7.8.5: {}
set-function-length@1.2.2:
dependencies:
@@ -4161,38 +4943,40 @@ snapshots:
dependencies:
dunder-proto: 1.0.1
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
- sharp@0.34.5:
+ sharp@0.35.4(@types/node@22.19.19):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.8.0
+ semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.34.5
- '@img/sharp-darwin-x64': 0.34.5
- '@img/sharp-libvips-darwin-arm64': 1.2.4
- '@img/sharp-libvips-darwin-x64': 1.2.4
- '@img/sharp-libvips-linux-arm': 1.2.4
- '@img/sharp-libvips-linux-arm64': 1.2.4
- '@img/sharp-libvips-linux-ppc64': 1.2.4
- '@img/sharp-libvips-linux-riscv64': 1.2.4
- '@img/sharp-libvips-linux-s390x': 1.2.4
- '@img/sharp-libvips-linux-x64': 1.2.4
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
- '@img/sharp-linux-arm': 0.34.5
- '@img/sharp-linux-arm64': 0.34.5
- '@img/sharp-linux-ppc64': 0.34.5
- '@img/sharp-linux-riscv64': 0.34.5
- '@img/sharp-linux-s390x': 0.34.5
- '@img/sharp-linux-x64': 0.34.5
- '@img/sharp-linuxmusl-arm64': 0.34.5
- '@img/sharp-linuxmusl-x64': 0.34.5
- '@img/sharp-wasm32': 0.34.5
- '@img/sharp-win32-arm64': 0.34.5
- '@img/sharp-win32-ia32': 0.34.5
- '@img/sharp-win32-x64': 0.34.5
+ '@img/sharp-darwin-arm64': 0.35.4
+ '@img/sharp-darwin-x64': 0.35.4
+ '@img/sharp-freebsd-wasm32': 0.35.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.3
+ '@img/sharp-libvips-darwin-x64': 1.3.3
+ '@img/sharp-libvips-linux-arm': 1.3.3
+ '@img/sharp-libvips-linux-arm64': 1.3.3
+ '@img/sharp-libvips-linux-ppc64': 1.3.3
+ '@img/sharp-libvips-linux-riscv64': 1.3.3
+ '@img/sharp-libvips-linux-s390x': 1.3.3
+ '@img/sharp-libvips-linux-x64': 1.3.3
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.3
+ '@img/sharp-linux-arm': 0.35.4
+ '@img/sharp-linux-arm64': 0.35.4
+ '@img/sharp-linux-ppc64': 0.35.4
+ '@img/sharp-linux-riscv64': 0.35.4
+ '@img/sharp-linux-s390x': 0.35.4
+ '@img/sharp-linux-x64': 0.35.4
+ '@img/sharp-linuxmusl-arm64': 0.35.4
+ '@img/sharp-linuxmusl-x64': 0.35.4
+ '@img/sharp-webcontainers-wasm32': 0.35.4
+ '@img/sharp-win32-arm64': 0.35.4
+ '@img/sharp-win32-ia32': 0.35.4
+ '@img/sharp-win32-x64': 0.35.4
+ '@types/node': 22.19.19
optional: true
shebang-command@2.0.0:
@@ -4221,7 +5005,7 @@ snapshots:
object-inspect: 1.13.4
side-channel-map: 1.0.1
- side-channel@1.1.0:
+ side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
@@ -4231,6 +5015,8 @@ snapshots:
siginfo@2.0.0: {}
+ signal-exit@4.1.0: {}
+
source-map-js@1.2.1: {}
stable-hash@0.0.5: {}
@@ -4244,60 +5030,85 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
+ string-width@5.1.2:
+ dependencies:
+ eastasianwidth: 0.2.0
+ emoji-regex: 9.2.2
+ strip-ansi: 7.2.0
+
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.9
define-properties: 1.2.1
es-abstract: 1.24.2
- string.prototype.matchall@4.0.12:
+ string.prototype.matchall@4.1.0:
dependencies:
call-bind: 1.0.9
call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.2
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
get-intrinsic: 1.3.0
gopd: 1.2.0
has-symbols: 1.1.0
internal-slot: 1.1.0
regexp.prototype.flags: 1.5.4
set-function-name: 2.0.2
- side-channel: 1.1.0
+ side-channel: 1.1.1
string.prototype.repeat@1.0.0:
dependencies:
define-properties: 1.2.1
es-abstract: 1.24.2
- string.prototype.trim@1.2.10:
+ string.prototype.trim@1.2.11:
dependencies:
call-bind: 1.0.9
call-bound: 1.0.4
define-data-property: 1.1.4
define-properties: 1.2.1
es-abstract: 1.24.2
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
has-property-descriptors: 1.0.2
+ safe-regex-test: 1.1.0
- string.prototype.trimend@1.0.9:
+ string.prototype.trimend@1.0.10:
dependencies:
call-bind: 1.0.9
call-bound: 1.0.4
define-properties: 1.2.1
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
string.prototype.trimstart@1.0.8:
dependencies:
call-bind: 1.0.9
define-properties: 1.2.1
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
+ strip-ansi@7.2.0:
+ dependencies:
+ ansi-regex: 6.3.0
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
+ strip-literal@3.1.0:
+ dependencies:
+ js-tokens: 9.0.1
+
styled-jsx@5.1.6(react@19.2.6):
dependencies:
client-only: 0.0.1
@@ -4309,25 +5120,47 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ symbol-tree@3.2.4: {}
+
+ test-exclude@7.0.2:
+ dependencies:
+ '@istanbuljs/schema': 0.1.6
+ glob: 10.5.0
+ minimatch: 10.2.6
+
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
- tinyglobby@0.2.16:
+ tinyglobby@0.2.17:
dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
tinypool@1.1.1: {}
- tinyrainbow@1.2.0: {}
+ tinyrainbow@2.0.0: {}
+
+ tinyspy@4.0.4: {}
- tinyspy@3.0.2: {}
+ tldts-core@7.4.11: {}
+
+ tldts@7.4.11:
+ dependencies:
+ tldts-core: 7.4.11
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
+ tough-cookie@6.0.2:
+ dependencies:
+ tldts: 7.4.11
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -4369,7 +5202,7 @@ snapshots:
is-typed-array: 1.1.15
reflect.getprototypeof: 1.0.10
- typed-array-length@1.0.7:
+ typed-array-length@1.0.8:
dependencies:
call-bind: 1.0.9
for-each: 0.3.5
@@ -4389,6 +5222,8 @@ snapshots:
undici-types@6.21.0: {}
+ undici@8.10.0: {}
+
unrs-resolver@1.12.2:
dependencies:
napi-postinstall: 0.3.4
@@ -4420,15 +5255,16 @@ snapshots:
dependencies:
punycode: 2.3.1
- vite-node@2.1.9(@types/node@22.19.19):
+ vite-node@3.2.4(@types/node@22.19.19):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
- pathe: 1.1.2
- vite: 5.4.21(@types/node@22.19.19)
+ pathe: 2.0.3
+ vite: 7.3.6(@types/node@22.19.19)
transitivePeerDependencies:
- '@types/node'
+ - jiti
- less
- lightningcss
- sass
@@ -4437,41 +5273,51 @@ snapshots:
- sugarss
- supports-color
- terser
+ - tsx
+ - yaml
- vite@5.4.21(@types/node@22.19.19):
+ vite@7.3.6(@types/node@22.19.19):
dependencies:
- esbuild: 0.21.5
- postcss: 8.5.15
- rollup: 4.60.4
+ esbuild: 0.28.2
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+ postcss: 8.5.26
+ rollup: 4.63.1
+ tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 22.19.19
fsevents: 2.3.3
- vitest@2.1.9(@types/node@22.19.19):
+ vitest@3.2.7(@types/node@22.19.19)(jsdom@30.0.1):
dependencies:
- '@vitest/expect': 2.1.9
- '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19))
- '@vitest/pretty-format': 2.1.9
- '@vitest/runner': 2.1.9
- '@vitest/snapshot': 2.1.9
- '@vitest/spy': 2.1.9
- '@vitest/utils': 2.1.9
+ '@types/chai': 5.2.3
+ '@vitest/expect': 3.2.7
+ '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.19.19))
+ '@vitest/pretty-format': 3.2.7
+ '@vitest/runner': 3.2.7
+ '@vitest/snapshot': 3.2.7
+ '@vitest/spy': 3.2.7
+ '@vitest/utils': 3.2.7
chai: 5.3.3
debug: 4.4.3
- expect-type: 1.3.0
+ expect-type: 1.4.0
magic-string: 0.30.21
- pathe: 1.1.2
+ pathe: 2.0.3
+ picomatch: 4.0.7
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
+ tinyglobby: 0.2.17
tinypool: 1.1.1
- tinyrainbow: 1.2.0
- vite: 5.4.21(@types/node@22.19.19)
- vite-node: 2.1.9(@types/node@22.19.19)
+ tinyrainbow: 2.0.0
+ vite: 7.3.6(@types/node@22.19.19)
+ vite-node: 3.2.4(@types/node@22.19.19)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.19
+ jsdom: 30.0.1
transitivePeerDependencies:
+ - jiti
- less
- lightningcss
- msw
@@ -4481,6 +5327,32 @@ snapshots:
- sugarss
- supports-color
- terser
+ - tsx
+ - yaml
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ whatwg-url@17.1.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
which-boxed-primitive@1.1.1:
dependencies:
@@ -4493,7 +5365,7 @@ snapshots:
which-builtin-type@1.2.1:
dependencies:
call-bound: 1.0.4
- function.prototype.name: 1.1.8
+ function.prototype.name: 1.2.0
has-tostringtag: 1.0.2
is-async-function: 2.1.1
is-date-object: 1.1.0
@@ -4504,7 +5376,7 @@ snapshots:
isarray: 2.0.5
which-boxed-primitive: 1.1.1
which-collection: 1.0.2
- which-typed-array: 1.1.20
+ which-typed-array: 1.1.22
which-collection@1.0.2:
dependencies:
@@ -4513,7 +5385,7 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
- which-typed-array@1.1.20:
+ which-typed-array@1.1.22:
dependencies:
available-typed-arrays: 1.0.7
call-bind: 1.0.9
@@ -4534,4 +5406,22 @@ snapshots:
word-wrap@1.2.5: {}
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
+ wrap-ansi@8.1.0:
+ dependencies:
+ ansi-styles: 6.2.3
+ string-width: 5.1.2
+ strip-ansi: 7.2.0
+
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
yocto-queue@0.1.0: {}
+
+ zod@4.5.4: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d040517..1b2b39c 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,4 +1,7 @@
-allowBuilds:
- esbuild: false
- sharp: false
- unrs-resolver: false
+packages:
+ - .
+
+onlyBuiltDependencies:
+ - esbuild
+ - sharp
+ - unrs-resolver
diff --git a/scripts/coverage-uncovered.mjs b/scripts/coverage-uncovered.mjs
new file mode 100644
index 0000000..10b031a
--- /dev/null
+++ b/scripts/coverage-uncovered.mjs
@@ -0,0 +1,12 @@
+import { readFile } from 'node:fs/promises';
+
+const report = JSON.parse(await readFile(new URL('../coverage/coverage-final.json', import.meta.url), 'utf8'));
+let failed = false;
+for (const [file, coverage] of Object.entries(report)) {
+ const missing = Object.entries(coverage.s).filter(([, hits]) => hits === 0).map(([id]) => coverage.statementMap[id]?.start.line).filter(Boolean);
+ if (missing.length) {
+ failed = true;
+ console.log(`${file}: ${[...new Set(missing)].sort((a, b) => a - b).join(', ')}`);
+ }
+}
+if (!failed) console.log('No uncovered statements.');
diff --git a/storefront.config.ts b/storefront.config.ts
index ca07027..8396d93 100644
--- a/storefront.config.ts
+++ b/storefront.config.ts
@@ -22,7 +22,9 @@ const config: StorefrontWebConfig = {
delivery: true,
tips: true,
orderHistory: true,
- marketplace: true
+ // Marketplace checkout is intentionally held for a later release. Network
+ // catalogs require merchant/location-aware cart and multi-origin quoting.
+ marketplace: false
},
plugins: [stripePlugin, checkoutMessagePlugin, promoBannerPlugin, analyticsPlugin]
};
diff --git a/tests/checkout-interactions.test.tsx b/tests/checkout-interactions.test.tsx
new file mode 100644
index 0000000..1b47341
--- /dev/null
+++ b/tests/checkout-interactions.test.tsx
@@ -0,0 +1,211 @@
+// @vitest-environment jsdom
+import React from 'react';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Cart, Order } from '@/lib/types';
+
+const stripeState = vi.hoisted(() => ({
+ stripe: null as null | { confirmPayment: ReturnType },
+ elements: null as null | Record,
+ loadStripe: vi.fn()
+}));
+
+vi.mock('@stripe/stripe-js', () => ({ loadStripe: stripeState.loadStripe }));
+vi.mock('@stripe/react-stripe-js', () => ({
+ Elements: ({ children }: { children: React.ReactNode }) => React.createElement('div', { 'data-testid': 'elements' }, children),
+ PaymentElement: () => React.createElement('div', { 'data-testid': 'payment-element' }),
+ useStripe: () => stripeState.stripe,
+ useElements: () => stripeState.elements
+}));
+
+const fullCart: Cart = {
+ id: 'cart_1', currency: 'USD', subtotal: 1000,
+ items: [{ id: 'line_1', product_id: 'prod_1', name: 'Pizza', quantity: 1, subtotal: 1000 }]
+};
+const placedOrder: Order = { id: 'order_1', public_id: 'public_1', status: 'created' };
+
+function json(body: unknown, status = 200) {
+ return Promise.resolve(Response.json(body, { status }));
+}
+
+function initialFetch(overrides: { cart?: Cart | null; gateways?: unknown[]; fail?: boolean } = {}) {
+ return vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (overrides.fail) return Promise.reject(new Error('offline'));
+ if (url.endsWith('/cart')) return json({ cart: overrides.cart === undefined ? fullCart : overrides.cart });
+ if (url.endsWith('/gateways')) return json({ gateways: overrides.gateways ?? [{ id: 'stripe', code: 'stripe', name: 'Card' }, { id: 'cash', code: 'cash', name: 'Cash' }, { id: 'crypto', code: 'crypto' }] });
+ if (url.endsWith('/store-locations')) return json({ locations: [{ id: 'location_1', name: 'Main', place: { id: 'origin_1' } }] });
+ if (url.endsWith('/customer/places')) return json({ places: [{ id: 'destination_1', name: 'Home' }] });
+ throw new Error(`Unexpected request: ${url}`);
+ });
+}
+
+async function loadCheckout(publishableKey?: string) {
+ if (publishableKey === undefined) delete process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
+ else vi.stubEnv('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', publishableKey);
+ vi.resetModules();
+ return (await import('@/features/checkout/checkout-page-client')).CheckoutPageClient;
+}
+
+describe('checkout interactions', () => {
+ beforeEach(() => {
+ stripeState.loadStripe.mockReset().mockReturnValue(Promise.resolve({}));
+ stripeState.stripe = null;
+ stripeState.elements = null;
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ });
+ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
+
+ it('calculates delivery, confirms Stripe payment, captures, and renders success', async () => {
+ const fetch = initialFetch();
+ fetch.mockImplementationOnce(() => json({ cart: fullCart }))
+ .mockImplementationOnce(() => json({ gateways: [{ id: 'stripe', code: 'stripe', name: 'Card' }] }))
+ .mockImplementationOnce(() => json({ locations: [{ id: 'location_1', name: 'Main', place: { id: 'origin_1' } }] }))
+ .mockImplementationOnce(() => json({ places: [{ id: 'destination_1', name: 'Home' }] }))
+ .mockImplementationOnce(() => json({ quote: { id: 'quote_1', amount: 500, currency: 'USD' } }))
+ .mockImplementationOnce(() => json({ token: 'checkout_1', clientSecret: 'secret_1' }))
+ .mockImplementationOnce(() => json({ token: 'checkout_1', clientSecret: 'secret_1' }))
+ .mockImplementationOnce(() => json({ order: placedOrder }));
+ vi.stubGlobal('fetch', fetch);
+ stripeState.stripe = { confirmPayment: vi.fn().mockResolvedValue({ paymentIntent: { status: 'succeeded' } }) };
+ stripeState.elements = {};
+ const Checkout = await loadCheckout('pk_test_fixture');
+ render( );
+ await screen.findByText('Home');
+ fireEvent.change(screen.getByLabelText('Pickup location'), { target: { value: '' } });
+ fireEvent.change(screen.getByLabelText('Pickup location'), { target: { value: 'origin_1' } });
+ fireEvent.change(screen.getByLabelText('Delivery address'), { target: { value: '' } });
+ fireEvent.change(screen.getByLabelText('Delivery address'), { target: { value: 'destination_1' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Calculate delivery' }));
+ expect(await screen.findByText('$5.00')).toBeTruthy();
+ fireEvent.change(document.querySelector('textarea')!, { target: { value: 'At the door' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Continue to secure payment' }));
+ expect(await screen.findByTestId('payment-element')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Back' }));
+ await screen.findByRole('button', { name: 'Continue to secure payment' });
+ fireEvent.click(screen.getByRole('button', { name: 'Continue to secure payment' }));
+ await screen.findByTestId('payment-element');
+ fireEvent.click(screen.getByRole('button', { name: 'Pay and place order' }));
+ expect(await screen.findByText('Order placed')).toBeTruthy();
+ expect(screen.getByText('public_1')).toBeTruthy();
+ });
+
+ it('supports delivery cash capture and reports quote, prepare, and capture errors', async () => {
+ const fetch = initialFetch();
+ vi.stubGlobal('fetch', fetch);
+ const Checkout = await loadCheckout('pk_test_fixture');
+ const { container } = render( );
+ await screen.findByText('Home');
+ fireEvent.change(screen.getByLabelText('Payment method'), { target: { value: 'cash' } });
+ fetch.mockImplementationOnce(() => json({ error: 'No courier.' }, 409));
+ fireEvent.click(screen.getByRole('button', { name: 'Calculate delivery' }));
+ expect((await screen.findByRole('status')).textContent).toContain('No courier.');
+ fetch.mockImplementationOnce(() => json({ quote: { id: 'quote_1', amount: 500 } }));
+ fireEvent.click(screen.getByRole('button', { name: 'Calculate delivery' }));
+ await screen.findByText('$5.00');
+ fetch.mockImplementationOnce(() => json({ error: 'Prepare failed.' }, 500));
+ fireEvent.submit(container.querySelector('form')!);
+ expect((await screen.findByRole('status')).textContent).toContain('Prepare failed.');
+ fetch.mockImplementationOnce(() => json({ token: 'checkout_1' }));
+ fetch.mockImplementationOnce(() => json({ error: 'Capture failed.' }, 500));
+ fireEvent.submit(container.querySelector('form')!);
+ expect((await screen.findByRole('status')).textContent).toContain('Capture failed.');
+ fetch.mockImplementationOnce(() => json({ token: 'checkout_1' }));
+ fetch.mockImplementationOnce(() => json({ order: { id: 'order_without_public_id' } }));
+ fireEvent.submit(container.querySelector('form')!);
+ expect(await screen.findByText('Order placed')).toBeTruthy();
+ });
+
+ it('covers empty, missing gateway, missing quote, cash pickup, bad Stripe config, and load failure', async () => {
+ const Checkout = await loadCheckout();
+ let fetch = initialFetch({ cart: null, gateways: [] });
+ vi.stubGlobal('fetch', fetch);
+ let view = render( );
+ await screen.findByText(/Your cart is empty/);
+ fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('status')).textContent).toContain('Your cart is empty.');
+ cleanup();
+
+ fetch = initialFetch({ gateways: [] }); vi.stubGlobal('fetch', fetch); view = render( );
+ await screen.findByText('Home'); fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('status')).textContent).toContain('No supported payment method');
+ cleanup();
+
+ fetch = initialFetch(); vi.stubGlobal('fetch', fetch); view = render( );
+ await screen.findByText('Home'); fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('status')).textContent).toContain('Calculate delivery');
+ fireEvent.click(screen.getByLabelText('Pickup order'));
+ fireEvent.change(screen.getByLabelText('Payment method'), { target: { value: 'cash' } });
+ fetch.mockImplementationOnce(() => json({ token: 'checkout_1' }));
+ fetch.mockImplementationOnce(() => json({ order: placedOrder }));
+ fireEvent.submit(view.container.querySelector('form')!);
+ expect(await screen.findByText('Order placed')).toBeTruthy();
+ cleanup();
+
+ fetch = initialFetch(); vi.stubGlobal('fetch', fetch); view = render( );
+ await screen.findByText('Home');
+ fireEvent.click(screen.getByLabelText('Pickup order'));
+ fireEvent.change(screen.getByLabelText('Payment method'), { target: { value: 'stripe' } });
+ fetch.mockImplementationOnce(() => json({ token: 'checkout_1', clientSecret: 'secret_1' }));
+ fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('status')).textContent).toContain('Card payments are not configured');
+ cleanup();
+
+ fetch = initialFetch({ fail: true }); vi.stubGlobal('fetch', fetch); render( );
+ expect((await screen.findByRole('status')).textContent).toContain('Unable to load checkout');
+ cleanup();
+
+ fetch = initialFetch({ gateways: [{ id: 'cash', name: 'Cash' }] }); vi.stubGlobal('fetch', fetch); render( );
+ expect(await screen.findByRole('option', { name: 'Cash' })).toBeTruthy();
+ cleanup();
+
+ fetch = initialFetch(); vi.stubGlobal('fetch', fetch); view = render( );
+ await screen.findByText('Home');
+ fireEvent.click(screen.getByLabelText('Pickup order'));
+ fetch.mockImplementationOnce(() => Promise.reject('non-error'));
+ fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('status')).textContent).toContain('Unable to place order.');
+ });
+});
+
+describe('Stripe payment form boundaries', () => {
+ beforeEach(() => { vi.unstubAllGlobals(); stripeState.stripe = null; stripeState.elements = null; });
+ afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+ it('guards unavailable Stripe and reports confirmation, state, transport, and unknown failures', async () => {
+ const { StripePaymentForm } = await import('@/features/checkout/stripe-payment-form');
+ const captured = vi.fn();
+ const cancelled = vi.fn();
+ const renderForm = () => render( );
+ let view = renderForm();
+ fireEvent.submit(view.container.querySelector('form')!);
+ expect(captured).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: 'Back' }));
+ expect(cancelled).toHaveBeenCalled();
+ cleanup();
+
+ stripeState.elements = {};
+ stripeState.stripe = { confirmPayment: vi.fn().mockResolvedValue({ error: {} }) };
+ view = renderForm(); fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('alert')).textContent).toContain('Payment could not be confirmed.'); cleanup();
+
+ stripeState.stripe = { confirmPayment: vi.fn().mockResolvedValue({ paymentIntent: { status: 'requires_action' } }) };
+ view = renderForm(); fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('alert')).textContent).toContain('Payment is not ready'); cleanup();
+
+ stripeState.stripe = { confirmPayment: vi.fn().mockResolvedValue({ paymentIntent: { status: 'processing' } }) };
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ error: 'Capture unavailable.' }, { status: 503 })));
+ view = renderForm(); fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('alert')).textContent).toContain('Capture unavailable.'); cleanup();
+
+ stripeState.stripe = { confirmPayment: vi.fn().mockResolvedValue({ paymentIntent: { status: 'succeeded' } }) };
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({}, { status: 500 })));
+ view = renderForm(); fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('alert')).textContent).toContain('Unable to place order.'); cleanup();
+
+ stripeState.stripe = { confirmPayment: vi.fn().mockRejectedValue('not-an-error') };
+ view = renderForm(); fireEvent.submit(view.container.querySelector('form')!);
+ expect((await screen.findByRole('alert')).textContent).toContain('Unable to complete payment.');
+ });
+});
diff --git a/tests/client-interactions.test.tsx b/tests/client-interactions.test.tsx
new file mode 100644
index 0000000..17c90ee
--- /dev/null
+++ b/tests/client-interactions.test.tsx
@@ -0,0 +1,273 @@
+// @vitest-environment jsdom
+import React from 'react';
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { AddToCartForm } from '@/features/cart/add-to-cart-form';
+import { CartPageClient } from '@/features/cart/cart-page-client';
+import { LoginPageClient } from '@/features/customer/login-page-client';
+import { SmsLoginPageClient } from '@/features/customer/sms-login-page-client';
+import { RegisterPageClient } from '@/features/customer/register-page-client';
+import { OrdersPageClient } from '@/features/customer/orders-page-client';
+import { AccountDashboardClient } from '@/features/customer/account-dashboard-client';
+import ErrorPage from '@/app/error';
+import type { StorefrontProduct } from '@/lib/types';
+
+const { navigateBrowser } = vi.hoisted(() => ({ navigateBrowser: vi.fn() }));
+vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams('next=/orders') }));
+vi.mock('@/lib/navigation', async (load) => {
+ const actual = await load();
+ return { ...actual, navigateBrowser };
+});
+
+const product: StorefrontProduct = {
+ id: 'prod_1', name: 'Pizza', price: 1000, currency: 'USD', is_available: true,
+ variants: [{ id: 'size', name: 'Size', required: true, options: [{ id: 'large', name: 'Large', price: 200 }] }],
+ addon_categories: [
+ { id: 'required', name: 'Required topping', required: true, multi_select: true, addons: [{ id: 'cheese', name: 'Cheese', price: 50 }] },
+ { id: 'sauce', name: 'Sauce', multi_select: false, addons: [{ id: 'red', name: 'Red' }, { id: 'white', name: 'White' }] }
+ ]
+};
+
+function response(body: unknown, status = 200) {
+ return Promise.resolve(Response.json(body, { status }));
+}
+
+describe('cart customer interactions', () => {
+ beforeEach(() => { navigateBrowser.mockReset(); vi.unstubAllGlobals(); });
+ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
+
+ it('validates required addons, serializes selections, clamps quantity, and reports add failures and success', async () => {
+ const fetch = vi.fn()
+ .mockImplementationOnce(() => response({ error: 'Product changed.' }, 409))
+ .mockImplementationOnce(() => response({ cart: { id: 'cart_1', items: [] } }));
+ vi.stubGlobal('fetch', fetch);
+ render( );
+ fireEvent.change(screen.getByLabelText('Size'), { target: { value: 'large' } });
+ fireEvent.click(screen.getByRole('button', { name: /Add/ }));
+ expect((await screen.findByRole('status')).textContent).toContain('Choose an option for Required topping.');
+ fireEvent.click(screen.getByLabelText(/Cheese/));
+ fireEvent.click(screen.getByLabelText(/Cheese/));
+ fireEvent.click(screen.getByLabelText(/Cheese/));
+ fireEvent.click(screen.getByLabelText('Red'));
+ fireEvent.click(screen.getByLabelText('White'));
+ fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '1000' } });
+ fireEvent.click(screen.getByRole('button', { name: /Add/ }));
+ expect((await screen.findByRole('status')).textContent).toContain('Product changed.');
+ fireEvent.click(screen.getByRole('button', { name: /Add/ }));
+ expect((await screen.findByRole('status')).textContent).toContain('Added to cart.');
+ expect(fetch).toHaveBeenLastCalledWith('/api/storefront/cart/items', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"quantity":999') }));
+ });
+
+ it('covers zero-priced selections, non-array addon state, unavailable items, and unknown add failures', async () => {
+ const changing: StorefrontProduct = {
+ id: 'changing', name: 'Changing', price: 500, is_available: false,
+ variants: [{ id: 'variant', name: 'Variant', options: [{ id: 'free', name: 'Free option' }] }],
+ addon_categories: [{ id: 'choice', name: 'Choice', multi_select: false, addons: [{ id: 'free-addon', name: 'Free addon', price: 0 }] }]
+ };
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue('non-error'));
+ const view = render( );
+ fireEvent.change(screen.getByLabelText('Variant'), { target: { value: 'free' } });
+ fireEvent.click(screen.getByLabelText('Free addon'));
+ expect(screen.getByRole('button', { name: /Add/ }).hasAttribute('disabled')).toBe(true);
+ view.rerender( );
+ fireEvent.click(screen.getByLabelText('Free addon'));
+ fireEvent.click(screen.getByRole('button', { name: /Add/ }));
+ expect((await screen.findByRole('status')).textContent).toContain('Unable to add this product');
+ });
+
+ it('loads, updates, removes, cancels and confirms emptying a cart', async () => {
+ const fullCart = { id: 'cart_1', currency: 'USD', subtotal: 1000, items: [{ id: 'line_1', product_id: 'prod_1', name: 'Pizza', description: 'Hot', quantity: 1, subtotal: 1000 }] };
+ const fetch = vi.fn()
+ .mockImplementationOnce(() => response({ cart: fullCart }))
+ .mockImplementationOnce(() => response({ cart: fullCart }))
+ .mockImplementationOnce(() => response({ cart: { ...fullCart, items: [] } }))
+ .mockImplementationOnce(() => response({ cart: { ...fullCart, items: [] } }));
+ vi.stubGlobal('fetch', fetch);
+ const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true);
+ render( );
+ expect(await screen.findByText('Pizza')).toBeTruthy();
+ fireEvent.blur(screen.getByLabelText('Qty'), { target: { value: '2' } });
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
+ fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza' }));
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
+ cleanup();
+ fetch.mockReset().mockImplementationOnce(() => response({ cart: fullCart })).mockImplementationOnce(() => response({ cart: null }));
+ render( );
+ await screen.findByText('Pizza');
+ fireEvent.click(screen.getByRole('button', { name: 'Empty cart' }));
+ expect(confirm).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole('button', { name: 'Empty cart' }));
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
+ });
+
+ it('shows cart loading errors', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockImplementationOnce(() => response({ error: 'Cart unavailable.', cart: null }, 500)));
+ render( );
+ expect(await screen.findByText('Cart unavailable.')).toBeTruthy();
+ });
+
+ it('handles password login failure and successful safe navigation', async () => {
+ const fetch = vi.fn().mockImplementationOnce(() => response({ error: 'No match.' }, 401)).mockImplementationOnce(() => response({ customer: { id: 'customer_1' } }));
+ vi.stubGlobal('fetch', fetch);
+ render( );
+ fireEvent.change(screen.getByLabelText('Email or phone'), { target: { value: 'test@example.com' } });
+ fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
+ expect((await screen.findByRole('status')).textContent).toContain('No match.');
+ fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
+ await waitFor(() => expect(navigateBrowser).toHaveBeenCalledWith('/orders'));
+ });
+
+ it('requests and verifies SMS codes across failure and success states', async () => {
+ const fetch = vi.fn()
+ .mockImplementationOnce(() => response({ error: 'Try later.' }, 429))
+ .mockImplementationOnce(() => response({ status: 'OK' }))
+ .mockImplementationOnce(() => response({ customer: { id: 'customer_1' } }));
+ vi.stubGlobal('fetch', fetch);
+ render( );
+ fireEvent.change(screen.getByLabelText('Phone number'), { target: { value: '+15555550100' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Send code' }));
+ expect((await screen.findByRole('status')).textContent).toContain('Try later.');
+ fireEvent.click(screen.getByRole('button', { name: 'Send code' }));
+ expect(await screen.findByLabelText('Verification code')).toBeTruthy();
+ fireEvent.change(screen.getByLabelText('Verification code'), { target: { value: '123456' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Verify code' }));
+ await waitFor(() => expect(navigateBrowser).toHaveBeenCalledWith('/orders'));
+ });
+
+ it('reports registration errors, requests email and SMS registration, then creates an account', async () => {
+ const fetch = vi.fn()
+ .mockImplementationOnce(() => response({ error: 'Already registered.' }, 409))
+ .mockImplementationOnce(() => response({ status: 'OK' }))
+ .mockImplementationOnce(() => response({ customer: { id: 'customer_1' } }));
+ vi.stubGlobal('fetch', fetch);
+ render( );
+ fireEvent.change(screen.getByLabelText('Email or phone'), { target: { value: 'test@example.com' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Send verification code' }));
+ expect((await screen.findByRole('status')).textContent).toContain('Already registered.');
+ fireEvent.click(screen.getByRole('button', { name: 'Send verification code' }));
+ expect(await screen.findByLabelText('Verification code')).toBeTruthy();
+ fireEvent.change(screen.getByLabelText('Verification code'), { target: { value: '123456' } });
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Test Customer' } });
+ fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Create account' }));
+ await waitFor(() => expect(navigateBrowser).toHaveBeenCalledWith('/orders'));
+ expect(fetch.mock.calls[1]?.[1]?.body).toContain('"mode":"email"');
+ cleanup();
+ fetch.mockReset().mockImplementationOnce(() => response({ status: 'OK' }));
+ render( );
+ fireEvent.change(screen.getByLabelText('Email or phone'), { target: { value: '+15555550100' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Send verification code' }));
+ await screen.findByLabelText('Verification code');
+ expect(fetch.mock.calls[0]?.[1]?.body).toContain('"mode":"sms"');
+ });
+
+ it('loads order history and all account panels including error states', async () => {
+ const order = { id: 'order_1', public_id: 'order_public', status: 'created', created_at: '2026-01-01T00:00:00Z', total: 1000, currency: 'USD' };
+ const fetch = vi.fn()
+ .mockImplementationOnce(() => response({ orders: [order] }))
+ .mockImplementationOnce(() => response({ customer: { id: 'customer_1', name: 'Ada', email: 'ada@example.com' } }))
+ .mockImplementationOnce(() => response({ orders: [order] }))
+ .mockImplementationOnce(() => response({ places: [{ id: 'place_1', name: 'Home', street1: '1 Main', city: 'Town' }] }))
+ .mockImplementationOnce(() => response({ customer: { id: 'customer_1', name: 'Ada Lovelace', email: 'ada@example.com', phone: '+15555550100' } }))
+ .mockImplementationOnce(() => response({}, 500));
+ vi.stubGlobal('fetch', fetch);
+ render( );
+ expect(await screen.findByText('order_public')).toBeTruthy();
+ cleanup();
+ render( );
+ expect(await screen.findByText(/Welcome back, Ada/)).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Orders' }));
+ expect(screen.getByText('order_public')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Addresses' }));
+ expect(screen.getByText('1 Main, Town')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Profile' }));
+ expect((within(screen.getByRole('heading', { name: 'Profile' }).closest('.account-panel')!).getByLabelText('Email') as HTMLInputElement).value).toBe('ada@example.com');
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ada Lovelace' } });
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ada.lovelace@example.com' } });
+ fireEvent.change(screen.getByLabelText('Phone'), { target: { value: '+15555550100' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Save profile' }));
+ expect((await screen.findByRole('status')).textContent).toContain('Profile updated.');
+ fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
+ expect(await screen.findByText('Unable to sign out.')).toBeTruthy();
+ });
+
+ it('renders empty account states, profile fallbacks, successful logout, and the interactive error boundary', async () => {
+ const fetch = vi.fn()
+ .mockImplementationOnce(() => response({ customer: null }))
+ .mockImplementationOnce(() => response({ orders: [] }))
+ .mockImplementationOnce(() => response({ places: [] }))
+ .mockImplementationOnce(() => response({ ok: true }));
+ vi.stubGlobal('fetch', fetch);
+ render( );
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
+ fireEvent.click(screen.getByRole('button', { name: 'Orders' }));
+ expect(screen.getByText('No orders yet')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Addresses' }));
+ expect(screen.getByText('No saved addresses')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Profile' }));
+ expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('');
+ expect((screen.getByLabelText('Email') as HTMLInputElement).value).toBe('');
+ fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(navigateBrowser).toHaveBeenCalledWith('/login'));
+ cleanup();
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const reset = vi.fn();
+ render( );
+ await waitFor(() => expect(error).toHaveBeenCalledWith('[storefront-web] page request failed', { digest: 'digest_1', name: 'Error' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
+ expect(reset).toHaveBeenCalled();
+ });
+
+ it('renders account API errors and minimal order/address fallback values', async () => {
+ const fetch = vi.fn()
+ .mockImplementationOnce(() => response({ customer: { id: 'customer_1' } }))
+ .mockImplementationOnce(() => response({ orders: [{ id: 'minimal_order' }], error: 'Orders unavailable.' }))
+ .mockImplementationOnce(() => response({ places: [{ id: 'raw_place', address: 'Raw address' }, { id: 'empty_place' }], error: 'Places unavailable.' }));
+ vi.stubGlobal('fetch', fetch);
+ render( );
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
+ fireEvent.click(screen.getByRole('button', { name: 'Orders' }));
+ expect(screen.getByText('Orders unavailable.')).toBeTruthy();
+ expect(screen.getByText(/Pending/)).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Addresses' }));
+ expect(screen.getByText('Places unavailable.')).toBeTruthy();
+ expect(screen.getAllByText('Raw address').length).toBeGreaterThan(0);
+ expect(screen.getByText('Saved address')).toBeTruthy();
+ expect(screen.getByText('Address details unavailable')).toBeTruthy();
+ });
+
+ it('guards concurrent profile saves and reports bounded and unknown failures', async () => {
+ let resolveUpdate!: (value: Response) => void;
+ const pendingUpdate = new Promise((resolve) => { resolveUpdate = resolve; });
+ const fetch = vi.fn()
+ .mockImplementationOnce(() => response({ customer: { id: 'customer_1', name: 'Ada', email: 'ada@example.com' } }))
+ .mockImplementationOnce(() => response({ orders: [] }))
+ .mockImplementationOnce(() => response({ places: [] }))
+ .mockImplementationOnce(() => pendingUpdate)
+ .mockImplementationOnce(() => Promise.reject('network failure'));
+ vi.stubGlobal('fetch', fetch);
+ render( );
+ await screen.findByText(/Welcome back, Ada/);
+ fireEvent.click(screen.getByRole('button', { name: 'Profile' }));
+ const form = screen.getByRole('button', { name: 'Save profile' }).closest('form')!;
+ fireEvent.submit(form);
+ fireEvent.submit(form);
+ expect(fetch).toHaveBeenCalledTimes(4);
+ resolveUpdate(Response.json({}, { status: 500 }));
+ expect((await screen.findByRole('status')).textContent).toContain('Unable to update your profile.');
+ fireEvent.submit(form);
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(5));
+ expect((await screen.findByRole('status')).textContent).toContain('Unable to update your profile.');
+
+ cleanup();
+ vi.stubGlobal('fetch', vi.fn()
+ .mockImplementationOnce(() => response({ customer: null }))
+ .mockImplementationOnce(() => response({ orders: [] }))
+ .mockImplementationOnce(() => response({ places: [] })));
+ render( );
+ await waitFor(() => expect(screen.getByText('Your account')).toBeTruthy());
+ fireEvent.click(screen.getByRole('button', { name: 'Profile' }));
+ expect(screen.getByText('Customer details are unavailable.')).toBeTruthy();
+ });
+});
diff --git a/tests/component-rendering.test.tsx b/tests/component-rendering.test.tsx
new file mode 100644
index 0000000..3e83606
--- /dev/null
+++ b/tests/component-rendering.test.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it, vi } from 'vitest';
+import { CategoryPills } from '@/components/catalog/category-pills';
+import { ProductCard } from '@/components/catalog/product-card';
+import { ProductDetail } from '@/components/catalog/product-detail';
+import { ProductGrid } from '@/components/catalog/product-grid';
+import { StoreDirectory } from '@/components/catalog/store-directory';
+import { Footer } from '@/components/layout/footer';
+import { Header } from '@/components/layout/header';
+import { SignInPrompt } from '@/components/auth/sign-in-prompt';
+import { StatusMessage } from '@/components/auth/status-message';
+import { StoreHero } from '@/components/store-hero';
+import { ThemeScript } from '@/components/theme-script';
+import { PluginProvider } from '@/plugins/provider';
+import type { StorefrontProduct } from '@/lib/types';
+
+vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams() }));
+
+const product: StorefrontProduct = {
+ id: 'prod_1', name: 'Coffee', description: 'Fresh coffee', price: 1000, sale_price: 800,
+ currency: 'USD', is_on_sale: true, is_service: false, is_available: true,
+ primary_image_url: 'https://api.fleetbase.io/coffee.jpg', images: ['https://api.fleetbase.io/coffee.jpg', 'https://api.fleetbase.io/side.jpg'],
+ variants: [{ id: 'size', name: 'Size', required: true, options: [{ id: 'large', name: 'Large', price: 100 }] }],
+ addon_categories: [{ id: 'milk', name: 'Milk', required: false, multi_select: false, addons: [{ id: 'oat', name: 'Oat', price: 50 }] }]
+};
+
+describe('presentational storefront components', () => {
+ it('renders branded layout, theme bootstrapping, plugins, and auth states', () => {
+ const about = { id: 'store_1', name: 'Coffee Shop', description: 'Great drinks', logo_url: 'https://api.fleetbase.io/logo.jpg', backdrop_url: 'https://api.fleetbase.io/hero.jpg', email: 'hello@example.com' };
+ const html = renderToStaticMarkup( );
+ expect(html).toContain('Coffee Shop');
+ expect(html).toContain('Great drinks');
+ expect(html).toContain('Primary navigation');
+ expect(renderToStaticMarkup( )).toContain('localStorage');
+ expect(renderToStaticMarkup( )).toContain('Sign in');
+ expect(renderToStaticMarkup( )).toContain('Failed');
+ expect(renderToStaticMarkup( )).toContain('Saved');
+ expect(renderToStaticMarkup( )).toContain('Waiting');
+ expect(renderToStaticMarkup( )).toBe('');
+ });
+
+ it('renders fallback brand and hero states', () => {
+ const html = renderToStaticMarkup(<> >);
+ expect(html).toContain('Storefront Web');
+ expect(html).toContain('ready-to-customize');
+ });
+
+ it('renders categories, products, sale detail options, grids, and stores', () => {
+ expect(renderToStaticMarkup( )).toContain('Drinks');
+ const productCard = renderToStaticMarkup( );
+ expect(productCard).toContain('Coffee');
+ expect(productCard).toContain('href="/products/prod_1"');
+ expect(productCard).not.toContain('/products/coffee');
+ expect(renderToStaticMarkup( )).toContain('Choose an option');
+ expect(renderToStaticMarkup( )).toContain('Coffee');
+ expect(renderToStaticMarkup( )).toContain('Nothing here');
+ expect(renderToStaticMarkup( )).toContain('Merchant');
+ expect(renderToStaticMarkup( )).toBe('');
+ });
+
+ it('renders no optional product or store media without broken elements', () => {
+ const plain = { id: 'prod_2', name: 'Service', price: 0, is_service: true };
+ const primaryOnly = { id: 'prod_3', name: 'Primary', price: 0, primary_image_url: 'https://api.fleetbase.io/primary.jpg' };
+ const html = renderToStaticMarkup(<> >);
+ expect(html).toContain('Service');
+ expect(html).toContain('Plain store');
+ expect(html).not.toContain('undefined');
+ });
+});
diff --git a/tests/customer-ui.test.tsx b/tests/customer-ui.test.tsx
index ecde1ef..ba4bd8b 100644
--- a/tests/customer-ui.test.tsx
+++ b/tests/customer-ui.test.tsx
@@ -8,6 +8,7 @@ import { SmsLoginPageClient } from '@/features/customer/sms-login-page-client';
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
+ replace: vi.fn(),
refresh: vi.fn()
}),
useSearchParams: () => new URLSearchParams()
diff --git a/tests/e2e/mock-fleetbase.mjs b/tests/e2e/mock-fleetbase.mjs
new file mode 100644
index 0000000..2c6e47a
--- /dev/null
+++ b/tests/e2e/mock-fleetbase.mjs
@@ -0,0 +1,56 @@
+import { createServer } from 'node:http';
+
+const product = {
+ id: 'prod_fixture', name: 'Trail Mix', description: 'A deterministic test product.',
+ price: 1299, currency: 'USD', is_available: true,
+ variants: [{ id: 'size', name: 'Size', required: true, options: [{ id: 'regular', name: 'Regular', price: 0 }] }],
+ addon_categories: [{ id: 'extras', name: 'Extras', required: true, multi_select: true, addons: [{ id: 'nuts', name: 'More nuts', price: 100 }] }]
+};
+let cart = { id: 'cart_fixture', items: [], subtotal: 0, currency: 'USD' };
+
+function json(response, status, body) {
+ response.writeHead(status, { 'Content-Type': 'application/json' });
+ response.end(JSON.stringify(body));
+}
+
+createServer((request, response) => {
+ // Drain request bodies so keep-alive sockets can be reused deterministically
+ // across the many API mutations exercised by the browser suite.
+ request.resume();
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1:4010');
+ const path = url.pathname.replace('/storefront/v1/', '');
+ if (url.pathname === '/health') return json(response, 200, { ok: true });
+ if (path === 'about') return json(response, 200, { id: 'store_fixture', name: 'Fixture Store', currency: 'USD', is_store: true, is_network: false, online: true });
+ if (path === 'categories') return json(response, 200, [{ id: 'cat_fixture', name: 'Snacks', slug: 'snacks' }]);
+ if (path === 'products' || path === 'search') return json(response, 200, [product]);
+ if (path === 'products/prod_fixture') return json(response, 200, product);
+ if (path === 'stores') return json(response, 200, []);
+ if (path === 'gateways') return json(response, 200, [{ id: 'gateway_cash', code: 'cash', name: 'Cash on delivery' }]);
+ if (path === 'store-locations') return json(response, 200, [{ id: 'sl_fixture', name: 'Main store', place: { id: 'place_store', name: 'Main store', address: '1 Store Street' } }]);
+ if (path === 'customers/places') return json(response, 200, [{ id: 'place_home', name: 'Home', address: '10 Test Street' }]);
+ if (path === 'customers/orders') return json(response, 200, [{ id: 'order_fixture', public_id: 'order_fixture', status: 'created', currency: 'USD', total: 1399, items: cart.items }]);
+ if (path === 'customers/login' && request.method === 'POST') return json(response, 200, { id: 'customer_fixture', name: 'Test Customer', email: 'test@example.com', token: 'customer_token_fixture' });
+ if (path === 'customers/login-with-sms' && request.method === 'POST') return json(response, 200, { status: 'OK' });
+ if (path === 'customers/verify-code' && request.method === 'POST') return json(response, 200, { id: 'customer_fixture', name: 'Test Customer', token: 'customer_token_fixture' });
+ if (path === 'customers/request-creation-code' && request.method === 'POST') return json(response, 200, { status: 'OK' });
+ if (path === 'customers' && request.method === 'POST') return json(response, 200, { id: 'customer_fixture', name: 'Test Customer', token: 'customer_token_fixture' });
+ if (path === 'customers/customer_fixture' && request.method === 'PUT') return json(response, 200, { id: 'customer_fixture', name: 'Updated Customer', email: 'updated@example.com', phone: '+15555550100' });
+ if (path === 'carts' || path === 'carts/cart_fixture') return json(response, 200, cart);
+ if (path === 'carts/cart_fixture/prod_fixture' && request.method === 'POST') {
+ cart = { ...cart, items: [{ id: 'line_fixture', product_id: product.id, name: product.name, quantity: 1, subtotal: 1399 }], subtotal: 1399 };
+ return json(response, 200, cart);
+ }
+ if (path === 'carts/cart_fixture/line_fixture' && request.method === 'PUT') return json(response, 200, cart);
+ if (path === 'carts/cart_fixture/line_fixture' && request.method === 'DELETE') {
+ cart = { ...cart, items: [], subtotal: 0 };
+ return json(response, 200, cart);
+ }
+ if (path === 'carts/cart_fixture/empty' && request.method === 'PUT') {
+ cart = { ...cart, items: [], subtotal: 0 };
+ return json(response, 200, cart);
+ }
+ if (path.startsWith('service-quotes/from-cart')) return json(response, 200, { id: 'quote_fixture', amount: 500, currency: 'USD' });
+ if (path.startsWith('checkouts/before')) return json(response, 200, { checkout: 'chkt_fixture', token: 'checkout_fixture' });
+ if (path === 'checkouts/capture' && request.method === 'POST') return json(response, 200, { id: 'order_fixture', public_id: 'order_fixture', status: 'created' });
+ return json(response, 404, { message: `Unhandled fixture route: ${request.method} ${path}` });
+}).listen(4010, '127.0.0.1');
diff --git a/tests/e2e/storefront.spec.ts b/tests/e2e/storefront.spec.ts
new file mode 100644
index 0000000..d8b0095
--- /dev/null
+++ b/tests/e2e/storefront.spec.ts
@@ -0,0 +1,155 @@
+import AxeBuilder from '@axe-core/playwright';
+import { expect, test, type Page } from '@playwright/test';
+
+async function addFixtureProduct(page: Page) {
+ await page.goto('/products/prod_fixture');
+ await page.getByLabel('Size').selectOption('regular');
+ await page.getByLabel(/More nuts/).check();
+ await page.getByRole('button', { name: /Add/ }).click();
+ await expect(page.getByRole('status')).toHaveText('Added to cart.', { timeout: 20_000 });
+}
+
+async function passwordLogin(page: Page) {
+ await page.goto('/login');
+ await page.getByLabel(/Email or phone/).fill('test@example.com');
+ await page.getByLabel('Password').fill('valid-password');
+ await page.getByRole('button', { name: 'Sign in' }).click();
+ await expect(page).toHaveURL('/account');
+}
+
+test('browses a product, validates options, and mutates the cart', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByRole('heading', { name: 'Shop products' })).toBeVisible();
+ await page.getByRole('link', { name: 'Trail Mix' }).click();
+ await page.getByLabel('Size').selectOption('regular');
+ await page.getByRole('button', { name: /Add/ }).click();
+ await expect(page.getByRole('status')).toContainText('Choose an option for Extras');
+ await page.getByLabel(/More nuts/).check();
+ await page.getByRole('button', { name: /Add/ }).click();
+ await expect(page.getByRole('status')).toHaveText('Added to cart.');
+ await page.goto('/cart');
+ await expect(page.getByText('Trail Mix')).toBeVisible();
+ await page.getByLabel('Qty').fill('2');
+ await page.getByLabel('Qty').blur();
+ await expect(page.getByLabel('Qty')).toHaveValue('2');
+ await page.getByRole('button', { name: 'Remove Trail Mix' }).click();
+ await expect(page.getByText('Your cart is empty.')).toBeVisible();
+});
+
+test('searches, filters, sorts, and paginates the catalog', async ({ page }) => {
+ await page.goto('/search?q=Trail');
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Trail Mix' })).toBeVisible();
+ await page.getByLabel('Category').selectOption('snacks');
+ await page.getByLabel('Sort').selectOption('name');
+ await page.getByRole('button', { name: 'Apply filters' }).click();
+ await expect(page).toHaveURL(/q=Trail/);
+ await expect(page).toHaveURL(/category=snacks/);
+ await expect(page).toHaveURL(/sort=name/);
+ await page.getByRole('link', { name: 'Next' }).click();
+ await expect(page).toHaveURL(/page=2/);
+});
+
+test('registers a customer and establishes an account session', async ({ page }) => {
+ await page.goto('/register');
+ await page.getByLabel(/Email or phone/).fill('new@example.com');
+ await page.getByRole('button', { name: 'Send verification code' }).click();
+ await page.getByLabel('Verification code').fill('123456');
+ await page.getByLabel('Name').fill('New Customer');
+ await page.getByLabel('Password').fill('valid-password');
+ await page.getByRole('button', { name: 'Create account' }).click();
+ await expect(page).toHaveURL('/account');
+ await expect(page.getByText('Test Customer')).toBeVisible();
+});
+
+test('requests and verifies an SMS login code', async ({ page }) => {
+ await page.goto('/login/sms');
+ await page.getByLabel('Phone number').fill('+15555550100');
+ await page.getByRole('button', { name: 'Send code' }).click();
+ await page.getByLabel('Verification code').fill('123456');
+ await page.getByRole('button', { name: 'Verify code' }).click();
+ await expect(page).toHaveURL('/account');
+ await expect(page.getByText('Test Customer')).toBeVisible();
+});
+
+test('calculates delivery, completes cash checkout, and opens order history', async ({ page }) => {
+ await passwordLogin(page);
+ await addFixtureProduct(page);
+ await page.goto('/checkout');
+ await expect(page.getByLabel('Pickup location')).toHaveValue('place_store');
+ await expect(page.getByLabel('Delivery address')).toHaveValue('place_home');
+ await page.getByRole('button', { name: 'Calculate delivery' }).click();
+ await expect(page.getByText('$5.00')).toBeVisible();
+ await page.getByLabel('Order notes').fill('Leave at the door');
+ await page.getByRole('button', { name: 'Place order' }).click();
+ await expect(page.getByRole('heading', { name: 'Order placed' })).toBeVisible();
+ await page.getByRole('link', { name: 'View order' }).click();
+ await expect(page.getByRole('heading', { name: 'Order order_fixture' })).toBeVisible();
+ await page.goto('/orders');
+ await expect(page.getByText('order_fixture')).toBeVisible();
+});
+
+test('completes cash pickup without requesting a delivery quote', async ({ page }) => {
+ await passwordLogin(page);
+ await addFixtureProduct(page);
+ await page.goto('/checkout');
+ await page.getByLabel('Pickup order').check();
+ await expect(page.getByRole('button', { name: 'Place order' })).toBeEnabled();
+ await page.getByRole('button', { name: 'Place order' }).click();
+ await expect(page.getByRole('heading', { name: 'Order placed' })).toBeVisible();
+});
+
+test('updates the authenticated customer profile', async ({ page }) => {
+ await passwordLogin(page);
+ await expect(page.getByText('Recent order history is available.')).toBeVisible();
+ await page.getByRole('button', { name: 'Profile' }).click();
+ await page.getByLabel('Name').fill('Updated Customer');
+ await page.getByLabel('Email').fill('updated@example.com');
+ await page.getByLabel('Phone').fill('+15555550100');
+ await page.getByRole('button', { name: 'Save profile' }).click();
+ await expect(page.getByRole('status')).toHaveText('Profile updated.');
+});
+
+test('expires the local session and requires reauthentication', async ({ page, context }) => {
+ await passwordLogin(page);
+ await context.clearCookies();
+ await page.goto('/account');
+ await expect(page.getByRole('heading', { name: 'Sign in to view your account' })).toBeVisible();
+ await page.getByRole('link', { name: 'Sign in' }).click();
+ await expect(page).toHaveURL('/login?next=%2Faccount');
+});
+
+test('keeps incomplete marketplace routes disabled', async ({ page }) => {
+ await page.goto('/stores');
+ await expect(page.getByRole('heading', { name: 'Page not found' })).toBeVisible();
+});
+
+test('shows an upstream cart failure and recovers on retry', async ({ page }) => {
+ await page.route('**/api/storefront/cart', (route) => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'Storefront is temporarily unavailable.', cart: null }) }));
+ await page.goto('/cart');
+ await expect(page.getByText('Storefront is temporarily unavailable.')).toBeVisible();
+ await page.unroute('**/api/storefront/cart');
+ await page.reload();
+ await expect(page.getByRole('heading', { name: 'Your cart' })).toBeVisible();
+});
+
+test('logs in and rejects an unsafe return path', async ({ page }) => {
+ await page.goto('/login?next=https://example.com/phish');
+ await page.getByLabel(/Email or phone/).fill('test@example.com');
+ await page.getByLabel('Password').fill('valid-password');
+ const [loginResponse] = await Promise.all([
+ page.waitForResponse((response) => response.url().endsWith('/api/storefront/auth/login')),
+ page.getByRole('button', { name: 'Sign in' }).click()
+ ]);
+ expect(loginResponse.status()).toBe(200);
+ await expect(page).toHaveURL('/account');
+ await expect(page.getByText('Test Customer')).toBeVisible();
+});
+
+test('critical pages have no automatically detectable accessibility violations', async ({ page }) => {
+ for (const path of ['/', '/search', '/login', '/register', '/cart']) {
+ await page.goto(path);
+ const results = await new AxeBuilder({ page }).analyze();
+ expect(results.violations, `${path}: ${results.violations.map((violation) => violation.id).join(', ')}`).toEqual([]);
+ }
+});
diff --git a/tests/format.test.ts b/tests/format.test.ts
index 5a92da7..4e231e8 100644
--- a/tests/format.test.ts
+++ b/tests/format.test.ts
@@ -1,8 +1,14 @@
import { describe, expect, it } from 'vitest';
-import { formatMoney } from '@/lib/format';
+import { compact, effectiveProductPrice, formatMoney, productPrice } from '@/lib/format';
describe('formatMoney', () => {
it('formats minor units as currency', () => {
expect(formatMoney(1299, 'USD')).toContain('12.99');
+ expect(formatMoney()).toContain('0.00');
+ expect(productPrice({ price: 1000, sale_price: 500, is_on_sale: true, currency: 'USD' })).toContain('5.00');
+ expect(productPrice({ price: 1000, sale_price: 0, is_on_sale: false, currency: 'USD' })).toContain('10.00');
+ expect(productPrice({})).toContain('0.00');
+ expect(effectiveProductPrice({ price: 1000, sale_price: null, is_on_sale: true })).toBe(1000);
+ expect(compact([1, null, undefined, 2])).toEqual([1, 2]);
});
});
diff --git a/tests/health-route.test.ts b/tests/health-route.test.ts
index ede6547..925cba0 100644
--- a/tests/health-route.test.ts
+++ b/tests/health-route.test.ts
@@ -5,8 +5,10 @@ describe('health route', () => {
it('reports configured service health', async () => {
const previousKey = process.env.STOREFRONT_KEY;
const previousHost = process.env.FLEETBASE_HOST;
+ const previousSiteUrl = process.env.NEXT_PUBLIC_SITE_URL;
process.env.STOREFRONT_KEY = 'store_test';
process.env.FLEETBASE_HOST = 'https://api.example.com';
+ process.env.NEXT_PUBLIC_SITE_URL = 'https://shop.example.com';
const response = await GET();
const payload = await response.json();
@@ -14,11 +16,21 @@ describe('health route', () => {
expect(response.status).toBe(200);
expect(payload).toMatchObject({
ok: true,
- service: 'storefront-web',
- fleetbaseHost: 'https://api.example.com'
+ service: 'storefront-web'
});
+ expect(payload).not.toHaveProperty('fleetbaseHost');
process.env.STOREFRONT_KEY = previousKey;
process.env.FLEETBASE_HOST = previousHost;
+ process.env.NEXT_PUBLIC_SITE_URL = previousSiteUrl;
+ });
+
+ it('reports unhealthy service configuration without exposing values', async () => {
+ const previousKey = process.env.STOREFRONT_KEY;
+ delete process.env.STOREFRONT_KEY;
+ const response = await GET();
+ expect(response.status).toBe(503);
+ expect(await response.json()).toMatchObject({ ok: false, missing: expect.arrayContaining(['STOREFRONT_KEY']) });
+ process.env.STOREFRONT_KEY = previousKey;
});
});
diff --git a/tests/lib-hardening.test.ts b/tests/lib-hardening.test.ts
new file mode 100644
index 0000000..d9086c6
--- /dev/null
+++ b/tests/lib-hardening.test.ts
@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { z } from 'zod';
+
+const headerValues = new Map();
+const cookieValues = new Map();
+
+vi.mock('next/headers', () => ({
+ headers: vi.fn(async () => ({ get: (name: string) => headerValues.get(name) ?? null })),
+ cookies: vi.fn(async () => ({ get: (name: string) => cookieValues.has(name) ? { value: cookieValues.get(name) } : undefined }))
+}));
+
+describe('API input and error boundaries', () => {
+ it('accepts valid bounded JSON and rejects declared, actual, malformed, and invalid payloads', async () => {
+ const { readJsonBody, RequestValidationError } = await import('@/lib/api');
+ const schema = z.object({ name: z.string().min(2) });
+ await expect(readJsonBody(new Request('https://shop.test', { method: 'POST', body: '{"name":"ok"}' }), schema)).resolves.toEqual({ name: 'ok' });
+ await expect(readJsonBody(new Request('https://shop.test', { method: 'POST', headers: { 'content-length': '99' }, body: '{}' }), schema, 5)).rejects.toMatchObject({ status: 413 });
+ await expect(readJsonBody(new Request('https://shop.test', { method: 'POST', body: '{"name":"large"}' }), schema, 5)).rejects.toBeInstanceOf(RequestValidationError);
+ await expect(readJsonBody(new Request('https://shop.test', { method: 'POST', body: '{' }), schema)).rejects.toThrow('valid JSON');
+ await expect(readJsonBody(new Request('https://shop.test', { method: 'POST', body: '{"name":"x"}' }), schema)).rejects.toThrow();
+ const issueLessSchema = { safeParse: () => ({ success: false, error: { issues: [] } }) } as unknown as typeof schema;
+ await expect(readJsonBody(new Request('https://shop.test', { method: 'POST', body: '{}' }), issueLessSchema)).rejects.toThrow('Request body is invalid.');
+ });
+
+ it('enforces delivery quote and customer cookie boundaries', async () => {
+ const { checkoutPrepareInputSchema } = await import('@/lib/contracts');
+ expect(checkoutPrepareInputSchema.safeParse({ gateway: 'cash', cart: 'cart_1' }).success).toBe(false);
+ expect(checkoutPrepareInputSchema.safeParse({ gateway: 'cash', cart: 'cart_1', pickup: true }).success).toBe(true);
+ const { setCustomerCookies } = await import('@/lib/customer-session');
+ const response = (await import('next/server')).NextResponse.json({ ok: true });
+ expect(setCustomerCookies(response)).toBeUndefined();
+ setCustomerCookies(response, { id: 'customer_1' });
+ expect(response.cookies.get('customer_id')?.value).toBe('customer_1');
+ });
+
+ it('normalizes validation, upstream, and unknown failures without leaking upstream text', async () => {
+ const { apiError, RequestValidationError } = await import('@/lib/api');
+ const { UpstreamError } = await import('@/lib/http');
+ const validation = apiError(new RequestValidationError('Bad field.', 422), 'Fallback');
+ expect(validation.status).toBe(422);
+ expect(await validation.json()).toEqual({ error: 'Bad field.' });
+ const upstream = apiError(new UpstreamError(401, 'sensitive upstream response'), 'Unable to authenticate.', 500);
+ expect(upstream.status).toBe(401);
+ expect(await upstream.json()).toEqual({ error: 'Unable to authenticate.' });
+ expect(apiError(new UpstreamError(503, 'secret'), 'Unavailable.', 502).status).toBe(502);
+ expect(apiError(new Error('secret'), 'Safe failure.', 500).status).toBe(500);
+ });
+
+ it('resolves proxy IP headers in precedence order', async () => {
+ const { getRequestIp } = await import('@/lib/api');
+ expect(getRequestIp(new Request('https://shop.test', { headers: { 'x-forwarded-for': '1.2.3.4, 5.6.7.8', 'x-real-ip': '9.9.9.9' } }))).toBe('1.2.3.4');
+ expect(getRequestIp(new Request('https://shop.test', { headers: { 'x-real-ip': '9.9.9.9' } }))).toBe('9.9.9.9');
+ expect(getRequestIp(new Request('https://shop.test'))).toBe('unknown');
+ });
+});
+
+describe('HTTP response and navigation contracts', () => {
+ it('parses success and all supported error envelopes', async () => {
+ const { parseJsonResponse, UpstreamError } = await import('@/lib/http');
+ await expect(parseJsonResponse(Response.json({ ok: true }))).resolves.toEqual({ ok: true });
+ await expect(parseJsonResponse(Response.json({ message: 'message error' }, { status: 400 }))).rejects.toMatchObject({ status: 400, publicMessage: 'message error' });
+ await expect(parseJsonResponse(Response.json({ error: 'error envelope' }, { status: 409 }))).rejects.toMatchObject({ publicMessage: 'error envelope' });
+ await expect(parseJsonResponse(new Response('not json', { status: 500 }))).rejects.toBeInstanceOf(UpstreamError);
+ });
+
+ it('permits relative same-origin paths and rejects external, protocol-relative, backslash, invalid, and empty redirects', async () => {
+ const { navigateBrowser, safeRedirectPath } = await import('@/lib/navigation');
+ expect(safeRedirectPath('/orders?page=2#latest')).toBe('/orders?page=2#latest');
+ expect(safeRedirectPath(null)).toBe('/account');
+ expect(safeRedirectPath('https://evil.test')).toBe('/account');
+ expect(safeRedirectPath('//evil.test')).toBe('/account');
+ expect(safeRedirectPath('/orders\\evil')).toBe('/account');
+ expect(safeRedirectPath('/orders\nadmin', '/fallback')).toBe('/fallback');
+ const assign = vi.fn();
+ navigateBrowser('/orders', { assign });
+ expect(assign).toHaveBeenCalledWith('/orders');
+ });
+});
+
+describe('rate limiting', () => {
+ beforeEach(async () => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ const { resetMemoryRateLimits } = await import('@/lib/rate-limit');
+ resetMemoryRateLimits();
+ });
+ afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
+
+ it('enforces the local fixed window and exposes standard headers', async () => {
+ const { checkRateLimit, rateLimitHeaders } = await import('@/lib/rate-limit');
+ const options = { namespace: 'login', identifier: 'ip', limit: 1, windowMs: 60_000 };
+ const first = await checkRateLimit(options);
+ const second = await checkRateLimit(options);
+ expect(first.allowed).toBe(true);
+ expect(second).toMatchObject({ allowed: false, remaining: 0 });
+ expect(rateLimitHeaders(second)).toEqual(expect.objectContaining({ 'RateLimit-Limit': '1', 'RateLimit-Remaining': '0' }));
+ });
+
+ it('uses a shared service, defaults omitted counters, and fails closed when unavailable', async () => {
+ const { checkRateLimit } = await import('@/lib/rate-limit');
+ vi.stubEnv('RATE_LIMIT_REST_URL', 'https://limits.test/check');
+ vi.stubEnv('RATE_LIMIT_REST_TOKEN', 'secret');
+ const fetch = vi.fn().mockResolvedValueOnce(Response.json({ allowed: true })).mockResolvedValueOnce(new Response(null, { status: 503 }));
+ vi.stubGlobal('fetch', fetch);
+ await expect(checkRateLimit({ namespace: 'sms', identifier: 'ip', limit: 2, windowMs: 10_000 })).resolves.toMatchObject({ allowed: true, remaining: 0 });
+ expect(fetch).toHaveBeenCalledWith('https://limits.test/check', expect.objectContaining({ method: 'POST', cache: 'no-store' }));
+ await expect(checkRateLimit({ namespace: 'sms', identifier: 'ip', limit: 2, windowMs: 10_000 })).rejects.toThrow('unavailable');
+ });
+});
+
+describe('runtime and Fleetbase server transport', () => {
+ beforeEach(() => { headerValues.clear(); cookieValues.clear(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
+ afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
+
+ it('validates URLs and production HTTPS while handling invalid image hosts', async () => {
+ const { validateServerEnv, getAllowedImageHosts } = await import('@/lib/runtime');
+ expect(validateServerEnv({ NODE_ENV: 'production', STOREFRONT_KEY: 'store_x', FLEETBASE_HOST: 'http://api.test', NEXT_PUBLIC_SITE_URL: 'not a url' })).toMatchObject({ ok: false, invalid: ['FLEETBASE_HOST', 'NEXT_PUBLIC_SITE_URL'] });
+ expect(getAllowedImageHosts({ FLEETBASE_HOST: 'bad url' })).toEqual(['api.fleetbase.io', 'localhost', '127.0.0.1']);
+ });
+
+ it('requires named variables without returning their values in the error', async () => {
+ const { requireEnv } = await import('@/lib/config');
+ vi.stubEnv('TEST_REQUIRED_VALUE', 'present');
+ expect(requireEnv('TEST_REQUIRED_VALUE')).toBe('present');
+ expect(() => requireEnv('MISSING_REQUIRED_VALUE')).toThrow('MISSING_REQUIRED_VALUE');
+ });
+
+ it('constructs authenticated bounded requests with query, locale, body, and schema parsing', async () => {
+ vi.stubEnv('STOREFRONT_KEY', 'store_secret');
+ vi.stubEnv('FLEETBASE_HOST', 'https://api.test/base');
+ vi.stubEnv('FLEETBASE_REQUEST_TIMEOUT_MS', '2500');
+ headerValues.set('accept-language', 'mn-MN,mn;q=0.9');
+ headerValues.set('x-request-id', 'request_fixture');
+ cookieValues.set('customer_token', 'customer_secret');
+ const fetch = vi.fn(async (request: Request) => {
+ expect(request.url).toBe('https://api.test/storefront/v1/example?page=2&enabled=false');
+ expect(request.method).toBe('POST');
+ expect(request.headers.get('Authorization')).toBe('Bearer store_secret');
+ expect(request.headers.get('Customer-Token')).toBe('customer_secret');
+ expect(request.headers.get('Accept-Language')).toBe('mn-MN');
+ expect(request.headers.get('X-Request-ID')).toBe('request_fixture');
+ expect(await request.clone().json()).toEqual({ value: 1 });
+ return Response.json({ ok: true });
+ });
+ vi.stubGlobal('fetch', fetch);
+ const { storefrontRequest } = await import('@/lib/fleetbase/server');
+ await expect(storefrontRequest('example', { method: 'POST', body: { value: 1 }, query: { page: 2, ignored: '', enabled: false, absent: null }, authenticated: true, schema: z.object({ ok: z.literal(true) }) })).resolves.toEqual({ ok: true });
+ });
+
+ it('omits customer credentials for public calls and creates default request metadata', async () => {
+ vi.stubEnv('STOREFRONT_KEY', 'store_secret');
+ const fetch = vi.fn(async (request: Request) => {
+ expect(request.headers.has('Customer-Token')).toBe(false);
+ expect(request.headers.get('Accept-Language')).toBe('en');
+ expect(request.headers.get('X-Request-ID')).toBeTruthy();
+ return Response.json({ ok: true });
+ });
+ vi.stubGlobal('fetch', fetch);
+ const { storefrontRequest } = await import('@/lib/fleetbase/server');
+ await storefrontRequest('public', { customerToken: null, cache: 'force-cache' });
+ await storefrontRequest('public', { next: { revalidate: 1 } });
+ });
+});
diff --git a/tests/middleware.test.ts b/tests/middleware.test.ts
new file mode 100644
index 0000000..2afd901
--- /dev/null
+++ b/tests/middleware.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+import { middleware } from '@/middleware';
+
+describe('security middleware', () => {
+ afterEach(() => vi.unstubAllEnvs());
+
+ it('adds production security headers, API cache controls, and private-page indexing controls', () => {
+ vi.stubEnv('NODE_ENV', 'production');
+ vi.stubEnv('NEXT_PUBLIC_IMAGE_HOSTS', ' images.test, cdn.test ,, ');
+ vi.stubEnv('FLEETBASE_HOST', 'https://fleetbase.test');
+ const response = middleware(new NextRequest('https://shop.test/api/storefront/cart'));
+ expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
+ expect(response.headers.get('X-Frame-Options')).toBe('DENY');
+ expect(response.headers.get('Cache-Control')).toBe('no-store');
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex, nofollow');
+ const csp = response.headers.get('Content-Security-Policy') ?? '';
+ expect(csp).toContain('images.test cdn.test');
+ expect(csp).toContain('https://fleetbase.test');
+ expect(csp).not.toContain("'unsafe-eval'");
+ });
+
+ it('allows development evaluation and leaves public pages indexable and cacheable', () => {
+ vi.stubEnv('NODE_ENV', 'development');
+ vi.stubEnv('NEXT_PUBLIC_IMAGE_HOSTS', '');
+ vi.stubEnv('FLEETBASE_HOST', '');
+ const response = middleware(new NextRequest('https://shop.test/products/prod_1'));
+ expect(response.headers.has('Cache-Control')).toBe(false);
+ expect(response.headers.has('X-Robots-Tag')).toBe(false);
+ expect(response.headers.get('Content-Security-Policy')).toContain("'unsafe-eval'");
+ expect(response.headers.get('Content-Security-Policy')).toContain('https://api.fleetbase.io');
+ });
+
+ it('uses the unconfigured image-host default path', () => {
+ vi.stubEnv('NODE_ENV', 'test');
+ delete process.env.NEXT_PUBLIC_IMAGE_HOSTS;
+ expect(middleware(new NextRequest('https://shop.test/')).headers.get('Content-Security-Policy')).toContain("img-src 'self' data: blob: https:");
+ });
+});
diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts
new file mode 100644
index 0000000..eb9215f
--- /dev/null
+++ b/tests/next-config.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+type ImagePattern = { protocol: 'http' | 'https'; hostname: string };
+const nextConfigPath: string = '../next.config.mjs';
+
+async function loadImageHosts() {
+ const configExports = await import(nextConfigPath) as { imageHosts: () => ImagePattern[] };
+ return configExports.imageHosts;
+}
+
+describe('Next image host configuration', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('allows Fleetbase assets, configured hosts, the API host, and local development images', async () => {
+ vi.stubEnv('NODE_ENV', 'development');
+ vi.stubEnv('FLEETBASE_HOST', 'https://fleetbase.test');
+ vi.stubEnv('NEXT_PUBLIC_IMAGE_HOSTS', 'cdn.test, images.test, cdn.test, ');
+ const imageHosts = await loadImageHosts();
+ const config = (await import(nextConfigPath) as { default: { allowedDevOrigins: string[] } }).default;
+
+ expect(config.allowedDevOrigins).toEqual(['127.0.0.1']);
+ expect(imageHosts()).toEqual([
+ { protocol: 'https', hostname: 'flb-assets.s3.ap-southeast-1.amazonaws.com' },
+ { protocol: 'https', hostname: 'cdn.test' },
+ { protocol: 'https', hostname: 'images.test' },
+ { protocol: 'https', hostname: 'fleetbase.test' },
+ { protocol: 'http', hostname: 'localhost' },
+ { protocol: 'http', hostname: '127.0.0.1' }
+ ]);
+ });
+
+ it('falls back safely for an invalid API host and excludes local hosts in production', async () => {
+ vi.stubEnv('NODE_ENV', 'production');
+ vi.stubEnv('FLEETBASE_HOST', 'not a url');
+ vi.stubEnv('NEXT_PUBLIC_IMAGE_HOSTS', '');
+ const imageHosts = await loadImageHosts();
+
+ expect(imageHosts()).toEqual([
+ { protocol: 'https', hostname: 'flb-assets.s3.ap-southeast-1.amazonaws.com' },
+ { protocol: 'https', hostname: 'api.fleetbase.io' }
+ ]);
+ });
+
+ it('uses safe defaults when optional image and API host settings are absent', async () => {
+ vi.stubEnv('NODE_ENV', 'production');
+ delete process.env.FLEETBASE_HOST;
+ delete process.env.NEXT_PUBLIC_IMAGE_HOSTS;
+ const imageHosts = await loadImageHosts();
+
+ expect(imageHosts()).toEqual([
+ { protocol: 'https', hostname: 'flb-assets.s3.ap-southeast-1.amazonaws.com' },
+ { protocol: 'https', hostname: 'api.fleetbase.io' }
+ ]);
+ });
+});
diff --git a/tests/page-rendering.test.tsx b/tests/page-rendering.test.tsx
new file mode 100644
index 0000000..1b88a8b
--- /dev/null
+++ b/tests/page-rendering.test.tsx
@@ -0,0 +1,189 @@
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import config from '@/storefront.config';
+
+const cookieValues = new Map();
+const notFound = vi.hoisted(() => vi.fn(() => { throw new Error('NEXT_NOT_FOUND'); }));
+const api = vi.hoisted(() => ({
+ getStorefrontAbout: vi.fn(), getCategories: vi.fn(), getProducts: vi.fn(), getProduct: vi.fn(),
+ getNetworkStores: vi.fn(), searchProducts: vi.fn(), getCustomerOrder: vi.fn()
+}));
+
+vi.mock('next/headers', () => ({ cookies: vi.fn(async () => ({ get: (name: string) => cookieValues.has(name) ? { value: cookieValues.get(name) } : undefined })) }));
+vi.mock('next/navigation', () => ({ notFound }));
+vi.mock('@/lib/fleetbase/storefront', () => api);
+
+const about = { id: 'store_1', name: 'Test Store', description: 'Store description', is_store: true, is_network: false };
+const category = { id: 'cat_1', name: 'Snacks', slug: 'snacks', description: 'Snack category' };
+const product = { id: 'prod_1', name: 'Trail Mix', description: 'Good food', price: 1000, currency: 'USD', updated_at: '2026-01-01T00:00:00Z' };
+const order = { id: 'order_1', public_id: 'order_public', status: 'dispatched', tracking_number: 'TRACK1', created_at: '2026-01-01T00:00:00Z', subtotal: 1000, total: 1200, currency: 'USD', items: [{ id: 'line_1', product_id: 'prod_1', name: 'Trail Mix', quantity: 1, subtotal: 1000 }] };
+
+const html = (node: React.ReactNode) => renderToStaticMarkup(node);
+
+describe('App Router page behavior', () => {
+ beforeEach(() => {
+ cookieValues.clear(); notFound.mockClear(); config.features.marketplace = false;
+ api.getStorefrontAbout.mockResolvedValue(about);
+ api.getCategories.mockResolvedValue([category]);
+ api.getProducts.mockResolvedValue([product]);
+ api.getProduct.mockResolvedValue(product);
+ api.getNetworkStores.mockResolvedValue([{ id: 'merchant_1', name: 'Merchant' }]);
+ api.searchProducts.mockResolvedValue([product]);
+ api.getCustomerOrder.mockResolvedValue(order);
+ });
+ afterEach(() => { vi.clearAllMocks(); config.features.marketplace = false; });
+
+ it('renders metadata, root layout, loading, errors, not-found, and robots', async () => {
+ const layout = await import('@/app/layout');
+ expect((await layout.generateMetadata()).title).toBeTruthy();
+ expect(html(await layout.default({ children: Page child
}))).toContain('Page child');
+ api.getStorefrontAbout.mockRejectedValueOnce(new Error('offline'));
+ expect((await layout.generateMetadata()).description).toContain('Fleetbase');
+ const Loading = (await import('@/app/loading')).default;
+ const NotFound = (await import('@/app/not-found')).default;
+ const ErrorPage = (await import('@/app/error')).default;
+ expect(html( )).toContain('Loading storefront');
+ expect(html( )).toContain('Page not found');
+ expect(html( )).toContain('Try again');
+ const robots = (await import('@/app/robots')).default();
+ expect(robots.rules).toMatchObject({ disallow: expect.arrayContaining(['/checkout']) });
+ });
+
+ it('renders the single-store home and the disabled network warning', async () => {
+ const Home = (await import('@/app/page')).default;
+ expect(html(await Home())).toContain('Shop products');
+ api.getStorefrontAbout.mockResolvedValueOnce({ ...about, is_network: true, is_store: false });
+ expect(html(await Home())).toContain('Marketplace ordering is not enabled');
+ });
+
+ it('renders marketplace directory branches only when explicitly enabled', async () => {
+ config.features.marketplace = true;
+ const Home = (await import('@/app/page')).default;
+ expect(html(await Home())).toContain('Merchant');
+ const Stores = (await import('@/app/stores/page')).default;
+ api.getStorefrontAbout.mockResolvedValueOnce({ ...about, is_network: true, is_store: false });
+ expect(html(await Stores())).toContain('Marketplace stores');
+ expect(html(await Stores())).toContain('Browse storefronts');
+ const Store = (await import('@/app/stores/[id]/page')).default;
+ expect(html(await Store({ params: Promise.resolve({ id: 'merchant_1' }) }))).toContain('Products from Merchant');
+ api.getNetworkStores.mockResolvedValueOnce([]);
+ await expect(Store({ params: Promise.resolve({ id: 'missing' }) })).rejects.toThrow('NEXT_NOT_FOUND');
+ config.features.marketplace = false;
+ await expect(Stores()).rejects.toThrow('NEXT_NOT_FOUND');
+ });
+
+ it('renders category, search, and product metadata/detail paths', async () => {
+ const Category = (await import('@/app/categories/[slug]/page')).default;
+ expect(html(await Category({ params: Promise.resolve({ slug: 'snacks' }), searchParams: Promise.resolve({ page: '2', sort: 'name' }) }))).toContain('Snack category');
+ api.getCategories.mockResolvedValueOnce([]);
+ await expect(Category({ params: Promise.resolve({ slug: 'missing' }), searchParams: Promise.resolve({}) })).rejects.toThrow('NEXT_NOT_FOUND');
+ const Search = (await import('@/app/search/page')).default;
+ expect(html(await Search({ searchParams: Promise.resolve({ q: 'trail', page: '2', category: 'snacks', sort: 'name' }) }))).toContain('Results for');
+ expect(html(await Search({ searchParams: Promise.resolve({ tag: 'healthy', page: 'bad' }) }))).toContain('Page 1');
+ const Product = await import('@/app/products/[id]/page');
+ expect(await Product.generateMetadata({ params: Promise.resolve({ id: 'prod_1' }) })).toMatchObject({
+ title: 'Trail Mix',
+ alternates: { canonical: '/products/prod_1' }
+ });
+ expect(html(await Product.default({ params: Promise.resolve({ id: 'prod_1' }) }))).toContain('Trail Mix');
+ api.getProduct.mockRejectedValue(new Error('missing'));
+ expect((await Product.generateMetadata({ params: Promise.resolve({ id: 'missing' }) })).title).toBe('Product not found');
+ await expect(Product.default({ params: Promise.resolve({ id: 'missing' }) })).rejects.toThrow('NEXT_NOT_FOUND');
+ });
+
+ it('renders auth wrappers, cart, and checkout pages', async () => {
+ const modules = await Promise.all([
+ import('@/app/login/page'), import('@/app/login/sms/page'), import('@/app/register/page'), import('@/app/cart/page'), import('@/app/checkout/page')
+ ]);
+ for (const pageModule of modules) {
+ const Page = pageModule.default;
+ expect(() => html( )).not.toThrow();
+ }
+ });
+
+ it('protects account and orders then renders authenticated order details and tracking', async () => {
+ const Account = (await import('@/app/account/page')).default;
+ const Orders = (await import('@/app/orders/page')).default;
+ const Detail = (await import('@/app/orders/[id]/page')).default;
+ const Tracking = (await import('@/app/orders/[id]/tracking/page')).default;
+ expect(html(await Account())).toContain('Sign in to view your account');
+ expect(html(await Orders())).toContain('Sign in to view your orders');
+ expect(html(await Detail({ params: Promise.resolve({ id: 'order_1' }) }))).toContain('Sign in to view this order');
+ expect(html(await Tracking({ params: Promise.resolve({ id: 'order_1' }) }))).toContain('Sign in to track');
+ cookieValues.set('customer_token', 'token'); cookieValues.set('customer_id', 'customer_1'); cookieValues.set('customer_name', 'Ada');
+ expect(html(await Account())).toContain('Ada');
+ expect(html(await Orders())).toContain('Orders');
+ expect(html(await Detail({ params: Promise.resolve({ id: 'order_1' }) }))).toContain('Track order');
+ expect(html(await Tracking({ params: Promise.resolve({ id: 'order_1' }) }))).toContain('TRACK1');
+ api.getCustomerOrder.mockResolvedValueOnce({ id: 'order_empty' });
+ expect(html(await Detail({ params: Promise.resolve({ id: 'order_empty' }) }))).toContain('Order details');
+ api.getCustomerOrder.mockResolvedValueOnce({ id: 'order_empty' });
+ expect(html(await Tracking({ params: Promise.resolve({ id: 'order_empty' }) }))).toContain('Tracking will appear here');
+ api.getCustomerOrder.mockRejectedValue(new Error('missing'));
+ await expect(Detail({ params: Promise.resolve({ id: 'missing' }) })).rejects.toThrow('NEXT_NOT_FOUND');
+ await expect(Tracking({ params: Promise.resolve({ id: 'missing' }) })).rejects.toThrow('NEXT_NOT_FOUND');
+ });
+
+ it('paginates sitemap products and conditionally includes stores', async () => {
+ const sitemap = (await import('@/app/sitemap')).default;
+ api.getProducts.mockResolvedValueOnce(Array.from({ length: 100 }, (_, index) => ({ ...product, id: `prod_${index}` }))).mockResolvedValueOnce([{ ...product, slug: 'trail-mix' }]);
+ const productEntries = (await sitemap()).filter((entry) => entry.url.includes('/products/'));
+ expect(productEntries.some((entry) => entry.url.includes('/products/prod_1'))).toBe(true);
+ expect(productEntries.some((entry) => entry.url.includes('/products/trail-mix'))).toBe(false);
+ config.features.marketplace = true;
+ api.getProducts.mockResolvedValueOnce([]);
+ expect((await sitemap()).some((entry) => entry.url.includes('/stores/merchant_1'))).toBe(true);
+ config.features.marketplace = false;
+ api.getProducts.mockResolvedValueOnce([{ ...product, updated_at: undefined }]);
+ api.getCategories.mockResolvedValueOnce([{ id: 'category_without_slug', name: 'Fallback' }]);
+ expect((await sitemap()).some((entry) => entry.url.includes('/categories/category_without_slug'))).toBe(true);
+ });
+
+ it('renders metadata, catalog, account, search, and marketplace fallback branches', async () => {
+ const layout = await import('@/app/layout');
+ api.getStorefrontAbout.mockResolvedValueOnce({ ...about, backdrop_url: 'https://images.test/backdrop.jpg' });
+ expect((await layout.generateMetadata()).openGraph).toMatchObject({ images: ['https://images.test/backdrop.jpg'] });
+
+ const Product = await import('@/app/products/[id]/page');
+ api.getProduct.mockResolvedValueOnce({ ...product, primary_image_url: 'https://images.test/product.jpg' });
+ expect((await Product.generateMetadata({ params: Promise.resolve({ id: 'prod_1' }) })).openGraph).toMatchObject({ images: ['https://images.test/product.jpg'] });
+
+ const Search = (await import('@/app/search/page')).default;
+ api.getCategories.mockResolvedValueOnce([{ id: 'category_without_slug', name: 'Fallback' }]);
+ expect(html(await Search({ searchParams: Promise.resolve({}) }))).toContain('category_without_slug');
+
+ const Category = (await import('@/app/categories/[slug]/page')).default;
+ api.getCategories.mockResolvedValueOnce([{ id: 'cat-id', name: 'Fallback category', products: [product] }]);
+ api.getProducts.mockRejectedValueOnce(new Error('offline'));
+ expect(html(await Category({ params: Promise.resolve({ slug: 'cat-id' }), searchParams: Promise.resolve({ page: 'bad' }) }))).toContain('Trail Mix');
+ api.getCategories.mockResolvedValueOnce([{ id: 'cat-empty', name: 'Empty fallback category' }]);
+ api.getProducts.mockRejectedValueOnce(new Error('offline'));
+ expect(html(await Category({ params: Promise.resolve({ slug: 'cat-empty' }), searchParams: Promise.resolve({}) }))).toContain('No products are available');
+ api.getCategories.mockRejectedValueOnce(new Error('offline'));
+ await expect(Category({ params: Promise.resolve({ slug: 'missing' }), searchParams: Promise.resolve({}) })).rejects.toThrow('NEXT_NOT_FOUND');
+
+ config.features.marketplace = true;
+ const Stores = (await import('@/app/stores/page')).default;
+ api.getStorefrontAbout.mockResolvedValueOnce({ ...about, is_network: false });
+ api.getNetworkStores.mockResolvedValueOnce([]);
+ expect(html(await Stores())).toContain('No stores are available yet');
+ api.getStorefrontAbout.mockRejectedValueOnce(new Error('offline'));
+ api.getNetworkStores.mockResolvedValueOnce([]);
+ expect(html(await Stores())).toContain('Stores ');
+ const Store = (await import('@/app/stores/[id]/page')).default;
+ api.getNetworkStores.mockResolvedValueOnce([{ id: 'merchant_2', slug: 'merchant', name: 'Fallback merchant', phone: '123' }]);
+ api.getProducts.mockRejectedValueOnce(new Error('offline'));
+ expect(html(await Store({ params: Promise.resolve({ id: 'merchant' }) }))).toContain('123');
+ config.features.marketplace = false;
+ await expect(Store({ params: Promise.resolve({ id: 'merchant' }) })).rejects.toThrow('NEXT_NOT_FOUND');
+
+ cookieValues.set('customer_token', 'token');
+ cookieValues.set('customer_name', 'Ada');
+ cookieValues.set('customer_email', 'ada@example.com');
+ cookieValues.set('customer_phone', '123');
+ cookieValues.delete('customer_id');
+ const Account = (await import('@/app/account/page')).default;
+ expect(html(await Account())).toContain('Your account');
+ });
+});
diff --git a/tests/plugins.test.tsx b/tests/plugins.test.tsx
index 53b03f8..eeedb97 100644
--- a/tests/plugins.test.tsx
+++ b/tests/plugins.test.tsx
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Slot } from '@/components/plugin-slot';
import { runAfterOrderPlacedHooks, runBeforeCheckoutCaptureHooks } from '@/plugins/registry';
+import config from '@/storefront.config';
describe('plugin registry', () => {
afterEach(() => {
@@ -23,7 +24,7 @@ describe('plugin registry', () => {
await expect(runBeforeCheckoutCaptureHooks(payload)).resolves.toBe(payload);
await runAfterOrderPlacedHooks({ id: 'order_test' });
- expect(info).toHaveBeenCalledWith('[storefront-web] order placed', { id: 'order_test' });
+ expect(info).toHaveBeenCalledWith('[storefront-web] order placed', { event: 'order.placed', orderId: 'order_test', status: undefined });
});
it('posts analytics events to a configured webhook', async () => {
@@ -37,8 +38,66 @@ describe('plugin registry', () => {
'https://analytics.example.com/events',
expect.objectContaining({
method: 'POST',
- body: JSON.stringify({ event: 'order.placed', order: { id: 'order_test' } })
+ body: JSON.stringify({ event: 'order.placed', orderId: 'order_test' })
})
);
});
+
+ it('renders optional message plugins and isolates throwing custom hooks', async () => {
+ vi.stubEnv('NEXT_PUBLIC_CHECKOUT_MESSAGE', 'Delivery terms apply');
+ vi.stubEnv('NEXT_PUBLIC_PROMO_BANNER', 'Weekend sale');
+ expect(renderToStaticMarkup( )).toContain('Delivery terms apply');
+ expect(renderToStaticMarkup( )).toContain('Weekend sale');
+ const custom = {
+ id: 'failure-fixture', name: 'Failure fixture',
+ serverHooks: {
+ beforeCheckoutCapture: vi.fn(async (payload: unknown) => ({ payload })),
+ afterOrderPlaced: vi.fn(async () => { throw 'non-error'; })
+ }
+ };
+ config.plugins.push(custom);
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ try {
+ await expect(runBeforeCheckoutCaptureHooks('input')).resolves.toEqual({ payload: 'input' });
+ await expect(runAfterOrderPlacedHooks({ id: 'order_1' })).resolves.toContainEqual({ pluginId: 'failure-fixture', message: 'Hook failed.' });
+ expect(error).toHaveBeenCalledWith('[storefront-web] afterOrderPlaced hook failed: failure-fixture');
+ } finally {
+ config.plugins.pop();
+ }
+ });
+
+ it('rejects duplicate IDs, runs transport hooks, reports Error failures, and fails closed on webhook errors', async () => {
+ const registry = await import('@/plugins/registry');
+ const duplicate = config.plugins[0]!;
+ config.plugins.push(duplicate);
+ try {
+ expect(() => registry.getPlugins()).toThrow('Duplicate Storefront plugin id');
+ } finally {
+ config.plugins.pop();
+ }
+
+ const requestHook = vi.fn(async (request: Request) => new Request(request, { headers: { 'x-hook': 'yes' } }));
+ const responseHook = vi.fn(async () => new Response('hooked', { status: 201 }));
+ const failure = {
+ id: 'transport-fixture', name: 'Transport fixture',
+ serverHooks: {
+ beforeFleetbaseRequest: requestHook,
+ afterFleetbaseResponse: responseHook,
+ afterOrderPlaced: vi.fn(async () => { throw new Error('fixture failed'); })
+ }
+ };
+ config.plugins.push(failure);
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ try {
+ expect((await registry.runBeforeFleetbaseRequestHooks(new Request('https://api.test'))).headers.get('x-hook')).toBe('yes');
+ expect((await registry.runAfterFleetbaseResponseHooks(new Response())).status).toBe(201);
+ await expect(registry.runAfterOrderPlacedHooks({})).resolves.toContainEqual({ pluginId: 'transport-fixture', message: 'fixture failed' });
+ } finally {
+ config.plugins.pop();
+ }
+
+ vi.stubEnv('STOREFRONT_ANALYTICS_WEBHOOK_URL', 'https://analytics.example.com/events');
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 500 })));
+ await expect(runAfterOrderPlacedHooks(null)).resolves.toContainEqual({ pluginId: 'analytics', message: 'Analytics webhook returned 500.' });
+ });
});
diff --git a/tests/route-boundaries.test.ts b/tests/route-boundaries.test.ts
new file mode 100644
index 0000000..fd95e6e
--- /dev/null
+++ b/tests/route-boundaries.test.ts
@@ -0,0 +1,159 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const cookieValues = new Map();
+const allowed = vi.hoisted(() => vi.fn(async () => ({ allowed: true, limit: 10, remaining: 9, resetAt: Date.now() + 1000 })));
+const hooks = vi.hoisted(() => ({
+ before: vi.fn(async (value: unknown) => value),
+ after: vi.fn(async (): Promise> => [])
+}));
+
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(async () => ({ get: (name: string) => cookieValues.has(name) ? { value: cookieValues.get(name) } : undefined })),
+ headers: vi.fn(async () => ({ get: () => 'en' }))
+}));
+vi.mock('@/lib/rate-limit', async (load) => ({ ...(await load()), checkRateLimit: allowed }));
+vi.mock('@/plugins/registry', async (load) => ({ ...(await load()), runBeforeCheckoutCaptureHooks: hooks.before, runAfterOrderPlacedHooks: hooks.after }));
+vi.mock('@/lib/fleetbase/storefront', () => ({
+ addCartItem: vi.fn(), updateCartItem: vi.fn(), removeCartItem: vi.fn(), emptyCart: vi.fn(), getCart: vi.fn(),
+ getGateways: vi.fn(), getStoreLocations: vi.fn(), getCustomerOrders: vi.fn(), getCustomerOrder: vi.fn(), getCustomerPlaces: vi.fn(),
+ getServiceQuoteFromCart: vi.fn(), beforeCheckout: vi.fn(), captureCheckout: vi.fn(),
+ loginCustomer: vi.fn(), requestSmsLogin: vi.fn(), verifyCustomerCode: vi.fn(), requestCustomerCreationCode: vi.fn(), createCustomer: vi.fn(), updateCustomer: vi.fn()
+}));
+
+describe('local route failure and mutation boundaries', () => {
+ beforeEach(() => { cookieValues.clear(); vi.clearAllMocks(); allowed.mockResolvedValue({ allowed: true, limit: 10, remaining: 9, resetAt: Date.now() + 1000 }); hooks.before.mockImplementation(async (value) => value); hooks.after.mockResolvedValue([]); });
+ afterEach(() => vi.restoreAllMocks());
+
+ it('rate limits every authentication mutation', async () => {
+ allowed.mockResolvedValue({ allowed: false, limit: 10, remaining: 0, resetAt: Date.now() + 1000 });
+ const request = (body: unknown) => new Request('http://localhost/auth', { method: 'POST', body: JSON.stringify(body) });
+ const login = await import('@/app/api/storefront/auth/login/route');
+ const sms = await import('@/app/api/storefront/auth/sms/route');
+ const register = await import('@/app/api/storefront/auth/register/route');
+ expect((await login.POST(request({ identity: 'a@b.com', password: 'x' }))).status).toBe(429);
+ expect((await sms.POST(request({ phone: '+15555550100' }))).status).toBe(429);
+ expect((await register.POST(request({ identity: 'a@b.com', mode: 'email' }))).status).toBe(429);
+ });
+
+ it('normalizes authentication upstream failures', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ vi.mocked(api.loginCustomer).mockRejectedValue(new Error('secret'));
+ vi.mocked(api.requestSmsLogin).mockRejectedValue(new Error('secret'));
+ vi.mocked(api.requestCustomerCreationCode).mockRejectedValue(new Error('secret'));
+ const request = (body: unknown) => new Request('http://localhost/auth', { method: 'POST', body: JSON.stringify(body) });
+ expect((await (await import('@/app/api/storefront/auth/login/route')).POST(request({ identity: 'a@b.com', password: 'x' }))).status).toBe(401);
+ expect((await (await import('@/app/api/storefront/auth/sms/route')).POST(request({ phone: '+15555550100' }))).status).toBe(400);
+ expect((await (await import('@/app/api/storefront/auth/register/route')).POST(request({ identity: 'a@b.com', mode: 'email' }))).status).toBe(400);
+ });
+
+ it('reports unauthenticated and invalid authenticated sessions and clears invalid cookies', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ const route = await import('@/app/api/storefront/auth/session/route');
+ expect(await (await route.GET()).json()).toEqual({ authenticated: false, customer: null });
+ cookieValues.set('customer_token', 'token'); cookieValues.set('customer_id', 'customer_1');
+ vi.mocked(api.getCustomerPlaces).mockRejectedValue(new Error('expired'));
+ const response = await route.GET();
+ expect(response.status).toBe(401);
+ expect(response.headers.get('set-cookie')).toContain('customer_token=');
+ cookieValues.set('customer_name', 'Ada'); cookieValues.set('customer_email', 'ada@example.com'); cookieValues.set('customer_phone', '123');
+ vi.mocked(api.getCustomerPlaces).mockResolvedValueOnce([]);
+ expect(await (await route.GET()).json()).toMatchObject({ authenticated: true, customer: { name: 'Ada', email: 'ada@example.com', phone: '123' } });
+ });
+
+ it('creates a cart when mutating without a cookie, then updates and removes lines', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ const route = await import('@/app/api/storefront/cart/items/route');
+ const cart = { id: 'cart_1', items: [] };
+ vi.mocked(api.getCart).mockResolvedValue(cart);
+ vi.mocked(api.addCartItem).mockResolvedValue(cart);
+ vi.mocked(api.updateCartItem).mockResolvedValue(cart);
+ vi.mocked(api.removeCartItem).mockResolvedValue(cart);
+ const request = (method: string, body: unknown) => new Request('http://localhost/cart', { method, body: JSON.stringify(body) });
+ expect((await route.POST(request('POST', { productId: 'prod_1' }))).status).toBe(200);
+ expect((await route.PUT(request('PUT', { lineItemId: 'line_1', quantity: 2 }))).status).toBe(200);
+ expect((await route.DELETE(request('DELETE', { lineItemId: 'line_1' }))).status).toBe(200);
+ vi.mocked(api.updateCartItem).mockRejectedValueOnce(new Error('failure'));
+ expect((await route.PUT(request('PUT', { lineItemId: 'line_1', quantity: 2 }))).status).toBe(500);
+ vi.mocked(api.removeCartItem).mockRejectedValueOnce(new Error('failure'));
+ expect((await route.DELETE(request('DELETE', { lineItemId: 'line_1' }))).status).toBe(500);
+ vi.mocked(api.getCart).mockRejectedValueOnce(new Error('failure'));
+ expect((await route.POST(request('POST', { productId: 'prod_1' }))).status).toBe(500);
+ });
+
+ it('empties existing carts, handles no cart, and normalizes cart errors', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ const route = await import('@/app/api/storefront/cart/route');
+ expect(await (await route.DELETE()).json()).toEqual({ cart: null });
+ cookieValues.set('cart_id', 'cart_1');
+ vi.mocked(api.emptyCart).mockResolvedValue({ id: 'cart_1', items: [] });
+ expect((await route.DELETE()).status).toBe(200);
+ vi.mocked(api.emptyCart).mockRejectedValueOnce(new Error('failure'));
+ expect((await route.DELETE()).status).toBe(500);
+ vi.mocked(api.getCart).mockRejectedValueOnce(new Error('failure'));
+ expect((await route.GET()).status).toBe(500);
+ });
+
+ it('proxies gateways, store locations, orders, order detail, and quotes with bounded errors', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ vi.mocked(api.getGateways).mockResolvedValue([{ id: 'gateway_1', code: 'cash' }]);
+ vi.mocked(api.getStoreLocations).mockResolvedValue([{ id: 'sl_1', name: 'Main' }]);
+ vi.mocked(api.getCustomerOrders).mockResolvedValue([{ id: 'order_1' }]);
+ vi.mocked(api.getCustomerOrder).mockResolvedValue({ id: 'order_1' });
+ vi.mocked(api.getServiceQuoteFromCart).mockResolvedValue({ id: 'quote_1', amount: 100 });
+ const gateways = await import('@/app/api/storefront/gateways/route');
+ const locations = await import('@/app/api/storefront/store-locations/route');
+ const orders = await import('@/app/api/storefront/orders/route');
+ const order = await import('@/app/api/storefront/orders/[id]/route');
+ const quote = await import('@/app/api/storefront/service-quote/route');
+ expect((await gateways.GET()).status).toBe(200);
+ expect((await locations.GET(new Request('http://localhost/locations?limit=999'))).status).toBe(200);
+ expect((await orders.GET(new Request('http://localhost/orders?page=2&query=test'))).status).toBe(200);
+ expect((await order.GET(new Request('http://localhost/orders/order_1'), { params: Promise.resolve({ id: 'order_1' }) })).status).toBe(200);
+ expect((await quote.POST(new Request('http://localhost/quote', { method: 'POST', body: JSON.stringify({ cart: 'cart_1', origin: 'place_1', destination: 'place_2' }) }))).status).toBe(200);
+ for (const fn of [api.getGateways, api.getStoreLocations, api.getCustomerOrders, api.getCustomerOrder, api.getServiceQuoteFromCart]) vi.mocked(fn as ReturnType).mockRejectedValueOnce(new Error('secret'));
+ expect((await gateways.GET()).status).toBe(500);
+ expect((await locations.GET(new Request('http://localhost/locations'))).status).toBe(500);
+ expect((await orders.GET(new Request('http://localhost/orders'))).status).toBe(401);
+ expect((await order.GET(new Request('http://localhost/orders/missing'), { params: Promise.resolve({ id: 'missing' }) })).status).toBe(404);
+ expect((await quote.POST(new Request('http://localhost/quote', { method: 'POST', body: JSON.stringify({ cart: 'cart_1', origin: 'place_1', destination: 'place_2' }) }))).status).toBe(500);
+ });
+
+ it('returns saved places and normalizes unauthenticated place errors', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ const places = await import('@/app/api/storefront/customer/places/route');
+ vi.mocked(api.getCustomerPlaces).mockResolvedValueOnce([{ id: 'place_1', name: 'Home' }]);
+ expect((await places.GET()).status).toBe(200);
+ vi.mocked(api.getCustomerPlaces).mockRejectedValueOnce(new Error('secret'));
+ expect((await places.GET()).status).toBe(401);
+ });
+
+ it('protects profile updates and validates upstream failures', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ const profile = await import('@/app/api/storefront/customer/profile/route');
+ const request = (body: unknown) => new Request('http://localhost/profile', { method: 'PATCH', body: JSON.stringify(body) });
+ const valid = { name: 'Ada', email: 'ada@example.com', phone: '' };
+ expect((await profile.PATCH(request(valid))).status).toBe(401);
+ cookieValues.set('customer_id', 'customer_1');
+ expect((await profile.PATCH(request({ ...valid, email: 'invalid' }))).status).toBe(400);
+ vi.mocked(api.updateCustomer).mockRejectedValueOnce(new Error('failure'));
+ expect((await profile.PATCH(request(valid))).status).toBe(500);
+ });
+
+ it('requires a customer for checkout and isolates post-order hook warnings', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ const prepare = await import('@/app/api/storefront/checkout/prepare/route');
+ const capture = await import('@/app/api/storefront/checkout/capture/route');
+ const prepareRequest = () => new Request('http://localhost/prepare', { method: 'POST', body: JSON.stringify({ gateway: 'stripe', cart: 'cart_1', pickup: true }) });
+ expect((await prepare.POST(prepareRequest())).status).toBe(401);
+ cookieValues.set('customer_id', 'customer_1');
+ vi.mocked(api.beforeCheckout).mockRejectedValueOnce(new Error('failure'));
+ expect((await prepare.POST(prepareRequest())).status).toBe(500);
+ hooks.before.mockResolvedValue({ token: 'checkout_token' });
+ vi.mocked(api.captureCheckout).mockResolvedValue({ id: 'order_1' });
+ hooks.after.mockResolvedValue([{ pluginId: 'analytics', message: 'failed' }]);
+ const response = await capture.POST(new Request('http://localhost/capture', { method: 'POST', body: JSON.stringify({ token: 'checkout_token' }) }));
+ expect(response.headers.get('X-Storefront-Hook-Warnings')).toBe('1');
+ vi.mocked(api.captureCheckout).mockRejectedValueOnce(new Error('failure'));
+ expect((await capture.POST(new Request('http://localhost/capture', { method: 'POST', body: JSON.stringify({ token: 'checkout_token' }) }))).status).toBe(500);
+ });
+});
diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts
index ccb494e..51097c9 100644
--- a/tests/runtime.test.ts
+++ b/tests/runtime.test.ts
@@ -8,14 +8,16 @@ describe('runtime helpers', () => {
});
it('validates required server env without exposing secrets', () => {
- expect(validateServerEnv({ FLEETBASE_HOST: 'https://api.example.com', STOREFRONT_KEY: 'store_test' })).toMatchObject({
+ expect(validateServerEnv({ FLEETBASE_HOST: 'https://api.example.com', STOREFRONT_KEY: 'store_test', NEXT_PUBLIC_SITE_URL: 'https://shop.example.com' })).toMatchObject({
ok: true,
missing: []
});
- expect(validateServerEnv({ FLEETBASE_HOST: 'https://api.example.com' })).toMatchObject({
+ expect(validateServerEnv({ FLEETBASE_HOST: 'https://api.example.com', NEXT_PUBLIC_SITE_URL: 'https://shop.example.com' })).toMatchObject({
ok: false,
missing: ['STOREFRONT_KEY']
});
+ expect(validateServerEnv({ STOREFRONT_KEY: 'key' }).values.FLEETBASE_HOST).toBe('https://api.fleetbase.io');
+ expect(validateServerEnv({ STOREFRONT_KEY: 'key', FLEETBASE_HOST: '', NEXT_PUBLIC_SITE_URL: '' }).missing).toEqual(expect.arrayContaining(['NEXT_PUBLIC_SITE_URL']));
});
it('parses configured image hosts and includes Fleetbase host', () => {
@@ -27,5 +29,6 @@ describe('runtime helpers', () => {
NEXT_PUBLIC_IMAGE_HOSTS: 'cdn.example.com'
})
).toEqual(['cdn.example.com', 'api.example.com']);
+ expect(getAllowedImageHosts({ NODE_ENV: 'production' })).toEqual(['api.fleetbase.io']);
});
});
diff --git a/tests/storefront-client.test.ts b/tests/storefront-client.test.ts
new file mode 100644
index 0000000..f9dcd09
--- /dev/null
+++ b/tests/storefront-client.test.ts
@@ -0,0 +1,62 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const request = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/fleetbase/server', () => ({ storefrontRequest: request }));
+
+describe('Storefront API client contract', () => {
+ beforeEach(() => request.mockReset());
+
+ it('maps every public operation to the supported Storefront API route', async () => {
+ const api = await import('@/lib/fleetbase/storefront');
+ request.mockResolvedValue([]);
+ await api.getStorefrontAbout();
+ await api.getProducts({ page: 2 });
+ await api.getProduct('prod_1');
+ await api.getCategories({ limit: 10 });
+ await api.searchProducts('pizza', { page: 1 });
+ await api.getNetworkStores();
+ await api.getGateways();
+ await api.getStoreLocations({ limit: 25 });
+ await api.getCart();
+ await api.getCart('cart_1');
+ await api.addCartItem('cart_1', 'prod_1', { quantity: 2 });
+ await api.updateCartItem('cart_1', 'line_1', { quantity: 3 });
+ await api.removeCartItem('cart_1', 'line_1');
+ await api.emptyCart('cart_1');
+ await api.loginCustomer({ identity: 'a@b.test', password: 'password' });
+ await api.requestSmsLogin('+15555550100');
+ await api.verifyCustomerCode('+15555550100', '123456');
+ await api.requestCustomerCreationCode('a@b.test', 'email');
+ await api.createCustomer({ identity: 'a@b.test', name: 'Ada', password: 'password', code: '123456' });
+ await api.getCustomer('customer_1');
+ await api.updateCustomer('customer_1', { name: 'Ada', email: 'a@b.test', phone: '' });
+ await api.getCustomerPlaces();
+ await api.getCustomerOrders({ page: 3 });
+ await api.getServiceQuoteFromCart({ cart: 'cart_1' });
+ await api.beforeCheckout({ customer: 'customer_1', gateway: 'stripe', cart: 'cart_1', pickup: true });
+ await api.updateStripePaymentIntent({ token: 'checkout_1' });
+ await api.captureCheckout({ token: 'checkout_1' });
+
+ expect(request.mock.calls.map(([path]) => path)).toEqual([
+ 'about', 'products', 'products/prod_1', 'categories', 'search', 'stores', 'gateways', 'store-locations',
+ 'carts', 'carts/cart_1', 'carts/cart_1/prod_1', 'carts/cart_1/line_1', 'carts/cart_1/line_1',
+ 'carts/cart_1/empty', 'customers/login', 'customers/login-with-sms', 'customers/verify-code',
+ 'customers/request-creation-code', 'customers', 'customers/customer_1', 'customers/customer_1', 'customers/places',
+ 'customers/orders', 'service-quotes/from-cart', 'checkouts/before',
+ 'checkouts/stripe-update-payment-intent', 'checkouts/capture'
+ ]);
+ expect(request).toHaveBeenCalledWith('customers/customer_1', expect.objectContaining({ authenticated: true }));
+ expect(request).toHaveBeenCalledWith('customers/customer_1', expect.objectContaining({ method: 'PUT', authenticated: true }));
+ expect(request).toHaveBeenCalledWith('search', expect.objectContaining({ query: { query: 'pizza', page: 1 } }));
+ });
+
+ it('finds orders by internal or public ID and rejects missing orders', async () => {
+ const { getCustomerOrder } = await import('@/lib/fleetbase/storefront');
+ request.mockResolvedValueOnce([{ id: 'order_1', public_id: 'public_1' }]);
+ await expect(getCustomerOrder('order_1')).resolves.toMatchObject({ id: 'order_1' });
+ request.mockResolvedValueOnce([{ id: 'order_1', public_id: 'public_1' }]);
+ await expect(getCustomerOrder('public_1')).resolves.toMatchObject({ public_id: 'public_1' });
+ request.mockResolvedValueOnce([]);
+ await expect(getCustomerOrder('missing')).rejects.toThrow('Order not found');
+ });
+});
diff --git a/tests/storefront-flow.test.ts b/tests/storefront-flow.test.ts
index 5bd626a..6ac5f5b 100644
--- a/tests/storefront-flow.test.ts
+++ b/tests/storefront-flow.test.ts
@@ -14,8 +14,8 @@ describe('mocked storefront customer flow', () => {
beforeEach(() => {
vi.stubEnv('STOREFRONT_KEY', 'store_test');
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = String(input);
- const method = init?.method ?? 'GET';
+ const url = input instanceof Request ? input.url : String(input);
+ const method = input instanceof Request ? input.method : init?.method ?? 'GET';
if (url.endsWith('/storefront/v1/products')) {
return Response.json([{ id: 'prod_1', name: 'Test Product', price: 1299 }]);
@@ -34,7 +34,7 @@ describe('mocked storefront customer flow', () => {
}
if (url.includes('/storefront/v1/checkouts/before')) {
- return Response.json({ token: 'checkout_token' });
+ return Response.json({ checkout: 'chkt_1', token: 'checkout_token' });
}
if (url.endsWith('/storefront/v1/checkouts/capture') && method === 'POST') {
@@ -46,11 +46,11 @@ describe('mocked storefront customer flow', () => {
}
if (url.endsWith('/storefront/v1/customers/verify-code') && method === 'POST') {
- return Response.json({ token: 'customer_token', customer: { id: 'customer_1' } });
+ return Response.json({ id: 'customer_1', token: 'customer_token' });
}
if (url.endsWith('/storefront/v1/customers') && method === 'POST') {
- return Response.json({ token: 'customer_token', customer: { id: 'customer_1' } });
+ return Response.json({ id: 'customer_1', token: 'customer_token' });
}
return Response.json({ message: `Unhandled ${method} ${url}` }, { status: 404 });
@@ -67,10 +67,10 @@ describe('mocked storefront customer flow', () => {
await expect(getProduct('prod_1')).resolves.toMatchObject({ name: 'Test Product' });
await expect(getCart()).resolves.toMatchObject({ id: 'cart_1' });
await expect(addCartItem('cart_1', 'prod_1', { quantity: 1 })).resolves.toMatchObject({ items: [{ id: 'line_1' }] });
- await expect(beforeCheckout({ gateway: 'stripe', cart: 'cart_1', pickup: true })).resolves.toMatchObject({ token: 'checkout_token' });
+ await expect(beforeCheckout({ gateway: 'stripe', customer: 'customer_1', cart: 'cart_1', pickup: true })).resolves.toMatchObject({ token: 'checkout_token' });
await expect(captureCheckout({ token: 'checkout_token' })).resolves.toMatchObject({ id: 'order_1' });
- await expect(requestCustomerCreationCode({ identity: 'test@example.com', mode: 'email' })).resolves.toMatchObject({ ok: true });
- await expect(verifyCustomerCode({ phone: '+15555550100', code: '123456' })).resolves.toMatchObject({ customer: { id: 'customer_1' } });
- await expect(createCustomer({ identity: 'test@example.com', code: '123456' })).resolves.toMatchObject({ customer: { id: 'customer_1' } });
+ await expect(requestCustomerCreationCode('test@example.com', 'email')).resolves.toMatchObject({ ok: true });
+ await expect(verifyCustomerCode('+15555550100', '123456')).resolves.toMatchObject({ id: 'customer_1' });
+ await expect(createCustomer({ identity: 'test@example.com', code: '123456' })).resolves.toMatchObject({ id: 'customer_1' });
});
});
diff --git a/tests/storefront-routes.test.ts b/tests/storefront-routes.test.ts
index cdb8902..e4376a3 100644
--- a/tests/storefront-routes.test.ts
+++ b/tests/storefront-routes.test.ts
@@ -27,6 +27,7 @@ vi.mock('@/lib/fleetbase/storefront', () => ({
requestCustomerCreationCode: vi.fn(),
requestSmsLogin: vi.fn(),
updateCartItem: vi.fn(),
+ updateCustomer: vi.fn(),
verifyCustomerCode: vi.fn()
}));
@@ -72,7 +73,7 @@ describe('storefront route handlers', () => {
it('sets customer cookies on password login', async () => {
const { loginCustomer } = await import('@/lib/fleetbase/storefront');
const { POST } = await import('@/app/api/storefront/auth/login/route');
- vi.mocked(loginCustomer).mockResolvedValue({ token: 'customer_token', customer: { id: 'customer_1', email: 'test@example.com' } });
+ vi.mocked(loginCustomer).mockResolvedValue({ token: 'customer_token', id: 'customer_1', email: 'test@example.com' });
const response = await POST(new Request('http://localhost/api/storefront/auth/login', {
method: 'POST',
@@ -103,7 +104,7 @@ describe('storefront route handlers', () => {
it('sets customer cookies after SMS verification', async () => {
const { verifyCustomerCode } = await import('@/lib/fleetbase/storefront');
const { POST } = await import('@/app/api/storefront/auth/sms/route');
- vi.mocked(verifyCustomerCode).mockResolvedValue({ token: 'customer_token', customer: { id: 'customer_1', phone: '+15555550100' } });
+ vi.mocked(verifyCustomerCode).mockResolvedValue({ token: 'customer_token', id: 'customer_1', phone: '+15555550100' });
const response = await POST(new Request('http://localhost/api/storefront/auth/sms', {
method: 'POST',
@@ -126,13 +127,13 @@ describe('storefront route handlers', () => {
const payload = await response.json();
expect(payload.ok).toBe(true);
- expect(requestCustomerCreationCode).toHaveBeenCalledWith({ identity: 'test@example.com', mode: 'email' });
+ expect(requestCustomerCreationCode).toHaveBeenCalledWith('test@example.com', 'email');
});
it('creates an account and sets customer cookies', async () => {
const { createCustomer } = await import('@/lib/fleetbase/storefront');
const { POST } = await import('@/app/api/storefront/auth/register/route');
- vi.mocked(createCustomer).mockResolvedValue({ token: 'customer_token', customer: { id: 'customer_1', name: 'Test Customer' } });
+ vi.mocked(createCustomer).mockResolvedValue({ token: 'customer_token', id: 'customer_1', name: 'Test Customer' });
const response = await POST(new Request('http://localhost/api/storefront/auth/register', {
method: 'POST',
@@ -181,11 +182,27 @@ describe('storefront route handlers', () => {
expect(payload.places).toEqual([{ id: 'place_1', name: 'Home', address: '100 Main Street' }]);
});
+ it('updates the authenticated customer profile and refreshes session cookies', async () => {
+ const { updateCustomer } = await import('@/lib/fleetbase/storefront');
+ const { PATCH } = await import('@/app/api/storefront/customer/profile/route');
+ cookieValues.set('customer_id', 'customer_1');
+ vi.mocked(updateCustomer).mockResolvedValue({ id: 'customer_1', name: 'Ada Lovelace', email: 'ada@example.com', phone: '+15555550100' });
+
+ const response = await PATCH(new Request('http://localhost/api/storefront/customer/profile', {
+ method: 'PATCH',
+ body: JSON.stringify({ name: 'Ada Lovelace', email: 'ada@example.com', phone: '+15555550100' })
+ }));
+
+ expect(response.status).toBe(200);
+ expect(updateCustomer).toHaveBeenCalledWith('customer_1', expect.objectContaining({ name: 'Ada Lovelace' }));
+ expect(response.headers.get('set-cookie')).toContain('customer_name=Ada%20Lovelace');
+ });
+
it('prepares checkout with the customer cookie fallback', async () => {
const { beforeCheckout } = await import('@/lib/fleetbase/storefront');
const { POST } = await import('@/app/api/storefront/checkout/prepare/route');
cookieValues.set('customer_id', 'customer_1');
- vi.mocked(beforeCheckout).mockResolvedValue({ token: 'checkout_token' });
+ vi.mocked(beforeCheckout).mockResolvedValue({ checkout: 'chkt_1', token: 'checkout_token' });
const response = await POST(new Request('http://localhost/api/storefront/checkout/prepare', {
method: 'POST',
diff --git a/vitest.config.ts b/vitest.config.ts
index 1845aa7..07d4f57 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,8 +1,28 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
+ esbuild: { jsx: 'automatic' },
test: {
- environment: 'node'
+ environment: 'node',
+ testTimeout: 15_000,
+ include: ['tests/**/*.test.{ts,tsx}'],
+ exclude: ['tests/e2e/**'],
+ coverage: {
+ provider: 'v8',
+ include: [
+ 'app/**/*.{ts,tsx}',
+ 'components/**/*.{ts,tsx}',
+ 'features/**/*.{ts,tsx}',
+ 'lib/**/*.{ts,tsx}',
+ 'plugins/**/*.{ts,tsx}',
+ 'middleware.ts',
+ 'next.config.mjs',
+ 'storefront.config.ts'
+ ],
+ exclude: ['**/*.d.ts', 'lib/types.ts', 'plugins/types.ts'],
+ reporter: ['text', 'text-summary', 'html', 'json', 'json-summary', 'lcov'],
+ thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 }
+ }
},
resolve: {
alias: {
From 88289c40c4d1082551977e121d6c01b64033bb7c Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Sun, 30 Aug 2026 21:04:36 +0800
Subject: [PATCH 4/7] Fix container security action pin
---
.github/workflows/docker.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 0d35826..063a3ea 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -21,7 +21,7 @@ jobs:
load: true
push: false
tags: storefront-web:scan
- - uses: aquasecurity/trivy-action@0.33.1
+ - uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: storefront-web:scan
format: sarif
From 69261407168bd98d7665b5d2f767229678cef738 Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Sun, 30 Aug 2026 21:06:14 +0800
Subject: [PATCH 5/7] Refine dependency license policy
---
.github/workflows/dependency-review.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 6b190d0..5a111cd 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -12,4 +12,4 @@ jobs:
- uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
- deny-licenses: GPL-2.0, LGPL-2.0, SSPL-1.0
+ deny-licenses: GPL-2.0-only, GPL-3.0-only, AGPL-3.0-only, SSPL-1.0
From 5d301578cdecfa801bcc4df0cd04dd8c4c425702 Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Sun, 30 Aug 2026 21:07:28 +0800
Subject: [PATCH 6/7] Allow canonical release workflow license
---
.github/workflows/dependency-review.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 5a111cd..cf6befe 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -13,3 +13,4 @@ jobs:
with:
fail-on-severity: high
deny-licenses: GPL-2.0-only, GPL-3.0-only, AGPL-3.0-only, SSPL-1.0
+ allow-dependencies-licenses: pkg:githubactions/fleetbase/fleetbase/.github/workflows/release-tag.yml@main
From 7f5553a2d4a36c4638cdd038b7785662fcec1158 Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Sun, 30 Aug 2026 21:11:03 +0800
Subject: [PATCH 7/7] Record hosted release validation
---
docs/validation.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/validation.md b/docs/validation.md
index bbf4d45..0ad2dd5 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -2,7 +2,7 @@
Use this checklist before cutting `v0.1.0`.
-## Latest local evidence (2026-08-30)
+## Latest local and hosted evidence (2026-08-30)
- Clean `pnpm install --frozen-lockfile`: passed with pnpm 10.15.1.
- ESLint and TypeScript: passed.
@@ -13,10 +13,11 @@ Use this checklist before cutting `v0.1.0`.
- Dependency audit: no known vulnerabilities at `high` severity or above.
- Workflow YAML, Compose configuration, whitespace checks, and a repository credential-pattern scan: passed. The only live-key matches are documented `pk_live_xxx` placeholders.
- The release-candidate container is 247,146,557 bytes and runs as `storefront`. Its configured health endpoint returned 200, missing configuration returned 503 without exposing values, npm/pnpm were absent from the runtime, and Trivy 0.69.3 reported zero HIGH/CRITICAL Alpine or Node findings.
+- GitHub PR #1 passed install reproducibility, lint, typecheck, unit tests, exact coverage, production build, desktop/mobile Playwright, Docker smoke, dependency review, CodeQL, and Trivy. Codecov project and patch checks both passed.
Live validation against the localhost Fleetbase API on Storefront `dev-v0.4.20` confirmed store/catalog loading, Fleetbase asset images, effective sale pricing, product detail lookup by public ID, configured variants/addons, cart creation, add-to-cart, cookie persistence, and the correct cart subtotal. The configured `.env.local` Storefront key did not match a current local database store, so validation used a process-only local key without changing or exposing the file.
-This evidence validates the repository-owned implementation, but it does not authorize a release. GitHub-hosted CI, Codecov ingestion, a deployable demo environment with customer credentials, a real Stripe test-mode payment, and the manual accessibility checks below remain external release gates.
+This evidence validates the repository-owned implementation, but it does not authorize a release. A deployable demo environment with customer credentials, a real Stripe test-mode payment, and the manual accessibility checks below remain external release gates.
## Remaining parity and product validation