@akinon/projectzero 2.0.79-rc.0 → 2.0.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +6 -4
  2. package/app-template/.env.example +10 -1
  3. package/app-template/AGENTS.md +0 -8
  4. package/app-template/CHANGELOG.md +38 -81
  5. package/app-template/README.md +1 -25
  6. package/app-template/next.config.mjs +1 -7
  7. package/app-template/package.json +41 -41
  8. package/app-template/src/app/[pz]/layout.tsx +20 -0
  9. package/app-template/src/app/api/preview/link/route.ts +115 -0
  10. package/app-template/src/app/api/preview/route.ts +96 -0
  11. package/app-template/src/hooks/index.ts +0 -2
  12. package/app-template/src/hooks/use-fav-button.tsx +13 -35
  13. package/app-template/src/plugins.js +0 -1
  14. package/app-template/src/proxy.ts +47 -4
  15. package/app-template/src/settings.js +17 -0
  16. package/app-template/src/utils/__tests__/preview-contract.test.ts +223 -0
  17. package/app-template/src/views/category/category-info.tsx +17 -25
  18. package/app-template/src/views/header/search/index.tsx +5 -13
  19. package/app-template/src/views/product/slider.tsx +38 -85
  20. package/app-template/src/views/product-item/index.tsx +1 -1
  21. package/app-template/tailwind.config.js +2 -0
  22. package/commands/plugins.ts +0 -4
  23. package/dist/commands/plugins.js +0 -4
  24. package/package.json +1 -1
  25. package/app-template/src/app/api/client-env/route.ts +0 -5
  26. package/app-template/src/app/global-error.tsx +0 -22
  27. package/app-template/src/hooks/favourite-product-ids-context.tsx +0 -104
  28. package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +0 -42
  29. package/app-template/src/utils/favourite-product-ids-fallback.ts +0 -51
package/CHANGELOG.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # @akinon/projectzero
2
2
 
3
- ## 2.0.79-rc.0
3
+ ## 2.0.79
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - 143be2b9d: ZERO-3457: Crop styles are customizable and logic improved for rendering similar products modal
8
- - d99a6a7d5: ZERO-3457_1: Fixed the settings prop and made sure everything is customizable.
9
- - e18836b20: ZERO-4160: Restore scope in Sentry addon configuration in akinon.json
7
+ - 945e153d: ZERO-4669: Keep the designer action toolbar reachable at the top of the canvas.
8
+
9
+ The toolbar is portaled to `<body>` (ZERO-4651), so nothing can clip it any more — but hanging above its host still draws it at a negative y when the host sits at the viewport top: the first section at scroll 0, and any section in a chrome slot pinned with `position: sticky; top: 0`, which reports the same box at every scroll position and so was unreachable permanently. The toolbar now flips inside its host when there is not enough room above it on screen, while the portal frame stays pinned to the host's box.
10
+
11
+ Also adds the `@akinon/pz-theme` content globs to the app's Tailwind config: without them the toolbar's own utilities (`-top-0.5`, `top-1`, `-translate-y-full`, `bg-[#4482ff]`) are never generated, so the designer chrome rendered unstyled. The app template carries the same two lines, so scaffolded brand apps are not shipping unstyled designer chrome either.
10
12
 
11
13
  ## 2.0.78
12
14
 
@@ -7,7 +7,6 @@ NEXT_PUBLIC_URL=http://localhost:3000
7
7
  SERVICE_BACKEND_URL=https://02fde10fee4440269e695aa10707dfaf.lb.akinoncloud.com
8
8
  SITEMAP_S3_BUCKET_NAME=0fb534
9
9
  NEXT_PUBLIC_VIRTUAL_TRY_ON_API_URL=https://d2a26507c7094f359aba349b96a96881.lb.akinoncloud.com
10
- NEXT_PUBLIC_ENABLE_IMAGE_SEARCH=true
11
10
 
12
11
  # PZ Dashboard dataLayer observability (unset = observer disabled)
13
12
  # NEXT_PUBLIC_PZ_DASHBOARD_URL=http://localhost:3005
@@ -22,3 +21,13 @@ NEXT_PUBLIC_ENABLE_IMAGE_SEARCH=true
22
21
  # CACHE_KEY_PREFIX=
23
22
 
24
23
  # BF_CACHE=false
