@akinon/projectzero 2.0.6-rc.0 → 2.0.6-rc.2

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 (37) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/app-template/.lintstagedrc.js +4 -6
  3. package/app-template/CHANGELOG.md +81 -0
  4. package/app-template/eslint.config.mjs +14 -0
  5. package/app-template/next.config.mjs +26 -4
  6. package/app-template/package.json +34 -35
  7. package/app-template/public/locales/en/basket.json +4 -1
  8. package/app-template/public/locales/en/common.json +15 -0
  9. package/app-template/public/locales/en/forgot_password.json +4 -1
  10. package/app-template/public/locales/en/product.json +3 -0
  11. package/app-template/public/locales/tr/basket.json +4 -1
  12. package/app-template/public/locales/tr/common.json +15 -0
  13. package/app-template/public/locales/tr/forgot_password.json +4 -1
  14. package/app-template/public/locales/tr/product.json +3 -0
  15. package/app-template/src/__tests__/middleware-matcher.test.ts +1 -1
  16. package/app-template/src/app/[pz]/flat-page/[pk]/page.tsx +58 -2
  17. package/app-template/src/app/[pz]/group-product/[pk]/page.tsx +2 -2
  18. package/app-template/src/app/[pz]/layout.tsx +11 -2
  19. package/app-template/src/app/[pz]/product/[pk]/page.tsx +2 -2
  20. package/app-template/src/app/[pz]/template.tsx +3 -2
  21. package/app-template/src/app/manifest.ts +39 -0
  22. package/app-template/src/app/sw.ts +25 -0
  23. package/app-template/src/components/index.ts +2 -0
  24. package/app-template/src/components/install-prompt.tsx +93 -0
  25. package/app-template/src/components/offline-fallback.tsx +50 -0
  26. package/app-template/src/components/pwa-tags.tsx +1 -1
  27. package/app-template/src/hooks/use-fav-button.tsx +12 -5
  28. package/app-template/src/hooks/use-install-prompt.ts +146 -0
  29. package/app-template/src/hooks/use-online-status.ts +40 -0
  30. package/app-template/src/utils/generate-jsonld.ts +3 -0
  31. package/app-template/src/views/basket/basket-item.tsx +8 -3
  32. package/app-template/src/views/product/product-actions.tsx +4 -3
  33. package/app-template/tailwind.config.js +22 -0
  34. package/package.json +1 -1
  35. package/app-template/.eslintignore +0 -1
  36. package/app-template/.eslintrc.js +0 -10
  37. package/app-template/public/manifest.json +0 -35
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @akinon/projectzero
2
2
 
3
+ ## 2.0.6-rc.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 1a345c47: ZERO-4234: Add Global Toast Notification System
8
+
9
+ ## 2.0.6-rc.1
10
+
3
11
  ## 2.0.6-rc.0
4
12
 
5
13
  ### Patch Changes
@@ -1,11 +1,9 @@
1
1
  const path = require('path');
2
2
 
3
- const buildEslintCommand = (filenames) => {
4
- // Added --fix flag to automatically fix errors that can be fixed by ESLint
5
- return `next lint --file ${filenames
6
- .map((f) => path.relative(process.cwd(), f))
7
- .join(' --file ')} --fix`;
8
- };
3
+ const buildEslintCommand = (filenames) =>
4
+ `eslint --fix ${filenames
5
+ .map((f) => `"${path.relative(process.cwd(), f)}"`)
6
+ .join(' ')}`;
9
7
 
10
8
  module.exports = {
11
9
  '**/*.(ts|tsx)': () => 'yarn tsc --noEmit',
@@ -1,5 +1,86 @@
1
1
  # projectzeronext
2
2
 
3
+ ## 2.0.6-rc.2
4
+
5
+ ### Patch Changes
6
+
7
+ - c6edd8e2: ZERO-4389: Add headers configuration for service worker with security policies
8
+ - 1a345c47: ZERO-4234: Add Global Toast Notification System
9
+ - a943fd69: ZERO-4383: Add offline fallback component and localization support for offline messages
10
+ - b8c210d5: ZERO-4433: Update pipeline scripts to install git for build steps and correct route import path
11
+ - 6a62ffaa: ZERO-4390: Add serializeJsonLd utility and update JSON-LD handling in templates
12
+ - c1dfaae3: ZERO-4380: Add service worker implementation with Serwist for caching and precaching
13
+ - 89ed4e03: ZERO-4394: Add Next.js 16 ESLint migration v9 + flat config
14
+ - e7861d74: ZERO-4379: Refactor PWA manifest handling: replace manifest.json with manifest.webmanifest
15
+ - d64e1035: ZERO-4386: Add install prompt and localization for PWA installation
16
+ - 8d8fefbe: ZERO-4398: Enhance SEO metadata generation for flat pages and update FlatPage interface to include localized URLs
17
+ - Updated dependencies [89deabe5]
18
+ - Updated dependencies [1a345c47]
19
+ - Updated dependencies [1f1ae44e]
20
+ - Updated dependencies [8ae85c5a]
21
+ - Updated dependencies [89ed4e03]
22
+ - Updated dependencies [8d8fefbe]
23
+ - @akinon/next@2.0.6-rc.2
24
+ - @akinon/pz-theme@2.0.6-rc.2
25
+ - @akinon/pz-virtual-try-on@2.0.6-rc.2
26
+ - @akinon/pz-akifast@2.0.6-rc.2
27
+ - @akinon/pz-apple-pay@2.0.6-rc.2
28
+ - @akinon/pz-b2b@2.0.6-rc.2
29
+ - @akinon/pz-basket-gift-pack@2.0.6-rc.2
30
+ - @akinon/pz-bkm@2.0.6-rc.2
31
+ - @akinon/pz-checkout-gift-pack@2.0.6-rc.2
32
+ - @akinon/pz-click-collect@2.0.6-rc.2
33
+ - @akinon/pz-credit-payment@2.0.6-rc.2
34
+ - @akinon/pz-cybersource-uc@2.0.6-rc.2
35
+ - @akinon/pz-flow-payment@2.0.6-rc.2
36
+ - @akinon/pz-google-pay@2.0.6-rc.2
37
+ - @akinon/pz-gpay@2.0.6-rc.2
38
+ - @akinon/pz-haso@2.0.6-rc.2
39
+ - @akinon/pz-hepsipay@2.0.6-rc.2
40
+ - @akinon/pz-masterpass@2.0.6-rc.2
41
+ - @akinon/pz-masterpass-rest@2.0.6-rc.2
42
+ - @akinon/pz-multi-basket@2.0.6-rc.2
43
+ - @akinon/pz-one-click-checkout@2.0.6-rc.2
44
+ - @akinon/pz-otp@2.0.6-rc.2
45
+ - @akinon/pz-pay-on-delivery@2.0.6-rc.2
46
+ - @akinon/pz-saved-card@2.0.6-rc.2
47
+ - @akinon/pz-similar-products@2.0.6-rc.2
48
+ - @akinon/pz-tabby-extension@2.0.6-rc.2
49
+ - @akinon/pz-tamara-extension@2.0.6-rc.2
50
+
51
+ ## 2.0.6-rc.1
52
+
53
+ ### Patch Changes
54
+
55
+ - Updated dependencies [51ea0688]
56
+ - @akinon/next@2.0.6-rc.1
57
+ - @akinon/pz-theme@2.0.6-rc.1
58
+ - @akinon/pz-virtual-try-on@2.0.6-rc.1
59
+ - @akinon/pz-akifast@2.0.6-rc.1
60
+ - @akinon/pz-apple-pay@2.0.6-rc.1
61
+ - @akinon/pz-b2b@2.0.6-rc.1
62
+ - @akinon/pz-basket-gift-pack@2.0.6-rc.1
63
+ - @akinon/pz-bkm@2.0.6-rc.1
64
+ - @akinon/pz-checkout-gift-pack@2.0.6-rc.1
65
+ - @akinon/pz-click-collect@2.0.6-rc.1
66
+ - @akinon/pz-credit-payment@2.0.6-rc.1
67
+ - @akinon/pz-cybersource-uc@2.0.6-rc.1
68
+ - @akinon/pz-flow-payment@2.0.6-rc.1
69
+ - @akinon/pz-google-pay@2.0.6-rc.1
70
+ - @akinon/pz-gpay@2.0.6-rc.1
71
+ - @akinon/pz-haso@2.0.6-rc.1
72
+ - @akinon/pz-hepsipay@2.0.6-rc.1
73
+ - @akinon/pz-masterpass@2.0.6-rc.1
74
+ - @akinon/pz-masterpass-rest@2.0.6-rc.1
75
+ - @akinon/pz-multi-basket@2.0.6-rc.1
76
+ - @akinon/pz-one-click-checkout@2.0.6-rc.1
77
+ - @akinon/pz-otp@2.0.6-rc.1
78
+ - @akinon/pz-pay-on-delivery@2.0.6-rc.1
79
+ - @akinon/pz-saved-card@2.0.6-rc.1
80
+ - @akinon/pz-similar-products@2.0.6-rc.1
81
+ - @akinon/pz-tabby-extension@2.0.6-rc.1
82
+ - @akinon/pz-tamara-extension@2.0.6-rc.1
83
+
3
84
  ## 2.0.6-rc.0
4
85
 
5
86
  ### Patch Changes
@@ -0,0 +1,14 @@
1
+ import { defineConfig, globalIgnores } from 'eslint/config'
2
+ import baseConfig from '@akinon/next/eslint.config.base.mjs'
3
+
4
+ export default defineConfig([
5
+ ...baseConfig,
6
+ {
7
+ settings: {
8
+ next: {
9
+ rootDir: ['src/*/']
10
+ }
11
+ }
12
+ },
13
+ globalIgnores(['public/**'])
14
+ ])
@@ -1,4 +1,4 @@
1
- import withPWA from 'next-pwa';
1
+ import withSerwistInit from '@serwist/next';
2
2
  import { withSentryConfig } from '@sentry/nextjs';
3
3
  import withPzConfig from '@akinon/next/with-pz-config.js';
4
4
 
@@ -15,11 +15,33 @@ const nextConfig = {
15
15
  destination: '/:pz/xml-sitemap/:node'
16
16
  }
17
17
  ];
