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 a00d3e3..db7cf21 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,21 @@ # 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= 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..cf6befe --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,16 @@ +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-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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..063a3ea --- /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@v0.36.0 + 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 72d9c31..6666059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,12 @@ - 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. +- 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/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..0d3cdc0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Fleetbase Storefront Web +[![CI](https://github.com/fleetbase/storefront-web/actions/workflows/ci.yml/badge.svg)](https://github.com/fleetbase/storefront-web/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/fleetbase/storefront-web/branch/main/graph/badge.svg)](https://codecov.io/gh/fleetbase/storefront-web) +[![License: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-blue.svg)](./LICENSE) +[![Next.js](https://img.shields.io/badge/Next.js-15-black.svg)](https://nextjs.org/) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED.svg)](./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. @@ -8,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. @@ -37,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 ``` @@ -55,19 +62,25 @@ 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. | | `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. | +| `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 ``` @@ -75,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 @@ -86,6 +101,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. @@ -121,17 +138,31 @@ Current slots: Current server hooks: +- `beforeFleetbaseRequest(request)` +- `afterFleetbaseResponse(response)` - `beforeCheckoutCapture(payload)` - `afterOrderPlaced(order)` 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 release evidence and [docs/releasing.md](./docs/releasing.md) for the Fleetbase `dev-v*` process. + ## Development ```bash pnpm lint pnpm typecheck pnpm test +pnpm test:coverage +pnpm test:e2e pnpm build ``` @@ -141,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/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/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/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 14166f9..f39fb1a 100644 --- a/app/api/storefront/auth/login/route.ts +++ b/app/api/storefront/auth/login/route.ts @@ -1,18 +1,25 @@ 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()); - if (result.customer?.id) { - response.cookies.set('customer_id', result.customer.id, sessionCookieOptions()); + 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 402f1a9..802d1ab 100644 --- a/app/api/storefront/auth/logout/route.ts +++ b/app/api/storefront/auth/logout/route.ts @@ -1,8 +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'); + clearCustomerCookies(response); 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..fd93238 --- /dev/null +++ b/app/api/storefront/auth/register/route.ts @@ -0,0 +1,31 @@ +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 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 (!('code' in body)) { + const result = await requestCustomerCreationCode(body.identity, body.mode); + return NextResponse.json(result, { headers: rateLimitHeaders(rateLimit) }); + } + + 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 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 new file mode 100644 index 0000000..c477e6b --- /dev/null +++ b/app/api/storefront/auth/session/route.ts @@ -0,0 +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: true, + customer: { + id: customerId, + name: cookieStore.get('customer_name')?.value, + email: cookieStore.get('customer_email')?.value, + phone: cookieStore.get('customer_phone')?.value + } + }); +} diff --git a/app/api/storefront/auth/sms/route.ts b/app/api/storefront/auth/sms/route.ts index dbfa1ab..c0f1792 100644 --- a/app/api/storefront/auth/sms/route.ts +++ b/app/api/storefront/auth/sms/route.ts @@ -1,22 +1,29 @@ 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()); - if (result.customer?.id) { - response.cookies.set('customer_id', result.customer.id, sessionCookieOptions()); - } + 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 new file mode 100644 index 0000000..9461494 --- /dev/null +++ b/app/api/storefront/customer/places/route.ts @@ -0,0 +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 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 new file mode 100644 index 0000000..00e9171 --- /dev/null +++ b/app/api/storefront/orders/[id]/route.ts @@ -0,0 +1,13 @@ +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 { + const { id } = await params; + const order = await getCustomerOrder(id); + return NextResponse.json({ order }); + } catch (error) { + 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/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/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.

+ +
+ ); +} 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/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/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/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/orders/[id]/page.tsx b/app/orders/[id]/page.tsx new file mode 100644 index 0000000..c2bde88 --- /dev/null +++ b/app/orders/[id]/page.tsx @@ -0,0 +1,56 @@ +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) { + 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} +
+ +
+ ); +} diff --git a/app/orders/[id]/tracking/page.tsx b/app/orders/[id]/tracking/page.tsx new file mode 100644 index 0000000..334d406 --- /dev/null +++ b/app/orders/[id]/tracking/page.tsx @@ -0,0 +1,38 @@ +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) { + 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/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/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 ? 'Explore the marketplace' : 'Shop products'}

+

{about?.is_network ? 'Network storefront' : 'Shop products'}

Browse products, services, and local delivery options.

+ {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/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/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 2c31ffe..d4e4907 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(), + query ? searchProducts(query, options) : getProducts(options) + ]); + 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 (
@@ -17,7 +41,34 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi +
+ + + + +
+
); } diff --git a/app/sitemap.ts b/app/sitemap.ts index e03132e..c2852f1 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,13 +1,37 @@ import type { MetadataRoute } from 'next'; -import { getProducts } from '@/lib/fleetbase/storefront'; +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 = await getProducts({ limit: 100 }).catch(() => []); + const [products, categories, stores] = await Promise.all([ + getSitemapProducts(), + getCategories(), + config.features.marketplace ? getNetworkStores() : Promise.resolve([]) + ]); return [ { url: siteUrl, lastModified: new Date() }, { url: `${siteUrl}/search`, 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() + })), + ...stores.map((store) => ({ + url: `${siteUrl}/stores/${store.slug ?? store.id}`, + lastModified: new Date() + })), ...products.map((product) => ({ 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 new file mode 100644 index 0000000..345ae40 --- /dev/null +++ b/app/stores/[id]/page.tsx @@ -0,0 +1,33 @@ +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); + + 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/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/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/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..9544b37 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.id}`; + return (
- +
{product.primary_image_url ? ( {product.name} 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/components/layout/header.tsx b/components/layout/header.tsx index 3f5cef7..f650565 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -13,9 +13,9 @@ export function Header({ about }: { about?: StorefrontAbout | null }) { {about?.logo_url ? : } {about?.name ?? 'Storefront Web'} -
) : !loading ? ( diff --git a/features/checkout/checkout-page-client.tsx b/features/checkout/checkout-page-client.tsx index 2727688..654619a 100644 --- a/features/checkout/checkout-page-client.tsx +++ b/features/checkout/checkout-page-client.tsx @@ -1,163 +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) { - return; + 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); + setStatus(cause instanceof Error ? cause.message : 'Unable to calculate delivery.'); + } finally { + setQuoteLoading(false); } - - 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); } - async function placeOrder(event: React.FormEvent) { + async function prepareOrder(event: React.FormEvent) { event.preventDefault(); + if (loading) return; setLoading(true); - setStatus(null); - + setStatus(''); try { - 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 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.'); + const captured = await captureResponse.json(); + if (!captureResponse.ok) throw new Error(captured.error ?? 'Unable to place order.'); + setOrder(captured.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 (
-
-
-

Checkout

-

Choose delivery or pickup and place your order.

-
- - {!pickup && ( - <> -