24
+
25
+ # Widget preview (theme editor named previews).
26
+ # PREVIEW_SECRET signs the expiring preview links the editor requests from
27
+ # /api/preview/link; it never travels in a url. Unset = preview disabled.
28
+ # OMNITRON_URL is the Omnitron the editor is signed into — the link endpoint
29
+ # verifies the editor's session token there before minting a link.
30
+ # PREVIEW_LINK_TTL_SECONDS: how long a link stays valid (default 86400 = 24h).
31
+ # PREVIEW_SECRET=
32
+ # OMNITRON_URL=https://<omnitron-host>
33
+ # PREVIEW_LINK_TTL_SECONDS=86400
@@ -1,11 +1,3 @@
1
- <!-- BEGIN:nextjs-agent-rules -->
2
-
3
- # Next.js: ALWAYS read docs before coding
4
-
5
- Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth.
6
-
7
- <!-- END:nextjs-agent-rules -->
8
-
9
1
  # Project Zero Next.js - Developer Instructions
10
2
 
11
3
  ## 📚 Available Instruction Files
@@ -1,86 +1,43 @@
1
1
  # projectzeronext
2
2
 
3
- ## 2.0.79-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - 1a8622a9b: ZERO-3392: Implemented AI Search functionality and update env
8
- - 2fea4143: ZERO-4620: Add runtime access to `NEXT_PUBLIC_*` environment variables from Client Components
9
- - b55acb768: ZERO-2577: Fix pagination bug and update usePagination hook and ensure pagination controls rendering correctly
10
- - 143be2b9d: ZERO-3457: Crop styles are customizable and logic improved for rendering similar products modal
11
- - 9f8cd3bc5: ZERO-3449: AI Search Active Filters & Crop Style changes have been implemented
12
- - d99a6a7d5: ZERO-3457_1: Fixed the settings prop and made sure everything is customizable.
13
- - 8a0379e6: ZERO-4845: Implement FavouriteProductIds context and provider; enhance fav button logic and add tests for fallback functionality
14
- - ccf21475d: ZERO-4404: Add Next.js 16.2 AI improvements
15
- - c0228aff0: ZERO-4159: Upgrade the package versions
16
- - b05c35435: ZERO-2570: Category filters routes to absolute url
17
- - e18836b20: ZERO-4160: Restore scope in Sentry addon configuration in akinon.json
18
- - c3244a310: ZERO-4013: Update runtime version to node:25-alpine in project configuration
19
- - Updated dependencies [a3e2acbf8]
20
- - Updated dependencies [86187b1c]
21
- - Updated dependencies [0cf9ea239]
22
- - Updated dependencies [324f97d55]
23
- - Updated dependencies [51ea06888]
24
- - Updated dependencies
25
- - Updated dependencies [c97525a31]
26
- - Updated dependencies [eba125b24]
27
- - Updated dependencies [2fea4143]
28
- - Updated dependencies [119f0c11]
29
- - Updated dependencies [823a4449a]
30
- - Updated dependencies [b55acb768]
31
- - Updated dependencies [760258c1c]
32
- - Updated dependencies [5da13fffc]
33
- - Updated dependencies [143be2b9d]
34
- - Updated dependencies [7889b08fe]
35
- - Updated dependencies [fb4aa9b47]
36
- - Updated dependencies [9f8cd3bc5]
37
- - Updated dependencies [bfafa3f49]
38
- - Updated dependencies [00b9f488f]
39
- - Updated dependencies [57d7eb305]
40
- - Updated dependencies [d99a6a7d5]
41
- - Updated dependencies [9db81a714]
42
- - Updated dependencies [591e345e1]
43
- - Updated dependencies [0df6bc1f8]
44
- - Updated dependencies [4de5303c5]
45
- - Updated dependencies [63a720003]
46
- - Updated dependencies [95b139dc1]
47
- - Updated dependencies [1d00f2d06]
48
- - Updated dependencies [4ac7b2a1e]
49
- - Updated dependencies [e9598c71]
50
- - Updated dependencies [4998a9631]
51
- - Updated dependencies [804d2bd6c]
52
- - Updated dependencies [c0228aff0]
53
- - Updated dependencies [3909d3224]
54
- - Updated dependencies [6a3d8a631]
55
- - Updated dependencies [e18836b20]
56
- - Updated dependencies [b1111d7b]
57
- - @akinon/pz-tabby-extension@2.0.79-rc.0
58
- - @akinon/next@2.0.79-rc.0
59
- - @akinon/pz-checkout-gift-pack@2.0.79-rc.0
60
- - @akinon/pz-masterpass-rest@2.0.79-rc.0
61
- - @akinon/pz-one-click-checkout@2.0.79-rc.0
62
- - @akinon/pz-basket-gift-pack@2.0.79-rc.0
63
- - @akinon/pz-pay-on-delivery@2.0.79-rc.0
64
- - @akinon/pz-credit-payment@2.0.79-rc.0
65
- - @akinon/pz-click-collect@2.0.79-rc.0
66
- - @akinon/pz-multi-basket@2.0.79-rc.0
67
- - @akinon/pz-masterpass@2.0.79-rc.0
68
- - @akinon/pz-gpay@2.0.79-rc.0
69
- - @akinon/pz-b2b@2.0.79-rc.0
70
- - @akinon/pz-bkm@2.0.79-rc.0
71
- - @akinon/pz-otp@2.0.79-rc.0
72
- - @akinon/pz-similar-products@2.0.79-rc.0
73
- - @akinon/pz-virtual-try-on@2.0.79-rc.0
74
- - @akinon/pz-theme@2.0.79-rc.0
75
- - @akinon/pz-akifast@2.0.79-rc.0
76
- - @akinon/pz-apple-pay@2.0.79-rc.0
77
- - @akinon/pz-cybersource-uc@2.0.79-rc.0
78
- - @akinon/pz-flow-payment@2.0.79-rc.0
79
- - @akinon/pz-google-pay@2.0.79-rc.0
80
- - @akinon/pz-haso@2.0.79-rc.0
81
- - @akinon/pz-hepsipay@2.0.79-rc.0
82
- - @akinon/pz-saved-card@2.0.79-rc.0
83
- - @akinon/pz-tamara-extension@2.0.79-rc.0
3
+ ## 2.0.79
4
+
5
+ ### Patch Changes
6
+
7
+ - 945e153d: ZERO-4669: Keep the designer action toolbar reachable at the top of the canvas.
8
+
9
+ The toolbar is portaled to `<body>` (ZERO-4651), so nothing can clip it any more — but hanging above its host still draws it at a negative y when the host sits at the viewport top: the first section at scroll 0, and any section in a chrome slot pinned with `position: sticky; top: 0`, which reports the same box at every scroll position and so was unreachable permanently. The toolbar now flips inside its host when there is not enough room above it on screen, while the portal frame stays pinned to the host's box.
10
+
11
+ Also adds the `@akinon/pz-theme` content globs to the app's Tailwind config: without them the toolbar's own utilities (`-top-0.5`, `top-1`, `-translate-y-full`, `bg-[#4482ff]`) are never generated, so the designer chrome rendered unstyled. The app template carries the same two lines, so scaffolded brand apps are not shipping unstyled designer chrome either.
12
+
13
+ - Updated dependencies [945e153d]
14
+ - @akinon/pz-theme@2.0.79
15
+ - @akinon/next@2.0.79
16
+ - @akinon/pz-akifast@2.0.79
17
+ - @akinon/pz-apple-pay@2.0.79
18
+ - @akinon/pz-b2b@2.0.79
19
+ - @akinon/pz-basket-gift-pack@2.0.79
20
+ - @akinon/pz-bkm@2.0.79
21
+ - @akinon/pz-checkout-gift-pack@2.0.79
22
+ - @akinon/pz-click-collect@2.0.79
23
+ - @akinon/pz-credit-payment@2.0.79
24
+ - @akinon/pz-cybersource-uc@2.0.79
25
+ - @akinon/pz-flow-payment@2.0.79
26
+ - @akinon/pz-google-pay@2.0.79
27
+ - @akinon/pz-gpay@2.0.79
28
+ - @akinon/pz-haso@2.0.79
29
+ - @akinon/pz-hepsipay@2.0.79
30
+ - @akinon/pz-masterpass@2.0.79
31
+ - @akinon/pz-masterpass-rest@2.0.79
32
+ - @akinon/pz-multi-basket@2.0.79
33
+ - @akinon/pz-one-click-checkout@2.0.79
34
+ - @akinon/pz-otp@2.0.79
35
+ - @akinon/pz-pay-on-delivery@2.0.79
36
+ - @akinon/pz-saved-card@2.0.79
37
+ - @akinon/pz-similar-products@2.0.79
38
+ - @akinon/pz-tabby-extension@2.0.79
39
+ - @akinon/pz-tamara-extension@2.0.79
40
+ - @akinon/pz-virtual-try-on@2.0.79
84
41
 