18
+ },
19
+ headers: async () => {
20
+ return [
21
+ {
22
+ source: '/sw.js',
23
+ headers: [
24
+ {
25
+ key: 'Content-Type',
26
+ value: 'application/javascript; charset=utf-8'
27
+ },
28
+ {
29
+ key: 'Cache-Control',
30
+ value: 'no-cache, no-store, must-revalidate'
31
+ },
32
+ {
33
+ key: 'Content-Security-Policy',
34
+ value: "default-src 'self'; script-src 'self'"
35
+ }
36
+ ]
37
+ }
38
+ ];
18
39
  }
19
40
  };
20
41
 
21
- const withPwaConfig = withPWA({
22
- dest: 'public',
42
+ const withSerwist = withSerwistInit({
43
+ swSrc: 'src/app/sw.ts',
44
+ swDest: 'public/sw.js',
23
45
  disable: process.env.NODE_ENV === 'development'
24
46
  });
25
47
 
@@ -36,4 +58,4 @@ const sentryConfig = {
36
58
 
37
59
  const enhancedConfig = withPzConfig(nextConfig);
38
60
 
39
- export default withSentryConfig(withPwaConfig(enhancedConfig), sentryConfig);
61
+ export default withSentryConfig(withSerwist(enhancedConfig), sentryConfig);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectzeronext",
3
- "version": "2.0.6-rc.0",
3
+ "version": "2.0.6-rc.2",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "scripts": {
@@ -10,7 +10,7 @@
10
10
  "build": "next build --webpack",
11
11
  "start": "next start -p 8008",
12
12
  "type-check": "tsc",
13
- "lint": "next lint",
13
+ "lint": "eslint .",
14
14
  "test": "jest",
15
15
  "cypress:headless": "cd projectzeroe2e && yarn headless",
16
16
  "e2e:headless": "CYPRESS_BASE_URL=http://localhost:3000 start-server-and-test start http://localhost:3000 cypress:headless",
@@ -24,42 +24,43 @@
24
24
  "test:middleware": "jest middleware-matcher.test.ts --bail"
25
25
  },
26
26
  "dependencies": {
27
- "@akinon/next": "2.0.6-rc.0",
28
- "@akinon/pz-akifast": "2.0.6-rc.0",
29
- "@akinon/pz-apple-pay": "2.0.6-rc.0",
30
- "@akinon/pz-b2b": "2.0.6-rc.0",
31
- "@akinon/pz-basket-gift-pack": "2.0.6-rc.0",
32
- "@akinon/pz-bkm": "2.0.6-rc.0",
33
- "@akinon/pz-checkout-gift-pack": "2.0.6-rc.0",
34
- "@akinon/pz-click-collect": "2.0.6-rc.0",
35
- "@akinon/pz-credit-payment": "2.0.6-rc.0",
36
- "@akinon/pz-cybersource-uc": "2.0.6-rc.0",
37
- "@akinon/pz-flow-payment": "2.0.6-rc.0",
38
- "@akinon/pz-google-pay": "2.0.6-rc.0",
39
- "@akinon/pz-gpay": "2.0.6-rc.0",
40
- "@akinon/pz-haso": "2.0.6-rc.0",
41
- "@akinon/pz-hepsipay": "2.0.6-rc.0",
42
- "@akinon/pz-masterpass": "2.0.6-rc.0",
43
- "@akinon/pz-masterpass-rest": "2.0.6-rc.0",
44
- "@akinon/pz-multi-basket": "2.0.6-rc.0",
45
- "@akinon/pz-one-click-checkout": "2.0.6-rc.0",
46
- "@akinon/pz-otp": "2.0.6-rc.0",
47
- "@akinon/pz-pay-on-delivery": "2.0.6-rc.0",
48
- "@akinon/pz-saved-card": "2.0.6-rc.0",
49
- "@akinon/pz-similar-products": "2.0.6-rc.0",
50
- "@akinon/pz-tabby-extension": "2.0.6-rc.0",
51
- "@akinon/pz-tamara-extension": "2.0.6-rc.0",
52
- "@akinon/pz-theme": "2.0.6-rc.0",
53
- "@akinon/pz-virtual-try-on": "2.0.6-rc.0",
27
+ "@akinon/next": "2.0.6-rc.2",
28
+ "@akinon/pz-akifast": "2.0.6-rc.2",
29
+ "@akinon/pz-apple-pay": "2.0.6-rc.2",
30
+ "@akinon/pz-b2b": "2.0.6-rc.2",
31
+ "@akinon/pz-basket-gift-pack": "2.0.6-rc.2",
32
+ "@akinon/pz-bkm": "2.0.6-rc.2",
33
+ "@akinon/pz-checkout-gift-pack": "2.0.6-rc.2",
34
+ "@akinon/pz-click-collect": "2.0.6-rc.2",
35
+ "@akinon/pz-credit-payment": "2.0.6-rc.2",
36
+ "@akinon/pz-cybersource-uc": "2.0.6-rc.2",
37
+ "@akinon/pz-flow-payment": "2.0.6-rc.2",
38
+ "@akinon/pz-google-pay": "2.0.6-rc.2",
39
+ "@akinon/pz-gpay": "2.0.6-rc.2",
40
+ "@akinon/pz-haso": "2.0.6-rc.2",
41
+ "@akinon/pz-hepsipay": "2.0.6-rc.2",
42
+ "@akinon/pz-masterpass": "2.0.6-rc.2",
43
+ "@akinon/pz-masterpass-rest": "2.0.6-rc.2",
44
+ "@akinon/pz-multi-basket": "2.0.6-rc.2",
45
+ "@akinon/pz-one-click-checkout": "2.0.6-rc.2",
46
+ "@akinon/pz-otp": "2.0.6-rc.2",
47
+ "@akinon/pz-pay-on-delivery": "2.0.6-rc.2",
48
+ "@akinon/pz-saved-card": "2.0.6-rc.2",
49
+ "@akinon/pz-similar-products": "2.0.6-rc.2",
50
+ "@akinon/pz-tabby-extension": "2.0.6-rc.2",
51
+ "@akinon/pz-tamara-extension": "2.0.6-rc.2",
52
+ "@akinon/pz-theme": "2.0.6-rc.2",
53
+ "@akinon/pz-virtual-try-on": "2.0.6-rc.2",
54
54
  "@hookform/resolvers": "2.9.0",
55
55
  "@next/third-parties": "16.2.4",
56
56
  "@react-google-maps/api": "2.17.1",
57
57
  "dayjs": "1.11.5",
58
58
  "lossless-json": "2.0.5",
59
59
  "next": "16.2.4",
60
+ "@serwist/next": "9.5.11",
60
61
  "next-auth": "5.0.0-beta.30",
61
- "next-pwa": "5.6.0",
62
62
  "pino": "8.21.0",
63
+ "serwist": "9.5.11",
63
64
  "postcss": "8.4.49",
64
65
  "react": "19.2.5",
65
66
  "react-dom": "19.2.5",
@@ -73,7 +74,7 @@
73
74
  "yup": "0.32.11"
74
75
  },
75
76
  "devDependencies": {
76
- "@akinon/eslint-plugin-projectzero": "2.0.6-rc.0",
77
+ "@akinon/eslint-plugin-projectzero": "2.0.6-rc.2",
77
78
  "@semantic-release/changelog": "6.0.2",
78
79
  "@semantic-release/exec": "6.0.3",
79
80
  "@semantic-release/git": "10.0.1",
@@ -87,14 +88,12 @@
87
88
  "@types/jest": "29.5.14",
88
89
  "@types/react": "19.2.14",
89
90
  "@types/react-dom": "19.2.3",
90
- "@typescript-eslint/eslint-plugin": "6.7.4",
91
- "@typescript-eslint/parser": "6.7.4",
92
91
  "client-only": "0.0.1",
93
92
  "clsx": "1.2.1",
94
93
  "currency-symbol-map": "5.1.0",
95
- "eslint": "8.14.0",
94
+ "eslint": "9.39.4",
96
95
  "eslint-config-next": "16.2.4",
97
- "eslint-config-prettier": "8.5.0",
96
+ "eslint-config-prettier": "10.1.1",
98
97
  "husky": "8.0.0",
99
98
  "jest": "29.7.0",
100
99
  "jest-css-modules-transform": "4.4.2",
@@ -20,7 +20,10 @@
20
20
  "question": "Are you sure you want to delete the product?",
21
21
  "delete": "Delete",
22
22
  "add_to_favorites": "Delete and Add to Favorites",
23
- "cancel": "Cancel"
23
+ "cancel": "Cancel",
24
+ "moved_to_favorites": "Moved to favorites",
25
+ "removed": "Removed from basket",
26
+ "error": "Failed to remove product"
24
27
  }
25
28
  },
26
29
  "summary": {
@@ -1,4 +1,19 @@
1
1
  {
2
+ "network": {
3
+ "offline": {
4
+ "title": "Connection Lost",
5
+ "message": "Your internet connection has been lost. Please check your connection and try again."
6
+ }
7
+ },
8
+ "install": {
9
+ "title": "Install App",
10
+ "description": {
11
+ "default": "Add this site to your home screen for faster access.",
12
+ "ios": "To add this site to your home screen, tap the share button in Safari and select 'Add to Home Screen'."
13
+ },
14
+ "button": "Install",
15
+ "dismiss": "Close"
16
+ },
2
17
  "header": {
3
18
  "login": "LOGIN",
4
19
  "signup": "SIGNUP"
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "title": "Reset Password",
3
3
  "description": "Enter your email address and we'll send you a link to reset your password.",
4
+ "description_first": "Enter your email address,",
5
+ "description_second": "we'll send you a link to reset your password.",
4
6
  "back_to_login": "Back to login",
5
7
  "form": {
6
8
  "email": {
@@ -13,7 +15,8 @@
13
15
  },
14
16
  "success": {
15
17
  "title": "Reset Password",
16
- "subtitle": "We've sent a password reset link to {EMAIL}. Please check your inbox and follow the instructions."
18
+ "subtitle": "We've sent a password reset link to {EMAIL}. Please check your inbox and follow the instructions.",
19
+ "button": "Continue to Homepage"
17
20
  }
18
21
  },
19
22
  "create_new_password": {
@@ -2,6 +2,9 @@
2
2
  "add_to_cart": "ADD TO CART",
3
3
  "add_stock_alert": "ADD STOCK ALERT",
4
4
  "add_to_favorites": "Add to Favorites",
5
+ "added_to_favorites": "Added to favorites",
6
+ "removed_from_favorites": "Removed from favorites",
7
+ "favorites_error": "Failed to update favorites",
5
8
  "share": "Share",
6
9
  "details_care": "Details & Care",
7
10
  "delivery_collections": "Delivery, Collections & Returns",
@@ -20,7 +20,10 @@
20
20
  "question": "Ürünü silmek istediğinizden emin misiniz?",
21
21
  "delete": "Sil",
22
22
  "add_to_favorites": "Sil ve Favorilere Ekle",
23
- "cancel": "Vazgeç"
23
+ "cancel": "Vazgeç",
24
+ "moved_to_favorites": "Favorilere taşındı",
25
+ "removed": "Sepetten kaldırıldı",
26
+ "error": "Ürün kaldırılamadı"
24
27
  }
25
28
  },
26
29
  "summary": {
@@ -1,4 +1,19 @@
1
1
  {
2
+ "network": {
3
+ "offline": {
4
+ "title": "Bağlantı Kesildi",
5
+ "message": "İnternet bağlantınız kesildi. Lütfen bağlantınızı kontrol edip tekrar deneyin."
6
+ }
7
+ },
8
+ "install": {
9
+ "title": "Uygulamayı Yükle",
10
+ "description": {
11
+ "default": "Daha hızlı erişim için bu siteyi ana ekranına ekle.",
12
+ "ios": "Ana ekranına eklemek için Safari'nin paylaş butonuna basıp 'Ana Ekrana Ekle' seçeneğini seç."
13
+ },
14
+ "button": "Yükle",
15
+ "dismiss": "Kapat"
16
+ },
2
17
  "header": {
3
18
  "login": "GİRİŞ YAP",
4
19
  "signup": "ÜYE OL"
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "title": "Şifre Sıfırlama",
3
3
  "description": "E-posta adresinizi girin, şifrenizi sıfırlamanız için size bir bağlantı gönderelim.",
4
+ "description_first": "E-posta adresinizi girin,",
5
+ "description_second": "şifrenizi sıfırlamanız için size bir bağlantı gönderelim.",
4
6
  "back_to_login": "Girişe dön",
5
7
  "form": {
6
8
  "email": {
@@ -13,7 +15,8 @@
13
15
  },
14
16
  "success": {
15
17
  "title": "Şifre Sıfırlama",
16
- "subtitle": "{EMAIL} adresine bir şifre sıfırlama bağlantısı gönderdik. Lütfen gelen kutunuzu kontrol edin ve talimatları izleyin."
18
+ "subtitle": "{EMAIL} adresine bir şifre sıfırlama bağlantısı gönderdik. Lütfen gelen kutunuzu kontrol edin ve talimatları izleyin.",
19
+ "button": "Ana Sayfaya Devam Et"
17
20
  }
18
21
  },
19
22
  "create_new_password": {
@@ -2,6 +2,9 @@
2
2
  "add_to_cart": "SEPETE EKLE",
3
3
  "add_stock_alert": "STOK UYARISI EKLE",
4
4
  "add_to_favorites": "Favorilere ekle",
5
+ "added_to_favorites": "Favorilere eklendi",
6
+ "removed_from_favorites": "Favorilerden kaldırıldı",
7
+ "favorites_error": "Favoriler güncellenemedi",
5
8
  "share": "Paylaş",
6
9
  "details_care": "DETAYLAR & BAKIM",
7
10
  "delivery_collections": "TESLİMAT, KOLEKSİYON VE İADE",
@@ -83,7 +83,7 @@ describe('Middleware matcher regex tests', () => {
83
83
  '/styles/main.css',
84
84
  '/fonts/roboto.woff2',
85
85
  '/favicon.ico',
86
- '/manifest.json'
86
+ '/manifest.webmanifest'
87
87
  ];
88
88
  staticFiles.forEach((path) => {
89
89
  expect(testPath(path)).toBe(false);
@@ -1,8 +1,64 @@
1
- import { getFlatPageData } from '@akinon/next/data/server';
1
+ import { getFlatPageData, getSeoData } from '@akinon/next/data/server';
2
2
  import { withSegmentDefaults } from '@akinon/next/hocs/server';
3
- import { ResolvedPageProps } from '@akinon/next/types';
3
+ import {
4
+ AsyncPageProps,
5
+ Metadata,
6
+ ResolvedPageProps
7
+ } from '@akinon/next/types';
8
+ import { parsePzParams } from '@akinon/next/utils/pz-segments';
9
+ import logger from '@akinon/next/utils/log';
10
+ import settings from '@theme/settings';
4
11
  import { notFound } from 'next/navigation';
5
12
 
13
+ export async function generateMetadata(
14
+ props: AsyncPageProps<{ pk: number }>
15
+ ): Promise<Metadata> {
16
+ const params = await props.params;
17
+ const { locale } = parsePzParams(params, settings);
18
+
19
+ try {
20
+ const data = await getFlatPageData({ pk: params.pk, locale });
21
+ const flatPage = data?.flat_page;
22
+
23
+ if (!flatPage) {
24
+ return {};
25
+ }
26
+
27
+ const localizedUrl = flatPage.flatpageprettyurl_set?.find(
28
+ (entry) => entry.language === locale
29
+ )?.url;
30
+ const seoUrl = localizedUrl ?? flatPage.url;
31
+
32
+ if (!seoUrl) {
33
+ return {};
34
+ }
35
+
36
+ const seo = await getSeoData(seoUrl, locale);
37
+
38
+ if (!seo?.title && !seo?.description && !seo?.keywords) {
39
+ logger.debug('No CMS SEO entry for flat-page', {
40
+ pk: params.pk,
41
+ seoUrl,
42
+ locale
43
+ });
44
+ return {};
45
+ }
46
+
47
+ return {
48
+ ...(seo.title ? { title: seo.title } : {}),
49
+ ...(seo.description ? { description: seo.description } : {}),
50
+ ...(seo.keywords ? { keywords: seo.keywords } : {})
51
+ };
52
+ } catch (error) {
53
+ logger.warn('flat-page generateMetadata failed', {
54
+ pk: params.pk,
55
+ locale,
56
+ error
57
+ });
58
+ return {};
59
+ }
60
+ }
61
+
6
62
  async function Page({ params }: ResolvedPageProps<{ pk: number }>) {
7
63
  try {
8
64
  const data = await getFlatPageData({ pk: params.pk });
@@ -3,7 +3,7 @@ import { ProductGroupInfo } from '@theme/views/product';
3
3
  import { getProductData, getWidgetData } from '@akinon/next/data/server';
4
4
  import { withSegmentDefaults } from '@akinon/next/hocs/server';
5
5
  import { AsyncPageProps, ResolvedPageProps, Metadata } from '@akinon/next/types';
6
- import { generateJsonLd } from '@theme/utils/generate-jsonld';
6
+ import { generateJsonLd, serializeJsonLd } from '@theme/utils/generate-jsonld';
7
7
  import { notFound } from 'next/navigation';
8
8
 
9
9
  export async function generateMetadata(
@@ -78,7 +78,7 @@ async function Page({ params, searchParams }: ResolvedPageProps<{ pk: number }>)
78
78
  </ProductLayout>
79
79
  <script
80
80
  type="application/ld+json"
81
- dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
81
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
82
82
  />
83
83
  </>
84
84
  );
@@ -11,6 +11,8 @@ import { getSeoData } from '@akinon/next/data/server';
11
11
  import PzRoot from '@akinon/next/components/pz-root';
12
12
  import MobileAppToggler from '@akinon/next/components/mobile-app-toggler';
13
13
  import pwaTags from '@theme/components/pwa-tags';
14
+ import { OfflineFallback } from '@theme/components/offline-fallback';
15
+ import { InstallPrompt } from '@theme/components/install-prompt';
14
16
  import type { Viewport } from 'next';
15
17
  import settings from '@theme/settings';
16
18
  import RouteHandler from '@theme/components/route-handler';
@@ -43,7 +45,11 @@ export async function generateMetadata() {
43
45
  return result;
44
46
  }
45
47
 
46
- async function RootLayout({ locale, translations, children }: ResolvedRootLayoutProps) {
48
+ async function RootLayout({
49
+ locale,
50
+ translations,
51
+ children
52
+ }: ResolvedRootLayoutProps) {
47
53
  return (
48
54
  <html lang={locale.isoCode} {...(locale.rtl ? { dir: 'rtl' } : {})}>
49
55
  <head />
@@ -55,13 +61,16 @@ async function RootLayout({ locale, translations, children }: ResolvedRootLayout
55
61
  <Header />
56
62
  </MobileAppToggler>
57
63
  <main>
58
- {children}
64
+ <OfflineFallback>{children}</OfflineFallback>
59
65
  <RouteHandler />
60
66
  <RootModal />
61
67
  </main>
62
68
  <MobileAppToggler>
63
69
  <Footer />
64
70
  </MobileAppToggler>
71
+ <MobileAppToggler>
72
+ <InstallPrompt />
73
+ </MobileAppToggler>
65
74
  </div>
66
75
  </ClientRoot>
67
76
  </PzRoot>
@@ -1,7 +1,7 @@
1
1
  import { getProductData, getWidgetData } from '@akinon/next/data/server';
2
2
  import { withSegmentDefaults } from '@akinon/next/hocs/server';
3
3
  import { AsyncPageProps, ResolvedPageProps, Metadata } from '@akinon/next/types';
4
- import { generateJsonLd } from '@theme/utils/generate-jsonld';
4
+ import { generateJsonLd, serializeJsonLd } from '@theme/utils/generate-jsonld';
5
5
  import { AccordionWrapper, ProductInfo } from '@theme/views/product';
6
6
  import ProductLayout from '@theme/views/product/layout';
7
7
  import { notFound } from 'next/navigation';
@@ -87,7 +87,7 @@ async function Page({ params, searchParams }: ResolvedPageProps<{ pk: number }>)
87
87
  </ProductLayout>
88
88
  <script
89
89
  type="application/ld+json"
90
- dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
90
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
91
91
  />
92
92
  </>
93
93
  );
@@ -8,6 +8,7 @@ import { closeMiniBasket } from '@akinon/next/redux/reducers/root';
8
8
  import { ROUTES } from '@theme/routes';
9
9
  import { GoogleTagManager } from '@next/third-parties/google';
10
10
  import { pushPageView } from '@theme/utils/gtm';
11
+ import { serializeJsonLd } from '@theme/utils/generate-jsonld';
11
12
 
12
13
  export default function RootTemplate({
13
14
  children
@@ -63,11 +64,11 @@ export default function RootTemplate({
63
64
  {/* End Google Tag Manager */}
64
65
  <script
65
66
  type="application/ld+json"
66
- dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdWebSite) }}
67
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLdWebSite) }}
67
68
  />
68
69
  <script
69
70
  type="application/ld+json"
70
- dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdOrganization) }}
71
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLdOrganization) }}
71
72
  />
72
73
  {children}
73
74
  </>
@@ -0,0 +1,39 @@
1
+ import type { MetadataRoute } from 'next'
2
+
3
+ export default function manifest(): MetadataRoute.Manifest {
4
+ return {
5
+ name: 'Project Zero | Next',
6
+ short_name: 'PZ | Next',
7
+ icons: [
8
+ {
9
+ src: '/icon-192x192.png',
10
+ sizes: '192x192',
11
+ type: 'image/png',
12
+ purpose: 'maskable'
13
+ },
14
+ {
15
+ src: '/icon-256x256.png',
16
+ sizes: '256x256',
17
+ type: 'image/png',
18
+ purpose: 'any'
19
+ },
20
+ {
21
+ src: '/icon-384x384.png',
22
+ sizes: '384x384',
23
+ type: 'image/png',
24
+ purpose: 'any'
25
+ },
26
+ {
27
+ src: '/icon-512x512.png',
28
+ sizes: '512x512',
29
+ type: 'image/png',
30
+ purpose: 'any'
31
+ }
32
+ ],
33
+ theme_color: '#FFFFFF',
34
+ background_color: '#FFFFFF',
35
+ start_url: '/',
36
+ display: 'standalone',
37
+ orientation: 'portrait'
38
+ }
39
+ }
@@ -0,0 +1,25 @@
1
+ /// <reference no-default-lib="true" />
2
+ /// <reference lib="esnext" />
3
+ /// <reference lib="webworker" />
4
+
5
+ import { defaultCache } from '@serwist/next/worker'
6
+ import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
7
+ import { Serwist } from 'serwist'
8
+
9
+ declare global {
10
+ interface WorkerGlobalScope extends SerwistGlobalConfig {
11
+ __SW_MANIFEST: (PrecacheEntry | string)[] | undefined
12
+ }
13
+ }
14
+
15
+ declare const self: ServiceWorkerGlobalScope
16
+
17
+ const serwist = new Serwist({
18
+ precacheEntries: self.__SW_MANIFEST,
19
+ skipWaiting: true,
20
+ clientsClaim: true,
21
+ navigationPreload: true,
22
+ runtimeCaching: defaultCache
23
+ })
24
+
25
+ serwist.addEventListeners()
@@ -40,5 +40,7 @@ export * from './skeleton-wrapper';
40
40
  // Head
41
41
  export * from './canonical-url';
42
42
  export * from './pwa-tags';
43
+ export * from './offline-fallback';
44
+ export * from './install-prompt';
43
45
  export * from './quantity-input';
44
46
  export * from './quantity-selector';
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
5
+ import { Button } from '@theme/components';
6
+ import { useInstallPrompt } from '@theme/hooks/use-install-prompt';
7
+ import { pushEventGA4 } from '@theme/utils/gtm';
8
+
9
+ const trackEvent = (event: string, params: Record<string, unknown>) => {
10
+ try {
11
+ pushEventGA4(event, params);
12
+ } catch {
13
+ // dataLayer unavailable (GTM blocked, script not loaded yet)
14
+ }
15
+ };
16
+
17
+ export const InstallPrompt = () => {
18
+ const { canInstall, isIOS, install, dismiss } = useInstallPrompt();
19
+ const { t } = useLocalization();
20
+
21
+ useEffect(() => {
22
+ if (!canInstall) return;
23
+ trackEvent('pwa_install_prompt_shown', {
24
+ platform: isIOS ? 'ios' : 'native'
25
+ });
26
+ }, [canInstall, isIOS]);
27
+
28
+ if (!canInstall) return null;
29
+
30
+ const handleInstall = async () => {
31
+ trackEvent('pwa_install_prompt_clicked', { platform: 'native' });
32
+ const outcome = await install();
33
+ if (outcome) {
34
+ trackEvent('pwa_install_prompt_outcome', { outcome });
35
+ }
36
+ };
37
+
38
+ const handleDismiss = () => {
39
+ trackEvent('pwa_install_prompt_dismissed', {
40
+ platform: isIOS ? 'ios' : 'native'
41
+ });
42
+ dismiss();
43
+ };
44
+
45
+ return (
46
+ <div
47
+ role="region"
48
+ aria-label={t('common.install.title')}
49
+ className="fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white p-4 shadow-lg"
50
+ >
51
+ <div className="mx-auto flex max-w-screen-lg items-start gap-4">
52
+ <div className="flex-1 min-w-0">
53
+ <h3 className="font-semibold mb-1">
54
+ {t('common.install.title')}
55
+ </h3>
56
+ <p className="text-sm text-gray-700">
57
+ {isIOS
58
+ ? t('common.install.description.ios')
59
+ : t('common.install.description.default')}
60
+ </p>
61
+ </div>
62
+ <div className="flex items-center gap-2 shrink-0">
63
+ {!isIOS && (
64
+ <Button onClick={handleInstall} size="sm">
65
+ {t('common.install.button')}
66
+ </Button>
67
+ )}
68
+ <button
69
+ type="button"
70
+ onClick={handleDismiss}
71
+ aria-label={t('common.install.dismiss')}
72
+ className="p-2 text-gray-500 hover:text-gray-700 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
73
+ >
74
+ <svg
75
+ xmlns="http://www.w3.org/2000/svg"
76
+ viewBox="0 0 24 24"
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth={2}
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ className="size-5"
83
+ aria-hidden="true"
84
+ >
85
+ <line x1="18" y1="6" x2="6" y2="18" />
86
+ <line x1="6" y1="6" x2="18" y2="18" />
87
+ </svg>
88
+ </button>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ );
93
+ };
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { ReactNode } from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
5
+ import { useOnlineStatus } from '@theme/hooks/use-online-status';
6
+
7
+ interface OfflineFallbackProps {
8
+ children: ReactNode;
9
+ }
10
+
11
+ export const OfflineFallback = ({ children }: OfflineFallbackProps) => {
12
+ const isOnline = useOnlineStatus();
13
+ const { t } = useLocalization();
14
+
15
+ if (isOnline) return <>{children}</>;
16
+
17
+ return (
18
+ <div
19
+ role="status"
20
+ aria-live="polite"
21
+ className="flex flex-col items-center justify-center min-h-[60vh] px-4 py-16 text-center"
22
+ >
23
+ <svg
24
+ xmlns="http://www.w3.org/2000/svg"
25
+ viewBox="0 0 24 24"
26
+ fill="none"
27
+ stroke="currentColor"
28
+ strokeWidth={1.5}
29
+ strokeLinecap="round"
30
+ strokeLinejoin="round"
31
+ className="w-16 h-16 mb-6 text-gray-400"
32
+ aria-hidden="true"
33
+ >
34
+ <line x1="1" y1="1" x2="23" y2="23" />
35
+ <path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55" />
36
+ <path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39" />
37
+ <path d="M10.71 5.05A16 16 0 0 1 22.58 9" />
38
+ <path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88" />
39
+ <path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
40
+ <line x1="12" y1="20" x2="12.01" y2="20" />
41
+ </svg>
42
+ <h2 className="text-2xl font-semibold mb-3">
43
+ {t('common.network.offline.title')}
44
+ </h2>
45
+ <p className="text-gray-600 max-w-md">
46
+ {t('common.network.offline.message')}
47
+ </p>
48
+ </div>
49
+ );
50
+ };
@@ -1,7 +1,7 @@
1
1
  import { Metadata } from '@akinon/next/types';
2
2
 
3
3
  const pwaTags: Metadata = {
4
- manifest: '/manifest.json',
4
+ manifest: '/manifest.webmanifest',
5
5
  themeColor: '#FFFFFF',
6
6
  formatDetection: {
7
7
  telephone: false
@@ -10,6 +10,7 @@ import {
10
10
  } from '@akinon/next/data/client/wishlist';
11
11
  import { Icon } from '@theme/components';
12
12
  import { pushAddToWishlist } from '@theme/utils/gtm';
13
+ import { useToast, useLocalization } from '@akinon/next/hooks';
13
14
 
14
15
  interface FavButtonProps {
15
16
  label?: string;
@@ -18,8 +19,12 @@ interface FavButtonProps {
18
19
  style?: React.CSSProperties;
19
20
  }
20
21
 
22
+ const FAV_TOAST_OPTIONS = { position: 'bottom-right' as const }
23
+
21
24
  const useFavButton = (productPk: number) => {
22
25
  const { status } = useSession();
26
+ const { t } = useLocalization();
27
+ const { success, error: showError } = useToast();
23
28
 
24
29
  const { data: favorites } = useGetFavoritesQuery(
25
30
  {
@@ -45,15 +50,17 @@ const useFavButton = (productPk: number) => {
45
50
  const handleClick = useCallback(async () => {
46
51
  try {
47
52
  if (favoriteItem) {
48
- await removeFavorite(favoriteItem.pk);
53
+ await removeFavorite(favoriteItem.pk).unwrap();
54
+ success(t('product.removed_from_favorites'), FAV_TOAST_OPTIONS);
49
55
  } else {
50
- await addFavorite(productPk);
56
+ await addFavorite(productPk).unwrap();
51
57
  setIsPushed(true);
58
+ success(t('product.added_to_favorites'), FAV_TOAST_OPTIONS);
52
59
  }
53
- } catch (error) {
54
- console.error('Failed operation:', error);
60
+ } catch (err) {
61
+ showError(t('product.favorites_error'), FAV_TOAST_OPTIONS);
55
62
  }
56
- }, [favoriteItem, productPk, addFavorite, removeFavorite]);
63
+ }, [favoriteItem, productPk, addFavorite, removeFavorite, success, showError, t]);
57
64
 
58
65
  useEffect(() => {
59
66
  if (favoriteItem && isPushed) {
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import { useCallback, useEffect, useState } from 'react';
5
+
6
+ interface BeforeInstallPromptEvent extends Event {
7
+ prompt: () => Promise<void>;
8
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
9
+ }
10
+
11
+ const DISMISS_STORAGE_KEY = 'pz-install-prompt-dismissed-at';
12
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
13
+
14
+ const CONVERSION_PATH_PATTERNS = ['/basket', '/orders/'];
15
+
16
+ const IN_APP_BROWSER_PATTERN =
17
+ /FBAN|FBAV|FB_IAB|Instagram|Twitter|Line\/|LinkedInApp|TikTok|Snapchat|; wv\)/i;
18
+ const NON_SAFARI_IOS_PATTERN = /CriOS|FxiOS|EdgiOS|OPiOS|YaBrowser|UCBrowser/i;
19
+
20
+ const safeRead = (key: string): string | null => {
21
+ try {
22
+ return window.localStorage.getItem(key);
23
+ } catch {
24
+ return null;
25
+ }
26
+ };
27
+
28
+ const safeWrite = (key: string, value: string): void => {
29
+ try {
30
+ window.localStorage.setItem(key, value);
31
+ } catch {
32
+ // Safari Private Mode, quota exceeded — fail soft
33
+ }
34
+ };
35
+
36
+ const isWithinDismissWindow = (timestamp: string | null): boolean => {
37
+ if (!timestamp) return false;
38
+ const dismissedAt = Number(timestamp);
39
+ if (!Number.isFinite(dismissedAt)) return false;
40
+ return Date.now() - dismissedAt < SEVEN_DAYS_MS;
41
+ };
42
+
43
+ const detectIOS = (): boolean => {
44
+ const ua = window.navigator.userAgent;
45
+ const isAppleMobile = /iPad|iPhone|iPod/.test(ua);
46
+ // iPadOS 13+ reports as Macintosh; disambiguate via touch points
47
+ const isIPadOSAsMac = /Mac/.test(ua) && navigator.maxTouchPoints > 1;
48
+ const isMSStream = !!(window as Window & { MSStream?: unknown }).MSStream;
49
+ return (isAppleMobile || isIPadOSAsMac) && !isMSStream;
50
+ };
51
+
52
+ const detectStandalone = (): boolean =>
53
+ window.matchMedia('(display-mode: standalone)').matches ||
54
+ (window.navigator as Navigator & { standalone?: boolean }).standalone === true;
55
+
56
+ const detectInAppBrowser = (): boolean =>
57
+ IN_APP_BROWSER_PATTERN.test(window.navigator.userAgent);
58
+
59
+ const detectNonSafariIOSBrowser = (): boolean =>
60
+ NON_SAFARI_IOS_PATTERN.test(window.navigator.userAgent);
61
+
62
+ const isConversionPath = (pathname: string): boolean =>
63
+ CONVERSION_PATH_PATTERNS.some((pattern) => pathname.includes(pattern));
64
+
65
+ export const useInstallPrompt = () => {
66
+ const pathname = usePathname() ?? '';
67
+
68
+ const [isHydrated, setIsHydrated] = useState(false);
69
+ const [deferredPrompt, setDeferredPrompt] =
70
+ useState<BeforeInstallPromptEvent | null>(null);
71
+ const [isIOS, setIsIOS] = useState(false);
72
+ const [isStandalone, setIsStandalone] = useState(false);
73
+ const [isInAppBrowser, setIsInAppBrowser] = useState(false);
74
+ const [isNonSafariIOS, setIsNonSafariIOS] = useState(false);
75
+ const [isDismissed, setIsDismissed] = useState(false);
76
+
77
+ useEffect(() => {
78
+ setIsStandalone(detectStandalone());
79
+ setIsIOS(detectIOS());
80
+ setIsInAppBrowser(detectInAppBrowser());
81
+ setIsNonSafariIOS(detectNonSafariIOSBrowser());
82
+ setIsDismissed(isWithinDismissWindow(safeRead(DISMISS_STORAGE_KEY)));
83
+ setIsHydrated(true);
84
+
85
+ const handleBeforeInstallPrompt = (event: Event) => {
86
+ event.preventDefault();
87
+ setDeferredPrompt(event as BeforeInstallPromptEvent);
88
+ };
89
+
90
+ const handleAppInstalled = () => {
91
+ setDeferredPrompt(null);
92
+ setIsStandalone(true);
93
+ };
94
+
95
+ const handleStorageChange = (event: StorageEvent) => {
96
+ if (event.key !== DISMISS_STORAGE_KEY) return;
97
+ setIsDismissed(isWithinDismissWindow(event.newValue));
98
+ };
99
+
100
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
101
+ window.addEventListener('appinstalled', handleAppInstalled);
102
+ window.addEventListener('storage', handleStorageChange);
103
+
104
+ return () => {
105
+ window.removeEventListener(
106
+ 'beforeinstallprompt',
107
+ handleBeforeInstallPrompt
108
+ );
109
+ window.removeEventListener('appinstalled', handleAppInstalled);
110
+ window.removeEventListener('storage', handleStorageChange);
111
+ };
112
+ }, []);
113
+
114
+ const dismiss = useCallback(() => {
115
+ safeWrite(DISMISS_STORAGE_KEY, Date.now().toString());
116
+ setIsDismissed(true);
117
+ }, []);
118
+
119
+ const install = useCallback(async () => {
120
+ if (!deferredPrompt) return null;
121
+ await deferredPrompt.prompt();
122
+ const { outcome } = await deferredPrompt.userChoice;
123
+ if (outcome === 'accepted') {
124
+ setDeferredPrompt(null);
125
+ } else {
126
+ dismiss();
127
+ }
128
+ return outcome;
129
+ }, [deferredPrompt, dismiss]);
130
+
131
+ const canInstall =
132
+ isHydrated &&
133
+ !isStandalone &&
134
+ !isDismissed &&
135
+ !isInAppBrowser &&
136
+ !isConversionPath(pathname) &&
137
+ (deferredPrompt !== null || (isIOS && !isNonSafariIOS));
138
+
139
+ return {
140
+ canInstall,
141
+ isIOS,
142
+ isStandalone,
143
+ install,
144
+ dismiss
145
+ };
146
+ };
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useSyncExternalStore } from 'react';
4
+
5
+ const subscribe = (callback: () => void) => {
6
+ window.addEventListener('online', callback);
7
+ window.addEventListener('offline', callback);
8
+ return () => {
9
+ window.removeEventListener('online', callback);
10
+ window.removeEventListener('offline', callback);
11
+ };
12
+ };
13
+
14
+ const getSnapshot = () => navigator.onLine;
15
+ const getServerSnapshot = () => true;
16
+
17
+ const OFFLINE_DEBOUNCE_MS = 500;
18
+
19
+ export const useOnlineStatus = () => {
20
+ const isOnlineRaw = useSyncExternalStore(
21
+ subscribe,
22
+ getSnapshot,
23
+ getServerSnapshot
24
+ );
25
+ const [isOnline, setIsOnline] = useState(isOnlineRaw);
26
+
27
+ useEffect(() => {
28
+ if (isOnlineRaw) {
29
+ setIsOnline(true);
30
+ return;
31
+ }
32
+ const timer = window.setTimeout(
33
+ () => setIsOnline(false),
34
+ OFFLINE_DEBOUNCE_MS
35
+ );
36
+ return () => window.clearTimeout(timer);
37
+ }, [isOnlineRaw]);
38
+
39
+ return isOnline;
40
+ };
@@ -1,5 +1,8 @@
1
1
  import { Product } from '@akinon/next/types/commerce/product';
2
2
 
3
+ export const serializeJsonLd = (data: object): string =>
4
+ JSON.stringify(data).replace(/</g, '\\u003c');
5
+
3
6
  export const generateJsonLd = (product: Product) => {
4
7
  const URL = process.env.NEXT_PUBLIC_URL;
5
8
 
@@ -9,7 +9,8 @@ import { useState } from 'react';
9
9
  import { useAddFavoriteMutation } from '@akinon/next/data/client/wishlist';
10
10
  import {
11
11
  useCommonProductAttributes,
12
- useLocalization
12
+ useLocalization,
13
+ useToast
13
14
  } from '@akinon/next/hooks';
14
15
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
15
16
  import { Image } from '@akinon/next/components/image';
@@ -23,6 +24,7 @@ interface Props {
23
24
 
24
25
  export const BasketItem = (props: Props) => {
25
26
  const { t } = useLocalization();
27
+ const { success, error: showError } = useToast();
26
28
  const { basketItem, namespace } = props;
27
29
  const [updateQuantityMutation] = useUpdateQuantityMutation();
28
30
  const dispatch = useAppDispatch();
@@ -78,9 +80,12 @@ export const BasketItem = (props: Props) => {
78
80
 
79
81
  if (productPk) {
80
82
  await addFavorite(productPk);
83
+ success(t('basket.card.modal.moved_to_favorites'));
84
+ } else {
85
+ success(t('basket.card.modal.removed'));
81
86
  }
82
- } catch (error) {
83
- console.error('Error in operation:', error);
87
+ } catch (err) {
88
+ showError(t('basket.card.modal.error'));
84
89
  } finally {
85
90
  setUpdateQuantityLoading(false);
86
91
  setRemoveBasketModalOpen(false);
@@ -1,8 +1,9 @@
1
1
  import React from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { Button, Icon, Modal } from '@theme/components';
4
- import { useLocalization } from '@akinon/next/hooks';
4
+ import { useLocalization, useToast } from '@akinon/next/hooks';
5
5
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
6
+ import { formatErrorMessage } from '@akinon/next/utils/format-error-message';
6
7
  import { validateVariantSelection } from '../../utils/variant-validation';
7
8
  import { VariantType } from '@akinon/next/types';
8
9
 
@@ -44,6 +45,7 @@ export const ProductActions: React.FC<ProductActionsProps> = ({
44
45
  onCloseModal
45
46
  }) => {
46
47
  const { t } = useLocalization();
48
+ const { error: showError } = useToast();
47
49
 
48
50
  const checkoutProviderProps = {
49
51
  product,
@@ -75,8 +77,7 @@ export const ProductActions: React.FC<ProductActionsProps> = ({
75
77
  Object.keys(error?.data || {}).map(
76
78
  (key) => `${key}: ${error?.data[key].join(', ')}`
77
79
  );
78
- // This would need to be handled by parent component
79
- console.error('Checkout error:', formattedError);
80
+ showError(formatErrorMessage(formattedError));
80
81
  }
81
82
  };
82
83
 
@@ -63,6 +63,26 @@ const defaultConfig = {
63
63
  transform: 'translateX(100%)'
64
64
  }
65
65
  },
66
+ 'toast-in': {
67
+ '0%': {
68
+ opacity: '0',
69
+ transform: 'translateX(100%)'
70
+ },
71
+ '100%': {
72
+ opacity: '1',
73
+ transform: 'translateX(0)'
74
+ }
75
+ },
76
+ 'toast-out': {
77
+ '0%': {
78
+ opacity: '1',
79
+ transform: 'translateX(0)'
80
+ },
81
+ '100%': {
82
+ opacity: '0',
83
+ transform: 'translateX(100%)'
84
+ }
85
+ },
66
86
  'slide-in': {
67
87
  '0%': { transform: 'translateX(100%)' },
68
88
  '100%': { transform: 'translateX(0)' }
@@ -74,6 +94,8 @@ const defaultConfig = {
74
94
  },
75
95
  animation: {
76
96
  'skeleton-shimmer': 'skeleton-shimmer 2s linear infinite',
97
+ 'toast-in': 'toast-in 0.3s ease-out forwards',
98
+ 'toast-out': 'toast-out 0.25s ease-in forwards',
77
99
  'slide-in': 'slide-in 0.4s ease-out forwards',
78
100
  'slide-out': 'slide-out 0.4s ease-out forwards'
79
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akinon/projectzero",
3
- "version": "2.0.6-rc.0",
3
+ "version": "2.0.6-rc.2",
4
4
  "private": false,
5
5
  "description": "CLI tool to manage your Project Zero Next project",
6
6
  "bin": {
@@ -1 +0,0 @@
1
- public
@@ -1,10 +0,0 @@
1
- const baseConfig = require('@akinon/next/.eslintrc')
2
-
3
- module.exports = {
4
- ...baseConfig,
5
- settings: {
6
- next: {
7
- rootDir: ['src/*/']
8
- }
9
- },
10
- };
@@ -1,35 +0,0 @@
1
- {
2
- "name": "Project Zero | Next",
3
- "short_name": "PZ | Next",
4
- "icons": [
5
- {
6
- "src": "/icon-192x192.png",
7
- "sizes": "192x192",
8
- "type": "image/png",
9
- "purpose": "maskable"
10
- },
11
- {
12
- "src": "/icon-256x256.png",
13
- "sizes": "256x256",
14
- "type": "image/png",
15
- "purpose": "any"
16
- },
17
- {
18
- "src": "/icon-384x384.png",
19
- "sizes": "384x384",
20
- "type": "image/png",
21
- "purpose": "any"
22
- },
23
- {
24
- "src": "/icon-512x512.png",
25
- "sizes": "512x512",
26
- "type": "image/png",
27
- "purpose": "any"
28
- }
29
- ],
30
- "theme_color": "#FFFFFF",
31
- "background_color": "#FFFFFF",
32
- "start_url": "/",
33
- "display": "standalone",
34
- "orientation": "portrait"
35
- }