85
42
  ## 2.0.78
86
43
 
@@ -12,30 +12,6 @@ Run `cp .env.example .env && yarn` command at project root and all dependencies
12
12
 
13
13
  - `SITEMAP_S3_BUCKET_NAME`: S3 bucket name for XML sitemaps (e.g., "0fb534"). This is required for the sitemap route to function correctly.
14
14
 
15
- ### Optional Environment Variables
16
-
17
- - `NEXT_PUBLIC_ENABLE_IMAGE_SEARCH`: Set to `true` to enable image search feature for all users. If not set or `false`, the feature will only be visible to users who have enabled it via localStorage.
18
-
19
- ## Image Search Feature
20
-
21
- The image search functionality allows users to search for similar products by uploading images or cropping existing product images.
22
-
23
- ### Feature Visibility Control
24
-
25
- The image search feature can be controlled in two ways:
26
-
27
- 1. **Environment Variable (Global)**: Set `NEXT_PUBLIC_ENABLE_IMAGE_SEARCH=true` to enable for all users
28
- 2. **localStorage (Individual Users)**: Users can enable it locally using browser developer tools
29
-
30
- ````
31
-
32
- ### Feature Locations
33
-
34
- When enabled, the image search feature appears in:
35
-
36
- - **Header Search**: Camera icon next to the search input
37
- - **Product Detail Page**: "View Similar Styles" button on product images
38
-
39
15
  ### Build
40
16
 
41
17
  To build the app, run the following command:
@@ -43,7 +19,7 @@ To build the app, run the following command:
43
19
  ```bash
44
20
  cd projectzeronext # Project root
45
21
  yarn build
46
- ````
22
+ ```
47
23
 
48
24
  ### Develop
49
25
 
@@ -38,9 +38,6 @@ const nextConfig = {
38
38
  ]
39
39
  }
40
40
  ];
41
- },
42
- logging: {
43
- browserToTerminal: 'error'
44
41
  }
45
42
  };
46
43
 
@@ -53,12 +50,9 @@ const withSerwist = withSerwistInit({
53
50
  const sentryConfig = {
54
51
  silent: true,
55
52
  dryRun: !process.env.SENTRY_DSN,
56
- org: 'akinon',
53
+ org: 'akinon'
57
54
  // project: 'enter_your_project_name_here',
58
55
  // authToken: 'enter_your_auth_token_here'
59
- sourcemaps: {
60
- deleteSourcemapsAfterUpload: true
61
- }
62
56
  };
63
57
 
64
58
  const enhancedConfig = withPzConfig(nextConfig);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectzeronext",
3
- "version": "2.0.79-rc.0",
3
+ "version": "2.0.79",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "scripts": {
@@ -24,33 +24,33 @@
24
24
  "test:middleware": "jest middleware-matcher.test.ts --bail"
25
25
  },
26
26
  "dependencies": {
27
- "@akinon/next": "2.0.79-rc.0",
28
- "@akinon/pz-akifast": "2.0.79-rc.0",
29
- "@akinon/pz-apple-pay": "2.0.79-rc.0",
30
- "@akinon/pz-b2b": "2.0.79-rc.0",
31
- "@akinon/pz-basket-gift-pack": "2.0.79-rc.0",
32
- "@akinon/pz-bkm": "2.0.79-rc.0",
33
- "@akinon/pz-checkout-gift-pack": "2.0.79-rc.0",
34
- "@akinon/pz-click-collect": "2.0.79-rc.0",
35
- "@akinon/pz-credit-payment": "2.0.79-rc.0",
36
- "@akinon/pz-cybersource-uc": "2.0.79-rc.0",
37
- "@akinon/pz-flow-payment": "2.0.79-rc.0",
38
- "@akinon/pz-google-pay": "2.0.79-rc.0",
39
- "@akinon/pz-gpay": "2.0.79-rc.0",
40
- "@akinon/pz-haso": "2.0.79-rc.0",
41
- "@akinon/pz-hepsipay": "2.0.79-rc.0",
42
- "@akinon/pz-masterpass": "2.0.79-rc.0",
43
- "@akinon/pz-masterpass-rest": "2.0.79-rc.0",
44
- "@akinon/pz-multi-basket": "2.0.79-rc.0",
45
- "@akinon/pz-one-click-checkout": "2.0.79-rc.0",
46
- "@akinon/pz-otp": "2.0.79-rc.0",
47
- "@akinon/pz-pay-on-delivery": "2.0.79-rc.0",
48
- "@akinon/pz-saved-card": "2.0.79-rc.0",
49
- "@akinon/pz-similar-products": "2.0.79-rc.0",
50
- "@akinon/pz-tabby-extension": "2.0.79-rc.0",
51
- "@akinon/pz-tamara-extension": "2.0.79-rc.0",
52
- "@akinon/pz-theme": "2.0.79-rc.0",
53
- "@akinon/pz-virtual-try-on": "2.0.79-rc.0",
27
+ "@akinon/next": "2.0.79",
28
+ "@akinon/pz-akifast": "2.0.79",
29
+ "@akinon/pz-apple-pay": "2.0.79",
30
+ "@akinon/pz-b2b": "2.0.79",
31
+ "@akinon/pz-basket-gift-pack": "2.0.79",
32
+ "@akinon/pz-bkm": "2.0.79",
33
+ "@akinon/pz-checkout-gift-pack": "2.0.79",
34
+ "@akinon/pz-click-collect": "2.0.79",
35
+ "@akinon/pz-credit-payment": "2.0.79",
36
+ "@akinon/pz-cybersource-uc": "2.0.79",
37
+ "@akinon/pz-flow-payment": "2.0.79",
38
+ "@akinon/pz-google-pay": "2.0.79",
39
+ "@akinon/pz-gpay": "2.0.79",
40
+ "@akinon/pz-haso": "2.0.79",
41
+ "@akinon/pz-hepsipay": "2.0.79",
42
+ "@akinon/pz-masterpass": "2.0.79",
43
+ "@akinon/pz-masterpass-rest": "2.0.79",
44
+ "@akinon/pz-multi-basket": "2.0.79",
45
+ "@akinon/pz-one-click-checkout": "2.0.79",
46
+ "@akinon/pz-otp": "2.0.79",
47
+ "@akinon/pz-pay-on-delivery": "2.0.79",
48
+ "@akinon/pz-saved-card": "2.0.79",
49
+ "@akinon/pz-similar-products": "2.0.79",
50
+ "@akinon/pz-tabby-extension": "2.0.79",
51
+ "@akinon/pz-tamara-extension": "2.0.79",
52
+ "@akinon/pz-theme": "2.0.79",
53
+ "@akinon/pz-virtual-try-on": "2.0.79",
54
54
  "@hookform/resolvers": "2.9.0",
55
55
  "@next/third-parties": "16.2.6",
56
56
  "@react-google-maps/api": "2.17.1",
@@ -59,7 +59,7 @@
59
59
  "next": "16.2.6",
60
60
  "@serwist/next": "9.5.11",
61
61
  "next-auth": "5.0.0-beta.30",
62
- "pino": "8.21.0",
62
+ "pino": "8.11.0",
63
63
  "serwist": "9.5.11",
64
64
  "postcss": "8.4.49",
65
65
  "react": "19.2.5",
@@ -67,14 +67,14 @@
67
67
  "react-google-recaptcha": "2.1.0",
68
68
  "react-hook-form": "7.71.2",
69
69
  "react-intersection-observer": "9.4.0",
70
- "react-multi-carousel": "2.8.5",
71
- "react-string-replace": "1.1.1",
72
- "start-server-and-test": "2.0.8",
73
- "tailwind-merge": "1.14.0",
70
+ "react-multi-carousel": "2.8.4",
71
+ "react-string-replace": "1.1.0",
72
+ "start-server-and-test": "2.0.3",
73
+ "tailwind-merge": "1.8.0",
74
74
  "yup": "0.32.11"
75
75
  },
76
76
  "devDependencies": {
77
- "@akinon/eslint-plugin-projectzero": "2.0.79-rc.0",
77
+ "@akinon/eslint-plugin-projectzero": "2.0.79",
78
78
  "@semantic-release/changelog": "6.0.2",
79
79
  "@semantic-release/exec": "6.0.3",
80
80
  "@semantic-release/git": "10.0.1",
@@ -89,25 +89,25 @@
89
89
  "@types/react": "19.2.14",
90
90
  "@types/react-dom": "19.2.3",
91
91
  "client-only": "0.0.1",
92
- "clsx": "1.2.1",
92
+ "clsx": "1.1.1",
93
93
  "currency-symbol-map": "5.1.0",
94
94
  "eslint": "9.39.4",
95
95
  "eslint-config-next": "16.2.4",
96
96
  "eslint-config-prettier": "10.1.1",
97
97
  "husky": "8.0.0",
98
98
  "jest": "29.7.0",
99
- "jest-css-modules-transform": "4.4.2",
99
+ "jest-css-modules-transform": "4.3.0",
100
100
  "jest-environment-jsdom": "29.7.0",
101
- "lint-staged": "13.3.0",
102
- "prettier": "2.8.8",
101
+ "lint-staged": "13.1.0",
102
+ "prettier": "2.6.2",
103
103
  "react-number-format": "5.3.4",
104
- "sass": "1.83.0",
104
+ "sass": "1.49.9",
105
105
  "semantic-release": "19.0.5",
106
106
  "server-only": "0.0.1",
107
- "stylelint": "14.16.1",
107
+ "stylelint": "14.6.0",
108
108
  "stylelint-config-sass-guidelines": "9.0.1",
109
109
  "stylelint-config-standard": "25.0.0",
110
- "stylelint-scss": "4.7.0",
110
+ "stylelint-scss": "4.2.0",
111
111
  "stylelint-selector-bem-pattern": "2.1.1",
112
112
  "tailwindcss": "4.2.0",
113
113
  "ts-jest": "29.2.6",
@@ -14,6 +14,9 @@ import pwaTags from '@theme/components/pwa-tags';
14
14
  import { OfflineFallback } from '@theme/components/offline-fallback';
15
15
  import { InstallPrompt } from '@theme/components/install-prompt';
16
16
  import type { Viewport } from 'next';
17
+ import { draftMode } from 'next/headers';
18
+ import { PREVIEW_PZ_SEGMENT } from '@akinon/next/utils/preview';
19
+ import { parsePzParams } from '@akinon/next/utils/pz-segments';
17
20
  import settings from '@theme/settings';
18
21
  import RouteHandler from '@theme/components/route-handler';
19
22
  import { PreviewAuthStateReporter } from '@akinon/pz-theme/src/chrome/preview-auth-state';
@@ -55,8 +58,15 @@ export async function generateMetadata() {
55
58
  async function RootLayout({
56
59
  locale,
57
60
  translations,
61
+ params,
58
62
  children
59
63
  }: ResolvedRootLayoutProps) {
64
+ // Widget preview indicator — reading draftMode does NOT opt the page into
65
+ // dynamic rendering; it is only true behind the /api/preview bypass cookie.
66
+ const { isEnabled: isPreview } = await draftMode();
67
+ // Which named preview is being viewed, so the banner says so — several can
68
+ // exist side by side and they look identical otherwise.
69
+ const previewId = parsePzParams(params, settings)[PREVIEW_PZ_SEGMENT] ?? '';
60
70
  // Reconstructed here, above the error boundary, because the boundary is a
61
71
  // client component that can neither fetch nor receive server props — see
62
72
  // pz-theme's error-composition.tsx. Costs one memoized widget read until a
@@ -67,6 +77,16 @@ async function RootLayout({
67
77
  <html lang={locale.isoCode} {...(locale.rtl ? { dir: 'rtl' } : {})}>
68
78
  <head />
69
79
  <body className="overflow-x-hidden">
80
+ {isPreview && (
81
+ <div className="fixed bottom-3 left-1/2 -translate-x-1/2 z-[9999] flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">
82
+ <span>{previewId ? `Preview: ${previewId}` : 'Preview'}</span>
83
+ {/* Relative query-only href: exits preview and returns to the
84
+ SAME page via the middleware ?preview=exit interception. */}
85
+ <a href="?preview=exit" className="underline">
86
+ Exit
87
+ </a>
88
+ </div>
89
+ )}
70
90
  <PzRoot translations={translations} locale={locale.value}>
71
91
  <ClientRoot>
72
92
  <div className="overflow-x-clip">
@@ -0,0 +1,115 @@
1
+ import { isValidPreviewId, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
2
+ import {
3
+ DEFAULT_PREVIEW_LINK_TTL_SECONDS,
4
+ signPreviewToken
5
+ } from '@akinon/next/utils/preview-token';
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+
8
+ /**
9
+ * Mints a signed, expiring preview entry token for the theme editor.
10
+ *
11
+ * The editor is a static SPA on another origin and must never hold the
12
+ * storefront's PREVIEW_SECRET. Instead it proves it is an editor user by
13
+ * forwarding its Omnitron session token; this handler verifies that token
14
+ * against Omnitron (`OMNITRON_URL`) and answers with a token the editor puts
15
+ * on the link as `?preview=<token>`. The link opens exactly one preview and
16
+ * stops working after PREVIEW_LINK_TTL_SECONDS (default 24h).
17
+ *
18
+ * CORS is open on purpose: the request carries no cookies, only the caller's
19
+ * Omnitron token, and the response is worthless without one.
20
+ */
21
+ const CORS_HEADERS = {
22
+ 'Access-Control-Allow-Origin': '*',
23
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
24
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
25
+ 'Access-Control-Max-Age': '600',
26
+ 'Cache-Control': 'no-store'
27
+ };
28
+
29
+ const json = (body: Record<string, unknown>, status: number) =>
30
+ NextResponse.json(body, { status, headers: CORS_HEADERS });
31
+
32
+ export async function OPTIONS() {
33
+ return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
34
+ }
35
+
36
+ /** Bounded wait for Omnitron — an unreachable host must fail fast, not hang the editor. */
37
+ const OMNITRON_TIMEOUT_MS = 8_000;
38
+
39
+ /** True when Omnitron accepts the token as a signed-in user. */
40
+ const isEditorUser = async (
41
+ omnitronUrl: string,
42
+ authorization: string
43
+ ): Promise<boolean> => {
44
+ try {
45
+ const response = await fetch(
46
+ `${omnitronUrl.replace(/\/+$/, '')}/api/v1/active_user/`,
47
+ {
48
+ headers: { Authorization: authorization, Accept: 'application/json' },
49
+ cache: 'no-store',
50
+ signal: AbortSignal.timeout(OMNITRON_TIMEOUT_MS)
51
+ }
52
+ );
53
+ return response.status === 200;
54
+ } catch {
55
+ return false;
56
+ }
57
+ };
58
+
59
+ export async function GET(request: NextRequest) {
60
+ const secret = process.env.PREVIEW_SECRET;
61
+ const omnitronUrl = process.env.OMNITRON_URL;
62
+ if (!secret || !omnitronUrl) {
63
+ return json(
64
+ {
65
+ error: 'preview-disabled',
66
+ message:
67
+ 'Preview links are disabled: PREVIEW_SECRET and OMNITRON_URL must both be configured on the storefront.'
68
+ },
69
+ 503
70
+ );
71
+ }
72
+
73
+ const previewId =
74
+ request.nextUrl.searchParams.get(PREVIEW_ID_QUERY_PARAM) ?? '';
75
+ if (!isValidPreviewId(previewId)) {
76
+ return json(
77
+ { error: 'invalid-preview-id', message: 'A valid preview_id is required.' },
78
+ 400
79
+ );
80
+ }
81
+
82
+ const authorization = request.headers.get('authorization') ?? '';
83
+ if (!/^Token \S+$/i.test(authorization)) {
84
+ return json(
85
+ { error: 'unauthorized', message: 'An Omnitron token is required.' },
86
+ 401
87
+ );
88
+ }
89
+ if (!(await isEditorUser(omnitronUrl, authorization))) {
90
+ return json(
91
+ {
92
+ error: 'unauthorized',
93
+ message: `Omnitron at ${omnitronUrl} did not accept the token (or did not answer).`
94
+ },
95
+ 401
96
+ );
97
+ }
98
+
99
+ const ttl =
100
+ Number(process.env.PREVIEW_LINK_TTL_SECONDS) ||
101
+ DEFAULT_PREVIEW_LINK_TTL_SECONDS;
102
+ const expiresAt = Math.floor(Date.now() / 1000) + ttl;
103
+ const token = await signPreviewToken({ previewId, expiresAt }, secret);
104
+
105
+ return json(
106
+ {
107
+ token,
108
+ previewId,
109
+ expiresAt: new Date(expiresAt * 1000).toISOString(),
110
+ // Ready to append to any page url of this storefront.
111
+ query: `preview=${encodeURIComponent(token)}`
112
+ },
113
+ 200
114
+ );
115
+ }
@@ -0,0 +1,96 @@
1
+ import { PREVIEW_ID_COOKIE, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
2
+ import { verifyPreviewToken } from '@akinon/next/utils/preview-token';
3
+ import { cookies, draftMode } from 'next/headers';
4
+ import { redirect } from 'next/navigation';
5
+ import { NextRequest, NextResponse } from 'next/server';
6
+
7
+ /**
8
+ * Enables/disables Next.js Draft Mode for widget previews, and records WHICH
9
+ * named preview is being viewed.
10
+ *
11
+ * Visitors never call this directly — any page url carrying `?preview` is
12
+ * redirected here by the middleware (see src/proxy.ts), and redirected back
13
+ * to the same page once the cookies are set. With draft mode on, the widget
14
+ * fetch layer serves `<slug>-preview-<id>` copies (falling back to live).
15
+ *
16
+ * The preview id rides in its own cookie rather than the url: pages keep
17
+ * their clean addresses and links followed afterwards stay inside the same
18
+ * preview. The cookie is folded into the pz route segment by the middleware
19
+ * (see settings.js `pzSegments`) because the widget pages are force-static,
20
+ * where Next.js blanks `cookies()` inside the render.
21
+ *
22
+ * Deny-by-default: entering preview ALWAYS requires `?preview=<token>` — a
23
+ * signed, expiring token minted by /api/preview/link for an editor user (see
24
+ * utils/preview-token.ts); PREVIEW_SECRET is the signing key and never
25
+ * travels in a url. Environments without the env have preview disabled
26
+ * entirely. Exiting never requires a token.
27
+ */
28
+ export async function GET(request: NextRequest) {
29
+ const { searchParams } = request.nextUrl;
30
+
31
+ // Only ever redirect within the site — the target comes from the query.
32
+ const target = searchParams.get('redirect') ?? '/';
33
+ let safeTarget =
34
+ target.startsWith('/') && !target.startsWith('//') ? target : '/';
35
+ // Never bounce back to a url that still carries ?preview — that would
36
+ // re-enter the middleware redirect and loop.
37
+ const targetUrl = new URL(safeTarget, request.nextUrl.origin);
38
+ targetUrl.searchParams.delete('preview');
39
+ targetUrl.searchParams.delete(PREVIEW_ID_QUERY_PARAM);
40
+ safeTarget = `${targetUrl.pathname}${targetUrl.search}`;
41
+ // URL normalization can reintroduce a protocol-relative target the first
42
+ // guard already rejected (e.g. '/..//evil.com' normalizes to pathname
43
+ // '//evil.com', which browsers resolve to https://evil.com) — re-check the
44
+ // FINAL string, not just the raw input.
45
+ if (!safeTarget.startsWith('/') || safeTarget.startsWith('//')) {
46
+ safeTarget = '/';
47
+ }
48
+
49
+ const draft = await draftMode();
50
+ const cookieStore = await cookies();
51
+
52
+ if (searchParams.has('exit')) {
53
+ draft.disable();
54
+ cookieStore.delete(PREVIEW_ID_COOKIE);
55
+ redirect(safeTarget);
56
+ }
57
+
58
+ const secret = process.env.PREVIEW_SECRET;
59
+ if (!secret) {
60
+ return new NextResponse(
61
+ 'Preview is disabled: PREVIEW_SECRET is not configured.',
62
+ { status: 401 }
63
+ );
64
+ }
65
+
66
+ const verdict = await verifyPreviewToken(
67
+ searchParams.get('token') ?? '',
68
+ secret
69
+ );
70
+ if (verdict.ok !== true) {
71
+ const { reason } = verdict;
72
+ return new NextResponse(
73
+ reason === 'expired'
74
+ ? 'This preview link has expired — open the page from the editor again to get a fresh one.'
75
+ : 'Preview link is invalid or missing. Preview pages are opened from the theme editor.',
76
+ { status: 401 }
77
+ );
78
+ }
79
+ const { previewId } = verdict.claims;
80
+
81
+ draft.enable();
82
+ // Same flags Next gives its own draft cookie (SameSite=None; Secure outside
83
+ // development): the storefront may be embedded cross-site (editor,
84
+ // Omnitron), and a Lax id cookie would be dropped there while the draft
85
+ // cookie survives — draft mode on, no id, live content under a "Preview"
86
+ // banner. `nextUrl.protocol` is not consulted: behind a TLS-terminating
87
+ // proxy it reads http even in production.
88
+ const isDevelopment = process.env.NODE_ENV === 'development';
89
+ cookieStore.set(PREVIEW_ID_COOKIE, previewId, {
90
+ path: '/',
91
+ httpOnly: true,
92
+ sameSite: isDevelopment ? 'lax' : 'none',
93
+ secure: !isDevelopment
94
+ });
95
+ redirect(safeTarget);
96
+ }
@@ -1,3 +1 @@
1
- export * from './use-contract';
2
- export * from './use-fav-button';
3
1
  export * from './use-add-product-to-basket';