mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Add SaaS frontend code (#5879)
# Description of Changes Adds the code for the SaaS frontend as proprietary code to the OSS repo. This version of the code is adapted from 22/1/2026, which was the last SaaS version based on the 'V2' design. This will move us closer to being able to have the OSS products understand whether the user has a SaaS account, and provide the correct UI in those cases.
This commit is contained in:
@@ -10,6 +10,8 @@ if that directory exists, is licensed under the license defined in "app/propriet
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
Generated
+568
-1
@@ -57,8 +57,10 @@
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@userback/widget": "^0.3.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.13.2",
|
||||
"d3": "^7.9.0",
|
||||
"globals": "^17.1.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -70,6 +72,7 @@
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
@@ -89,6 +92,7 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||
"@types/google.accounts": "^0.0.18",
|
||||
@@ -4561,24 +4565,173 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
@@ -4594,6 +4747,27 @@
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
@@ -4603,6 +4777,20 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
@@ -4618,12 +4806,40 @@
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -4678,6 +4894,13 @@
|
||||
"@maxim_mazurok/gapi.client.drive-v3": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/google.accounts": {
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz",
|
||||
@@ -5025,6 +5248,12 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@userback/widget": {
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@userback/widget/-/widget-0.3.12.tgz",
|
||||
"integrity": "sha512-68YihQblx1+4kFpd7e4nDBSV6ljSY0+hzIHHgbxgbJPJIhNgN4lyvOjcDbCrEb9qh6yXx5W6HBLKTMJD4hLXGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react-swc": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz",
|
||||
@@ -6300,7 +6529,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -6464,6 +6692,47 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
"d3-dsv": "3",
|
||||
"d3-ease": "3",
|
||||
"d3-fetch": "3",
|
||||
"d3-force": "3",
|
||||
"d3-format": "3",
|
||||
"d3-geo": "3",
|
||||
"d3-hierarchy": "3",
|
||||
"d3-interpolate": "3",
|
||||
"d3-path": "3",
|
||||
"d3-polygon": "3",
|
||||
"d3-quadtree": "3",
|
||||
"d3-random": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-time": "3",
|
||||
"d3-time-format": "4",
|
||||
"d3-timer": "3",
|
||||
"d3-transition": "3",
|
||||
"d3-zoom": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
@@ -6476,6 +6745,43 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-brush": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "3",
|
||||
"d3-transition": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-chord": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
@@ -6485,6 +6791,77 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
@@ -6494,6 +6871,32 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-fetch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
@@ -6503,6 +6906,27 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
@@ -6524,6 +6948,33 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-polygon": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
@@ -6540,6 +6991,28 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
@@ -6585,6 +7058,41 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
||||
@@ -6736,6 +7244,15 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -8351,6 +8868,18 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -9812,6 +10341,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/npm-normalize-package-bin": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz",
|
||||
@@ -11052,6 +11587,20 @@
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
|
||||
"integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||
@@ -11632,6 +12181,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
@@ -11677,12 +12232,24 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass-lookup": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.0.tgz",
|
||||
|
||||
+17
-4
@@ -52,8 +52,10 @@
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@userback/widget": "^0.3.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.13.2",
|
||||
"d3": "^7.9.0",
|
||||
"globals": "^17.1.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -66,6 +68,7 @@
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
@@ -79,11 +82,19 @@
|
||||
"pretauri-build": "node scripts/build-provisioner.mjs",
|
||||
"predev": "npm run generate-icons",
|
||||
"dev": "vite",
|
||||
"dev:core": "vite --mode core",
|
||||
"dev:proprietary": "vite --mode proprietary",
|
||||
"dev:saas": "vite --mode saas",
|
||||
"dev:desktop": "vite --mode desktop",
|
||||
"prebuild": "npm run generate-icons",
|
||||
"lint": "npm run lint:eslint && npm run lint:cycles",
|
||||
"lint:eslint": "eslint --max-warnings=0",
|
||||
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
|
||||
"build": "vite build",
|
||||
"build:core": "vite build --mode core",
|
||||
"build:proprietary": "vite build --mode proprietary",
|
||||
"build:saas": "vite build --mode saas",
|
||||
"build:desktop": "vite build --mode desktop",
|
||||
"preview": "vite preview",
|
||||
"tauri-dev": "tauri dev --no-watch",
|
||||
"tauri-build": "tauri build",
|
||||
@@ -93,10 +104,11 @@
|
||||
"tauri-build-dev-linux": "tauri build --bundles appimage",
|
||||
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
|
||||
"typecheck": "npm run typecheck:proprietary",
|
||||
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
|
||||
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
|
||||
"typecheck:desktop": "tsc --noEmit --project tsconfig.desktop.json",
|
||||
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:desktop",
|
||||
"typecheck:core": "tsc --noEmit --project src/core/tsconfig.json",
|
||||
"typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json",
|
||||
"typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json",
|
||||
"typecheck:desktop": "tsc --noEmit --project src/desktop/tsconfig.json",
|
||||
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop",
|
||||
"check": "npm run typecheck && npm run lint && npm run test:run",
|
||||
"generate-licenses": "node scripts/generate-licenses.js",
|
||||
"generate-icons": "node scripts/generate-icons.js",
|
||||
@@ -136,6 +148,7 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||
"@types/google.accounts": "^0.0.18",
|
||||
|
||||
@@ -142,6 +142,10 @@ welcome = "Welcome"
|
||||
white = "White"
|
||||
WorkInProgess = "Work in progress, May not work or be buggy, Please report any problems!"
|
||||
yes = "Yes"
|
||||
insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}"
|
||||
loadingCredits = "Checking credits..."
|
||||
loadingProStatus = "Checking subscription status..."
|
||||
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
|
||||
|
||||
[account]
|
||||
accountSettings = "Account Settings"
|
||||
@@ -2563,10 +2567,45 @@ title = "Quality Adjustment"
|
||||
tags = "squish,small,tiny"
|
||||
|
||||
[config.account.overview]
|
||||
confirmDelete = "Delete My Account"
|
||||
deleteAccount = "Delete Account"
|
||||
deleteAccountTitle = "Delete Account"
|
||||
deleteFailed = "Failed to delete account."
|
||||
deleteFailedTitle = "Unable to delete account"
|
||||
deleteWarning = "This action is permanent and cannot be undone. All your data will be deleted."
|
||||
enterEmailConfirm = "To confirm deletion, please type your email address ({{email}}) below:"
|
||||
guestDescription = "You are signed in as a guest. Consider upgrading your account above."
|
||||
label = "Overview"
|
||||
manageAccountPreferences = "Manage your account preferences"
|
||||
signedInAs = "Signed in as"
|
||||
title = "Account Settings"
|
||||
|
||||
[config.account.profilePicture]
|
||||
description = "Upload an image to personalize your account."
|
||||
help = "PNG, JPG, or WebP up to 2MB."
|
||||
remove = "Remove"
|
||||
sizeError = "Please select an image smaller than 2MB."
|
||||
switchedToCustom = "Switched to custom picture. You can now upload your own."
|
||||
title = "Profile picture"
|
||||
upload = "Upload"
|
||||
useCustom = "Use custom picture"
|
||||
usingProvider = "Using {{provider}} profile picture"
|
||||
|
||||
[config.account.profilePicture.cropper]
|
||||
cropError = "Failed to crop image. Please try again."
|
||||
invalidImage = "Invalid image file. Please select a valid PNG, JPG, or WebP file."
|
||||
processing = "Processing crop..."
|
||||
save = "Save Cropped Image"
|
||||
sizeErrorAfterCrop = "Cropped image is too large. Please zoom out or crop a smaller area."
|
||||
title = "Crop Profile Picture"
|
||||
zoom = "Zoom"
|
||||
|
||||
[config.account.security]
|
||||
changePassword = "Change password"
|
||||
description = "Manage your password and security settings."
|
||||
title = "Passwords & Security"
|
||||
update = "Update password"
|
||||
|
||||
[config.account.upgrade]
|
||||
description = "Link your account to preserve your history and access more features!"
|
||||
email = "Email"
|
||||
@@ -2580,9 +2619,13 @@ socialLogin = "Upgrade with Social Account"
|
||||
title = "Upgrade Guest Account"
|
||||
upgradeButton = "Upgrade Account"
|
||||
|
||||
[config]
|
||||
plan = "Plan"
|
||||
|
||||
[config.apiKeys]
|
||||
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
|
||||
copyKeyAriaLabel = "Copy API key"
|
||||
creditsRemaining = "Credits Remaining"
|
||||
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
|
||||
docsDescription = "Learn more about integrating with Stirling PDF:"
|
||||
docsLink = "API Documentation"
|
||||
@@ -3842,6 +3885,7 @@ version = "Version"
|
||||
accountCreatedSuccess = "Account created successfully! You can now sign in."
|
||||
alreadyLoggedIn = "You are already logged in to"
|
||||
alreadyLoggedIn2 = "devices. Please log out of the devices and try again."
|
||||
backToSignIn = "Back to sign in"
|
||||
cancel = "Cancel"
|
||||
changePasswordWarning = "Please change your password after logging in for the first time"
|
||||
credentialsUpdated = "Your credentials have been updated. Please sign in again."
|
||||
@@ -3883,16 +3927,21 @@ or = "Or"
|
||||
password = "Password"
|
||||
passwordChangedSuccess = "Password changed successfully! Please sign in with your new password."
|
||||
passwordResetSent = "Password reset link sent to {{email}}! Check your email and follow the instructions."
|
||||
passwordUpdatedSuccess = "Your password has been updated successfully."
|
||||
pleaseEnterBoth = "Please enter both email and password"
|
||||
pleaseEnterEmail = "Please enter your email address"
|
||||
relyingPartyRegistrationNotFound = "No relying party registration found"
|
||||
rememberme = "Remember me"
|
||||
resetHelp = "Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one."
|
||||
resetYourPassword = "Reset your password"
|
||||
saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan."
|
||||
sending = "Sending…"
|
||||
sendMagicLink = "Send Magic Link"
|
||||
sendResetLink = "Send reset link"
|
||||
sessionExpired = "Your session has expired. Please sign in again."
|
||||
signin = "Sign in"
|
||||
signInAnonymously = "Sign Up as a Guest"
|
||||
subtitle = "Sign back in to Stirling PDF"
|
||||
signingIn = "Signing in..."
|
||||
signinTitle = "Please sign in"
|
||||
signInWith = "Sign in with"
|
||||
@@ -3901,6 +3950,7 @@ ssoSignIn = "Login via Single Sign-on"
|
||||
title = "Sign in"
|
||||
toManySessions = "You have too many active sessions"
|
||||
unexpectedError = "Unexpected error: {{message}}"
|
||||
updatePassword = "Update password"
|
||||
useEmailInstead = "Login with email"
|
||||
useMagicLink = "Use magic link instead"
|
||||
userIsDisabled = "User is deactivated, login is currently blocked with this username. Please contact the administrator."
|
||||
@@ -4210,6 +4260,15 @@ viewSwitcher = "Use these controls to select how you want to view your PDFs."
|
||||
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
|
||||
wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."
|
||||
|
||||
[onboarding.freeTrial]
|
||||
afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access."
|
||||
afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends."
|
||||
body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing."
|
||||
daysRemaining = "{{days}} days remaining"
|
||||
daysRemainingSingular = "{{days}} day remaining"
|
||||
title = "Your 30-Day Pro Trial"
|
||||
trialEnds = "Trial ends {{date}}"
|
||||
|
||||
[onboarding.buttons]
|
||||
back = "Back"
|
||||
download = "Download →"
|
||||
@@ -4947,7 +5006,10 @@ perMonth = "/month"
|
||||
perSeat = "/seat"
|
||||
popular = "Popular"
|
||||
selectPlan = "Select Plan"
|
||||
selectCredits = "Select Credit Amount"
|
||||
showComparison = "Compare All Features"
|
||||
purchase = "Purchase"
|
||||
totalCost = "Total Cost"
|
||||
upgrade = "Upgrade"
|
||||
withServer = "+ Server Plan"
|
||||
|
||||
@@ -5039,6 +5101,30 @@ successMessage = "Your license has been successfully activated. You can now clos
|
||||
name = "Team"
|
||||
siteLicense = "Site License"
|
||||
|
||||
[plan.api]
|
||||
large = "5,000 Credits"
|
||||
medium = "1,000 Credits"
|
||||
small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.apiPackages]
|
||||
subtitle = "Purchase API credits for your applications"
|
||||
title = "API Credit Packages"
|
||||
|
||||
[plan.trial]
|
||||
badge = "Trial"
|
||||
continueWithFree = "Continue with Free"
|
||||
daysRemaining = "Your trial ends in {{days}} days"
|
||||
endDate = "Expires: {{date}}"
|
||||
expired = "Your Trial Has Ended"
|
||||
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
|
||||
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
|
||||
message = ""
|
||||
subscribe = "Subscribe to Pro"
|
||||
subscribeToPro = "Subscribe to Pro"
|
||||
subscriptionScheduled = "Subscription scheduled - starts {{date}}"
|
||||
title = "Free Trial Active"
|
||||
|
||||
[credits]
|
||||
enableOverageBilling = "Enable Overage Billing"
|
||||
maybeLater = "Maybe later"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Core stub for HomePage extensions.
|
||||
*/
|
||||
|
||||
export function HomePageExtensions() {
|
||||
return null;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions';
|
||||
import { useConfigButtonIcon } from '@app/hooks/useConfigButtonIcon';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -44,6 +45,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const configButtonIcon = useConfigButtonIcon();
|
||||
|
||||
const {
|
||||
tooltipOpen,
|
||||
manualCloseOnly,
|
||||
@@ -202,7 +205,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
...(shouldHideSettingsButton ? [] : [{
|
||||
id: 'config',
|
||||
name: t("quickAccess.settings", "Settings"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
icon: configButtonIcon ?? <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md' as const,
|
||||
type: 'modal' as const,
|
||||
onClick: () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
|
||||
import type { ConfigNavSection, ConfigNavItem } from '@app/components/shared/config/configNavSections';
|
||||
|
||||
interface SettingsSearchBarProps {
|
||||
configNavSections: ConfigNavSection[];
|
||||
|
||||
@@ -30,9 +30,11 @@ const BANNER_DISMISSED_KEY = "stirlingpdf_features_banner_dismissed";
|
||||
|
||||
interface GeneralSectionProps {
|
||||
hideTitle?: boolean;
|
||||
hideUpdateSection?: boolean;
|
||||
hideAdminBanner?: boolean;
|
||||
}
|
||||
|
||||
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) => {
|
||||
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false, hideUpdateSection = false, hideAdminBanner = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
@@ -156,7 +158,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginDisabled && !bannerDismissed && (
|
||||
{!hideAdminBanner && loginDisabled && !bannerDismissed && (
|
||||
<Paper withBorder p="md" radius="md" style={{ background: "var(--mantine-color-blue-0)", position: "relative" }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -215,7 +217,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
)}
|
||||
|
||||
{/* Update Check Section */}
|
||||
{config?.appVersion && (
|
||||
{!hideUpdateSection && config?.appVersion && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useCa
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
|
||||
import type { AppConfig, AppConfigBootstrapMode } from '@app/types/appConfig';
|
||||
import { useJwtConfigSync } from '@app/hooks/useJwtConfigSync';
|
||||
|
||||
/**
|
||||
* Sleep utility for delays
|
||||
@@ -41,6 +42,7 @@ export interface AppConfigProviderProps {
|
||||
initialConfig?: AppConfig | null;
|
||||
bootstrapMode?: AppConfigBootstrapMode;
|
||||
autoFetch?: boolean;
|
||||
onConfigLoaded?: (config: AppConfig) => void;
|
||||
}
|
||||
|
||||
export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
@@ -49,6 +51,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
initialConfig = null,
|
||||
bootstrapMode = 'blocking',
|
||||
autoFetch = true,
|
||||
onConfigLoaded,
|
||||
}) => {
|
||||
const isBlockingMode = bootstrapMode === 'blocking';
|
||||
const [config, setConfig] = useState<AppConfig | null>(initialConfig);
|
||||
@@ -58,6 +61,9 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode);
|
||||
const [loading, setLoading] = useState(!hasResolvedConfig);
|
||||
|
||||
const onConfigLoadedRef = React.useRef(onConfigLoaded);
|
||||
onConfigLoadedRef.current = onConfigLoaded;
|
||||
|
||||
const maxRetries = retryOptions?.maxRetries ?? 0;
|
||||
const initialDelay = retryOptions?.initialDelay ?? 1000;
|
||||
|
||||
@@ -113,6 +119,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
setConfig(data);
|
||||
setHasResolvedConfig(true);
|
||||
setLoading(false);
|
||||
onConfigLoadedRef.current?.(data);
|
||||
return; // Success - exit function
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
@@ -151,42 +158,21 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
setLoading(false);
|
||||
}, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
|
||||
// Config will be fetched after successful authentication via jwt-available event
|
||||
const currentPath = window.location.pathname;
|
||||
const isAuthPage = currentPath.includes('/login') ||
|
||||
currentPath.includes('/signup') ||
|
||||
currentPath.includes('/auth/callback') ||
|
||||
currentPath.includes('/invite/');
|
||||
const { isAuthPage } = useJwtConfigSync(fetchConfig);
|
||||
|
||||
// On auth pages, always skip the config fetch
|
||||
// The config will be fetched after authentication via jwt-available event
|
||||
useEffect(() => {
|
||||
if (isAuthPage) {
|
||||
console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: currentPath });
|
||||
console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: window.location.pathname });
|
||||
setConfig({ enableLogin: true });
|
||||
setHasResolvedConfig(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// On non-auth pages, fetch config (will validate JWT if present)
|
||||
if (autoFetch) {
|
||||
fetchConfig();
|
||||
}
|
||||
}, [autoFetch, fetchConfig]);
|
||||
|
||||
// Listen for JWT availability (triggered on login/signup)
|
||||
useEffect(() => {
|
||||
const handleJwtAvailable = () => {
|
||||
console.debug('[AppConfig] JWT available event - refetching config');
|
||||
// Force refetch with JWT
|
||||
fetchConfig(true);
|
||||
};
|
||||
|
||||
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
||||
}, [fetchConfig]);
|
||||
}, [autoFetch, fetchConfig, isAuthPage]);
|
||||
|
||||
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
|
||||
|
||||
|
||||
@@ -121,14 +121,12 @@ export const useToolOperation = <TParams>(
|
||||
? config.endpoint(params)
|
||||
: config.endpoint;
|
||||
|
||||
// Credit check for cloud operations (desktop SaaS mode only, no-op in web builds)
|
||||
if (willUseCloud && endpoint) {
|
||||
// Credit check — no-op in core builds, real check in desktop/SaaS versions
|
||||
const creditError = await checkCredits();
|
||||
if (creditError !== null) {
|
||||
actions.setError(creditError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Backend readiness check (will skip for SaaS-routed endpoints)
|
||||
const backendReady = await ensureBackendReady(endpoint);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Core stub — config button uses the default settings icon.
|
||||
*/
|
||||
export function useConfigButtonIcon(): React.ReactNode {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Core implementation: sets up the jwt-available event listener for OSS JWT auth,
|
||||
* and detects auth pages where config fetch should be skipped.
|
||||
*/
|
||||
export function useJwtConfigSync(fetchConfig: (force?: boolean) => void): { isAuthPage: boolean } {
|
||||
const currentPath = window.location.pathname;
|
||||
const isAuthPage =
|
||||
currentPath.includes('/login') ||
|
||||
currentPath.includes('/signup') ||
|
||||
currentPath.includes('/auth/callback') ||
|
||||
currentPath.includes('/invite/');
|
||||
|
||||
useEffect(() => {
|
||||
const handleJwtAvailable = () => {
|
||||
console.debug('[AppConfig] JWT available event - refetching config');
|
||||
fetchConfig(true);
|
||||
};
|
||||
|
||||
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
||||
}, [fetchConfig]);
|
||||
|
||||
return { isAuthPage };
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
|
||||
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
|
||||
|
||||
import "@app/pages/HomePage.css";
|
||||
|
||||
@@ -204,6 +205,7 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<HomePageExtensions />
|
||||
{isMobile ? (
|
||||
<div className="mobile-layout">
|
||||
<div className="mobile-toggle">
|
||||
|
||||
@@ -13,3 +13,13 @@
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"src/core/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../global.d.ts",
|
||||
"../*.js",
|
||||
"../*.ts",
|
||||
"../*.tsx",
|
||||
"."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
Stirling PDF User License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
License Scope & Usage Rights
|
||||
|
||||
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||
|
||||
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||
covering the appropriate number of licensed users.
|
||||
|
||||
Trial and Minimal Use
|
||||
|
||||
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||
|
||||
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||
|
||||
Modifications and Derivative Works
|
||||
|
||||
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||
|
||||
* May not be deployed in production environments without a valid User License;
|
||||
* May not be distributed or sublicensed;
|
||||
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||
|
||||
Prohibited Actions
|
||||
|
||||
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||
|
||||
* Use the Software in production environments;
|
||||
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||
* Remove or alter any licensing or copyright notices;
|
||||
* Circumvent access restrictions or licensing requirements.
|
||||
|
||||
Third-Party Components
|
||||
|
||||
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||
their original license terms as provided by their respective owners.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -8,6 +8,8 @@ import { SaaSTeamsSection } from '@app/components/shared/config/configSections/S
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { authService } from '@app/services/authService';
|
||||
|
||||
export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
|
||||
|
||||
/**
|
||||
* Hook version of desktop config nav sections with proper i18n support
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.proprietary.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"src/desktop/*",
|
||||
@@ -16,11 +17,11 @@
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/global.d.ts",
|
||||
"src/*.js",
|
||||
"src/*.ts",
|
||||
"src/*.tsx",
|
||||
"src/core/setupTests.ts",
|
||||
"src/desktop"
|
||||
"../global.d.ts",
|
||||
"../*.js",
|
||||
"../*.ts",
|
||||
"../*.tsx",
|
||||
"../core/setupTests.ts",
|
||||
"."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"src/proprietary/*",
|
||||
"src/core/*"
|
||||
],
|
||||
"@core/*": [
|
||||
"src/core/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../global.d.ts",
|
||||
"../*.js",
|
||||
"../*.ts",
|
||||
"../*.tsx",
|
||||
"../core/setupTests.ts",
|
||||
"."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { AppProviders } from '@app/components/AppProviders';
|
||||
import { setBaseUrl } from '@app/constants/app';
|
||||
import type { AppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { AppLayout } from '@app/components/AppLayout';
|
||||
import { LoadingFallback } from '@app/components/shared/LoadingFallback';
|
||||
import OnboardingTour from '@app/components/onboarding/OnboardingTour';
|
||||
import Landing from '@app/routes/Landing';
|
||||
import Login from '@app/routes/Login';
|
||||
import Signup from '@app/routes/Signup';
|
||||
import AuthCallback from '@app/routes/AuthCallback';
|
||||
import ResetPassword from '@app/routes/ResetPassword';
|
||||
import OnboardingBootstrap from '@app/components/OnboardingBootstrap';
|
||||
import TrialExpiredBootstrap from '@app/components/TrialExpiredBootstrap';
|
||||
|
||||
// Import global styles
|
||||
import '@app/styles/tailwind.css';
|
||||
import '@app/styles/saas-theme.css';
|
||||
import '@app/styles/cookieconsent.css';
|
||||
import '@app/styles/index.css';
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import '@app/utils/fileIdSafety';
|
||||
|
||||
function handleConfigLoaded(config: AppConfig) {
|
||||
if (config.baseUrl) setBaseUrl(config.baseUrl);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}>
|
||||
<AppLayout>
|
||||
<OnboardingBootstrap />
|
||||
<TrialExpiredBootstrap />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/auth/reset" element={<ResetPassword />} />
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<OnboardingTour />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
Stirling PDF User License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
License Scope & Usage Rights
|
||||
|
||||
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||
|
||||
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||
covering the appropriate number of licensed users.
|
||||
|
||||
Trial and Minimal Use
|
||||
|
||||
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||
|
||||
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||
|
||||
Modifications and Derivative Works
|
||||
|
||||
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||
|
||||
* May not be deployed in production environments without a valid User License;
|
||||
* May not be distributed or sublicensed;
|
||||
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||
|
||||
Prohibited Actions
|
||||
|
||||
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||
|
||||
* Use the Software in production environments;
|
||||
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||
* Remove or alter any licensing or copyright notices;
|
||||
* Circumvent access restrictions or licensing requirements.
|
||||
|
||||
Third-Party Components
|
||||
|
||||
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||
their original license terms as provided by their respective owners.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,583 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'
|
||||
import { supabase } from '@app/auth/supabase'
|
||||
import type { Session, User as SupabaseUser, AuthError } from '@supabase/supabase-js'
|
||||
import { CreditSummary, SubscriptionInfo, CreditCheckResult, ApiCredits } from '@app/types/credits'
|
||||
import apiClient, { setGlobalCreditUpdateCallback } from '@app/services/apiClient'
|
||||
import { synchronizeUserUpgrade } from '@app/services/userService'
|
||||
import { syncOAuthAvatar, getProfilePictureMetadata, type ProfilePictureMetadata } from '@app/services/avatarSyncService'
|
||||
|
||||
// Extend Supabase User to include optional username for compatibility
|
||||
export type User = SupabaseUser & { username?: string };
|
||||
|
||||
export interface TrialStatus {
|
||||
isTrialing: boolean
|
||||
trialEnd: string
|
||||
daysRemaining: number
|
||||
hasPaymentMethod: boolean
|
||||
hasScheduledSub: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
session: Session | null
|
||||
user: User | null
|
||||
loading: boolean
|
||||
error: AuthError | null
|
||||
creditBalance: number | null
|
||||
subscription: SubscriptionInfo | null
|
||||
creditSummary: CreditSummary | null
|
||||
isPro: boolean | null
|
||||
trialStatus: TrialStatus | null
|
||||
profilePictureUrl: string | null
|
||||
profilePictureMetadata: ProfilePictureMetadata | null
|
||||
signOut: () => Promise<void>
|
||||
refreshSession: () => Promise<void>
|
||||
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult
|
||||
updateCredits: (newBalance: number) => void
|
||||
refreshCredits: () => Promise<void>
|
||||
refreshProStatus: () => Promise<void>
|
||||
refreshTrialStatus: () => Promise<void>
|
||||
refreshProfilePicture: () => Promise<void>
|
||||
refreshProfilePictureMetadata: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
session: null,
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
creditBalance: null,
|
||||
subscription: null,
|
||||
creditSummary: null,
|
||||
isPro: null,
|
||||
trialStatus: null,
|
||||
profilePictureUrl: null,
|
||||
profilePictureMetadata: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
hasSufficientCredits: () => ({ hasSufficientCredits: false, currentBalance: 0, requiredCredits: 0 }),
|
||||
updateCredits: () => {},
|
||||
refreshCredits: async () => {},
|
||||
refreshProStatus: async () => {},
|
||||
refreshTrialStatus: async () => {},
|
||||
refreshProfilePicture: async () => {},
|
||||
refreshProfilePictureMetadata: async () => {}
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<AuthError | null>(null)
|
||||
const [creditBalance, setCreditBalance] = useState<number | null>(null)
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
|
||||
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(null)
|
||||
const [isPro, setIsPro] = useState<boolean | null>(null)
|
||||
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null)
|
||||
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(null)
|
||||
const [profilePictureMetadata, setProfilePictureMetadata] = useState<ProfilePictureMetadata | null>(null)
|
||||
|
||||
const fetchCredits = useCallback(async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug('[Auth Debug] No user session, skipping credit fetch')
|
||||
setCreditBalance(null)
|
||||
setCreditSummary(null)
|
||||
setSubscription(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug('[Auth Debug] Fetching credits for user:', currentSession.user.id)
|
||||
const response = await apiClient.get<ApiCredits>('/api/v1/credits')
|
||||
const apiCredits = response.data
|
||||
|
||||
// Map server payload to app CreditSummary
|
||||
const credits: CreditSummary = {
|
||||
currentCredits: apiCredits.totalAvailableCredits,
|
||||
maxCredits: apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
|
||||
creditsUsed: (apiCredits.weeklyCreditsAllocated - apiCredits.weeklyCreditsRemaining) + (apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
|
||||
creditsRemaining: apiCredits.totalAvailableCredits,
|
||||
resetDate: apiCredits.weeklyResetDate,
|
||||
weeklyAllowance: apiCredits.weeklyCreditsAllocated
|
||||
}
|
||||
|
||||
setCreditSummary(credits)
|
||||
setCreditBalance(credits.creditsRemaining)
|
||||
|
||||
const subscriptionInfo: SubscriptionInfo = {
|
||||
status: 'active',
|
||||
tier: (credits.weeklyAllowance || 0) > 100 ? 'premium' : 'free',
|
||||
creditsPerWeek: credits.weeklyAllowance,
|
||||
maxCredits: credits.maxCredits
|
||||
}
|
||||
setSubscription(subscriptionInfo)
|
||||
|
||||
console.debug('[Auth Debug] Credits fetched successfully:', credits)
|
||||
} catch (error: any) {
|
||||
console.debug('[Auth Debug] Failed to fetch credits:', error)
|
||||
// Don't set error state for credit fetching failures to avoid disrupting auth flow
|
||||
// Credits might not be available in all deployments
|
||||
setCreditBalance(null)
|
||||
setCreditSummary(null)
|
||||
setSubscription(null)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const refreshCredits = useCallback(async () => {
|
||||
await fetchCredits()
|
||||
}, [fetchCredits])
|
||||
|
||||
const fetchProStatus = useCallback(async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug('[Auth Debug] No user session, skipping pro status fetch')
|
||||
setIsPro(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug('[Auth Debug] Fetching pro status for user:', currentSession.user.id)
|
||||
const { data: proStatus, error } = await supabase.rpc('is_pro')
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth Debug] Error checking Pro status:', error)
|
||||
setIsPro(false) // Default to false if there's an error
|
||||
} else {
|
||||
const isProUser = Boolean(proStatus)
|
||||
setIsPro(isProUser)
|
||||
console.debug('[Auth Debug] Pro status fetched:', isProUser)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.debug('[Auth Debug] Failed to fetch pro status:', error)
|
||||
setIsPro(false) // Default to false if there's an error
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const refreshProStatus = useCallback(async () => {
|
||||
await fetchProStatus()
|
||||
}, [fetchProStatus])
|
||||
|
||||
const fetchTrialStatus = useCallback(async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug('[Auth Debug] No user session, skipping trial status fetch')
|
||||
setTrialStatus(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug('[Auth Debug] Fetching trial status for user:', currentSession.user.id)
|
||||
const { data, error } = await supabase
|
||||
.from('billing_subscriptions')
|
||||
.select('status, trial_end, has_payment_method, scheduled_subscription_id')
|
||||
.in('status', ['trialing', 'incomplete_expired', 'canceled'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth Debug] Error fetching trial status:', error)
|
||||
setTrialStatus(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.trial_end) {
|
||||
const trialEnd = new Date(data.trial_end)
|
||||
const now = new Date()
|
||||
const daysRemaining = Math.ceil((trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
setTrialStatus({
|
||||
isTrialing: data.status === 'trialing' && daysRemaining > 0,
|
||||
trialEnd: data.trial_end,
|
||||
daysRemaining: Math.max(0, daysRemaining),
|
||||
hasPaymentMethod: data.has_payment_method || false,
|
||||
hasScheduledSub: !!data.scheduled_subscription_id,
|
||||
status: data.status
|
||||
})
|
||||
console.debug('[Auth Debug] Trial status fetched:', {
|
||||
status: data.status,
|
||||
daysRemaining: Math.max(0, daysRemaining),
|
||||
hasPaymentMethod: data.has_payment_method,
|
||||
isTrialing: data.status === 'trialing' && daysRemaining > 0
|
||||
})
|
||||
} else {
|
||||
setTrialStatus(null)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.debug('[Auth Debug] Failed to fetch trial status:', error)
|
||||
setTrialStatus(null)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const refreshTrialStatus = useCallback(async () => {
|
||||
await fetchTrialStatus()
|
||||
}, [fetchTrialStatus])
|
||||
|
||||
const fetchProfilePicture = useCallback(async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug('[Auth Debug] No user session, skipping profile picture fetch')
|
||||
setProfilePictureUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const PROFILE_BUCKET = 'profile-pictures'
|
||||
const profilePath = `${currentSession.user.id}/avatar`
|
||||
|
||||
console.debug('[Auth Debug] Fetching profile picture for user:', currentSession.user.id)
|
||||
const { data, error } = await supabase
|
||||
.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.createSignedUrl(profilePath, 60 * 60)
|
||||
|
||||
if (error) {
|
||||
// Profile picture not found is expected for users without uploads
|
||||
console.debug('[Auth Debug] Profile picture not available:', error.message)
|
||||
setProfilePictureUrl(null)
|
||||
} else {
|
||||
setProfilePictureUrl(data.signedUrl)
|
||||
console.debug('[Auth Debug] Profile picture URL fetched successfully')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.debug('[Auth Debug] Failed to fetch profile picture:', error)
|
||||
setProfilePictureUrl(null)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const refreshProfilePicture = useCallback(async () => {
|
||||
await fetchProfilePicture()
|
||||
}, [fetchProfilePicture])
|
||||
|
||||
const fetchProfilePictureMetadata = useCallback(async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug('[Auth Debug] No user session, skipping profile picture metadata fetch')
|
||||
setProfilePictureMetadata(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug('[Auth Debug] Fetching profile picture metadata for user:', currentSession.user.id)
|
||||
const metadata = await getProfilePictureMetadata(currentSession.user.id)
|
||||
setProfilePictureMetadata(metadata)
|
||||
console.debug('[Auth Debug] Profile picture metadata fetched:', metadata)
|
||||
} catch (error: any) {
|
||||
console.debug('[Auth Debug] Failed to fetch profile picture metadata:', error)
|
||||
setProfilePictureMetadata(null)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const refreshProfilePictureMetadata = useCallback(async () => {
|
||||
await fetchProfilePictureMetadata()
|
||||
}, [fetchProfilePictureMetadata])
|
||||
|
||||
const updateCredits = useCallback((newBalance: number) => {
|
||||
console.debug('[Auth Debug] Updating credit balance:', { from: creditBalance, to: newBalance })
|
||||
setCreditBalance(newBalance)
|
||||
// Also update the creditSummary if it exists
|
||||
if (creditSummary) {
|
||||
const updatedSummary: CreditSummary = {
|
||||
...creditSummary,
|
||||
creditsRemaining: newBalance,
|
||||
currentCredits: newBalance
|
||||
}
|
||||
setCreditSummary(updatedSummary)
|
||||
}
|
||||
}, [creditSummary])
|
||||
|
||||
const hasSufficientCredits = useCallback((requiredCredits: number): CreditCheckResult => {
|
||||
const currentBalance = creditBalance ?? 0
|
||||
const hasSufficient = currentBalance >= requiredCredits
|
||||
console.debug('[Auth Debug] Credit check:', { requiredCredits, currentBalance, hasSufficient })
|
||||
|
||||
return {
|
||||
hasSufficientCredits: hasSufficient,
|
||||
currentBalance,
|
||||
requiredCredits,
|
||||
shortfall: hasSufficient ? undefined : requiredCredits - currentBalance
|
||||
}
|
||||
}, [creditBalance])
|
||||
|
||||
const refreshSession = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const { data, error } = await supabase.auth.refreshSession()
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth Debug] Session refresh error:', error)
|
||||
setError(error)
|
||||
setSession(null)
|
||||
} else {
|
||||
console.debug('[Auth Debug] Session refreshed successfully')
|
||||
setSession(data.session)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth Debug] Unexpected error during session refresh:', err)
|
||||
setError(err as AuthError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const { error } = await supabase.auth.signOut()
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth Debug] Sign out error:', error)
|
||||
setError(error)
|
||||
} else {
|
||||
console.debug('[Auth Debug] Signed out successfully')
|
||||
setSession(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth Debug] Unexpected error during sign out:', err)
|
||||
setError(err as AuthError)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up global credit update callback
|
||||
useEffect(() => {
|
||||
setGlobalCreditUpdateCallback(updateCredits)
|
||||
}, [updateCredits])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
// Load current session on first mount
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
console.debug('[Auth Debug] Initializing auth...')
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
if (error) {
|
||||
console.error('[Auth Debug] Initial session error:', error)
|
||||
setError(error)
|
||||
} else {
|
||||
console.debug('[Auth Debug] Initial session loaded:', {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email
|
||||
})
|
||||
setSession(data.session)
|
||||
|
||||
// Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response
|
||||
if (data.session?.user) {
|
||||
// Sync OAuth avatar in background
|
||||
syncOAuthAvatar(data.session.user).catch((err) => {
|
||||
console.debug('[Auth Debug] Failed to sync OAuth avatar on init:', err)
|
||||
})
|
||||
|
||||
await fetchCredits(data.session)
|
||||
await fetchProStatus(data.session)
|
||||
await fetchTrialStatus(data.session)
|
||||
await fetchProfilePictureMetadata(data.session)
|
||||
|
||||
// Small delay to allow avatar sync to complete if quick
|
||||
setTimeout(() => {
|
||||
fetchProfilePicture(data.session)
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth Debug] Unexpected error during auth initialization:', err)
|
||||
if (mounted) {
|
||||
setError(err as AuthError)
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeAuth()
|
||||
|
||||
// Subscribe to auth state changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
async (event, newSession) => {
|
||||
if (!mounted) return
|
||||
|
||||
console.debug('[Auth Debug] Auth state change:', {
|
||||
event,
|
||||
hasSession: !!newSession,
|
||||
userId: newSession?.user?.id,
|
||||
email: newSession?.user?.email,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Don't run supabase calls inside this callback; schedule them
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setSession(newSession)
|
||||
setError(null)
|
||||
|
||||
// Additional handling for specific events
|
||||
if (event === 'SIGNED_OUT') {
|
||||
console.debug('[Auth Debug] User signed out, clearing session')
|
||||
// Clear credit data, pro status, trial status, profile picture, and metadata on sign out
|
||||
setCreditBalance(null)
|
||||
setCreditSummary(null)
|
||||
setSubscription(null)
|
||||
setIsPro(null)
|
||||
setTrialStatus(null)
|
||||
setProfilePictureUrl(null)
|
||||
setProfilePictureMetadata(null)
|
||||
} else if (event === 'SIGNED_IN') {
|
||||
console.debug('[Auth Debug] User signed in successfully')
|
||||
if (newSession?.user) {
|
||||
setLoading(true)
|
||||
|
||||
// Sync OAuth avatar in background (don't block other fetches)
|
||||
syncOAuthAvatar(newSession.user).catch((err) => {
|
||||
console.debug('[Auth Debug] Failed to sync OAuth avatar:', err)
|
||||
})
|
||||
|
||||
// Fetch user data in parallel
|
||||
Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
]).then(() => {
|
||||
// Fetch profile picture AFTER sync has had time to complete
|
||||
// Use a small delay to allow avatar sync to finish if it's quick
|
||||
setTimeout(() => {
|
||||
fetchProfilePicture(newSession).finally(() => {
|
||||
setLoading(false)
|
||||
console.debug('[Auth Debug] User data fully loaded after sign in')
|
||||
})
|
||||
}, 500)
|
||||
})
|
||||
}
|
||||
} else if (event === 'TOKEN_REFRESHED') {
|
||||
console.debug('[Auth Debug] Token refreshed')
|
||||
// Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh
|
||||
if (newSession?.user) {
|
||||
Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession)
|
||||
]).then(() => {
|
||||
console.debug('[Auth Debug] User data refreshed after token refresh')
|
||||
})
|
||||
}
|
||||
} else if (event === 'USER_UPDATED') {
|
||||
console.debug('[Auth Debug] User updated')
|
||||
|
||||
// Check if this is a pending OAuth upgrade completion
|
||||
const pendingUpgrade = sessionStorage.getItem('pendingUpgrade')
|
||||
const upgradeProvider = sessionStorage.getItem('upgradeProvider')
|
||||
|
||||
if (pendingUpgrade && newSession?.user && newSession.user.is_anonymous === false) {
|
||||
console.debug('[Auth Debug] Processing pending OAuth upgrade:', upgradeProvider)
|
||||
|
||||
// Clear the flags first to prevent loops
|
||||
sessionStorage.removeItem('pendingUpgrade')
|
||||
sessionStorage.removeItem('upgradeProvider')
|
||||
|
||||
// Synchronize with backend
|
||||
synchronizeUserUpgrade(upgradeProvider || undefined)
|
||||
.then(() => {
|
||||
console.debug('[Auth Debug] User upgrade synchronized successfully')
|
||||
|
||||
// Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade
|
||||
if (newSession?.user) {
|
||||
return Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession)
|
||||
])
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
console.debug('[Auth Debug] User data refreshed after upgrade')
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Auth Debug] Failed to synchronize user upgrade:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value: AuthContextType = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
loading,
|
||||
error,
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
trialStatus,
|
||||
profilePictureUrl,
|
||||
profilePictureMetadata,
|
||||
signOut,
|
||||
refreshSession,
|
||||
hasSufficientCredits,
|
||||
updateCredits,
|
||||
refreshCredits,
|
||||
refreshProStatus,
|
||||
refreshTrialStatus,
|
||||
refreshProfilePicture,
|
||||
refreshProfilePictureMetadata
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
// Debug hook to expose auth state for debugging
|
||||
export function useAuthDebug() {
|
||||
const auth = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
console.debug('[Auth Debug] Current auth state:', {
|
||||
hasSession: !!auth.session,
|
||||
hasUser: !!auth.user,
|
||||
loading: auth.loading,
|
||||
hasError: !!auth.error,
|
||||
userId: auth.user?.id,
|
||||
email: auth.user?.email,
|
||||
provider: auth.user?.app_metadata?.provider
|
||||
})
|
||||
}, [auth.session, auth.user, auth.loading, auth.error])
|
||||
|
||||
return auth
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Debug helper to log Supabase configuration
|
||||
const debugConfig = () => {
|
||||
const url = import.meta.env.VITE_SUPABASE_URL
|
||||
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY
|
||||
|
||||
console.log('[Supabase Debug] Configuration:', {
|
||||
url: url ? '✓ URL configured' : '✗ URL missing',
|
||||
key: key ? '✓ Key configured' : '✗ Key missing',
|
||||
urlValue: url || 'undefined',
|
||||
keyValue: key ? `${key.substring(0, 20)}...` : 'undefined'
|
||||
})
|
||||
|
||||
return { url, key }
|
||||
}
|
||||
|
||||
const config = debugConfig()
|
||||
|
||||
if (!config.url) {
|
||||
throw new Error('Missing VITE_SUPABASE_URL environment variable')
|
||||
}
|
||||
|
||||
if (!config.key) {
|
||||
throw new Error('Missing VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY environment variable')
|
||||
}
|
||||
|
||||
export const supabase = createClient(
|
||||
config.url,
|
||||
config.key,
|
||||
{
|
||||
auth: {
|
||||
persistSession: true, // keep session in localStorage
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: true, // helpful on first load after redirect
|
||||
// debug: import.meta.env.DEV, // Enable debug logs in development
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Debug helper for auth events
|
||||
export const debugAuthEvents = () => {
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log('[Supabase Debug] Auth state change:', {
|
||||
event,
|
||||
hasSession: !!session,
|
||||
userId: session?.user?.id,
|
||||
email: session?.user?.email,
|
||||
provider: session?.user?.app_metadata?.provider,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Debug auth events can be manually enabled by calling debugAuthEvents()
|
||||
// Commented out to prevent excessive logging on every page load
|
||||
// if (import.meta.env.DEV) {
|
||||
// debugAuthEvents()
|
||||
// }
|
||||
|
||||
// Anonymous authentication functions
|
||||
export const signInAnonymously = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInAnonymously()
|
||||
|
||||
if (error) {
|
||||
console.error('[Supabase] Anonymous sign-in error:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
console.log('[Supabase] Anonymous sign-in successful:', {
|
||||
userId: data.user?.id,
|
||||
isAnonymous: data.user?.is_anonymous
|
||||
})
|
||||
|
||||
return { data, error }
|
||||
} catch (error) {
|
||||
console.error('[Supabase] Anonymous sign-in failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Account linking functions
|
||||
export const linkEmailIdentity = async (email: string, password?: string) => {
|
||||
try {
|
||||
const updateData: any = { email }
|
||||
if (password) {
|
||||
updateData.password = password
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.updateUser(updateData)
|
||||
|
||||
if (error) {
|
||||
console.error('[Supabase] Email linking error:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Refresh session to get updated token with new user metadata
|
||||
const { error: refreshError } = await supabase.auth.refreshSession()
|
||||
|
||||
if (refreshError) {
|
||||
console.warn('[Supabase] Session refresh after email linking failed:', refreshError)
|
||||
// Don't throw - linking was successful, refresh is just for consistency
|
||||
} else {
|
||||
console.log('[Supabase] Session refreshed after email linking')
|
||||
}
|
||||
|
||||
console.log('[Supabase] Email linked successfully:', {
|
||||
email,
|
||||
userId: data.user?.id
|
||||
})
|
||||
|
||||
return { data, error }
|
||||
} catch (error) {
|
||||
console.error('[Supabase] Email linking failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const linkOAuthIdentity = async (provider: 'google' | 'github' | 'apple' | 'azure', redirectTo?: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.linkIdentity({
|
||||
provider: provider,
|
||||
options: redirectTo ? { redirectTo } : undefined
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Supabase] OAuth linking error:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
console.log('[Supabase] OAuth identity linked successfully:', {
|
||||
provider,
|
||||
redirectTo,
|
||||
url: data.url
|
||||
})
|
||||
|
||||
return { data, error }
|
||||
} catch (error) {
|
||||
console.error('[Supabase] OAuth linking failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if user is anonymous
|
||||
export const isUserAnonymous = (user: any) => {
|
||||
return user?.is_anonymous === true
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
export const getCurrentUser = async () => {
|
||||
try {
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
|
||||
if (error) {
|
||||
console.error('[Supabase] Get user error:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
console.error('[Supabase] Get user failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import SaasOnboardingModal from '@app/components/onboarding/SaasOnboardingModal';
|
||||
|
||||
const STORAGE_KEY = 'saas_onboarding_seen';
|
||||
const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
|
||||
|
||||
/**
|
||||
* SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed,
|
||||
* and show SaaS-specific onboarding on first login.
|
||||
*/
|
||||
export default function OnboardingBootstrap() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding();
|
||||
const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [pollAttempts, setPollAttempts] = useState(0);
|
||||
|
||||
// Start polling when user logs in
|
||||
useEffect(() => {
|
||||
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
|
||||
if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
|
||||
console.debug('[Onboarding] Starting poll for trial data');
|
||||
setIsPolling(true);
|
||||
setPollAttempts(0);
|
||||
}
|
||||
}, [user, loading, isPolling, showModal]);
|
||||
|
||||
// Poll for trial data
|
||||
useEffect(() => {
|
||||
if (!isPolling) return;
|
||||
|
||||
const pollInterval = 500; // Check every 500ms
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const newAttempts = pollAttempts + 1;
|
||||
console.debug('[Onboarding] Polling for trial data, attempt:', newAttempts);
|
||||
|
||||
await refreshTrialStatus();
|
||||
setPollAttempts(newAttempts);
|
||||
|
||||
// Check will happen in the next effect
|
||||
}, pollInterval);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isPolling, pollAttempts, refreshTrialStatus]);
|
||||
|
||||
// Stop polling when data arrives or timeout
|
||||
useEffect(() => {
|
||||
if (!isPolling) return;
|
||||
|
||||
const hasData = trialStatus !== undefined && trialStatus !== null;
|
||||
const hasProStatus = isPro !== null;
|
||||
const maxAttempts = 10;
|
||||
|
||||
if (hasData || pollAttempts >= maxAttempts) {
|
||||
console.debug('[Onboarding] Trial data ready or timeout, showing modal', {
|
||||
hasData,
|
||||
hasProStatus,
|
||||
attempts: pollAttempts,
|
||||
trialStatus,
|
||||
isPro
|
||||
});
|
||||
setIsPolling(false);
|
||||
setShowModal(true);
|
||||
}
|
||||
}, [isPolling, trialStatus, isPro, pollAttempts]);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
// Keep existing logic to disable core onboarding flags
|
||||
useEffect(() => {
|
||||
// Ensure tool panel preference is set so tours are never deferred.
|
||||
if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
|
||||
updatePreference('toolPanelModePromptSeen', true);
|
||||
updatePreference('hasSelectedToolPanelMode', true);
|
||||
}
|
||||
|
||||
// Clear any lingering deferred tour requests.
|
||||
clearPendingTourRequest();
|
||||
setStartAfterToolModeSelection(false);
|
||||
|
||||
// In SaaS, skip the core intro onboarding entirely.
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
// Also mark completed to avoid follow-up banners/modals.
|
||||
if (!preferences.hasCompletedOnboarding) {
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
}
|
||||
|
||||
// Also clear any session flag that might mark onboarding as active.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
|
||||
}
|
||||
}, [
|
||||
preferences.hasSelectedToolPanelMode,
|
||||
preferences.toolPanelModePromptSeen,
|
||||
preferences.hasSeenIntroOnboarding,
|
||||
preferences.hasCompletedOnboarding,
|
||||
updatePreference,
|
||||
clearPendingTourRequest,
|
||||
setStartAfterToolModeSelection,
|
||||
]);
|
||||
|
||||
// Only render modal when it should be shown to avoid running hooks unnecessarily
|
||||
return showModal ? <SaasOnboardingModal opened={showModal} onClose={handleClose} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { TrialExpiredModal } from '@app/components/shared/TrialExpiredModal';
|
||||
import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
|
||||
|
||||
/**
|
||||
* Bootstrap component that shows the trial expired modal when a user's trial has ended
|
||||
* and they haven't added a payment method. Shows once per user per expired trial.
|
||||
*/
|
||||
export default function TrialExpiredBootstrap() {
|
||||
const { user, trialStatus, isPro } = useAuth();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [checkoutOpened, setCheckoutOpened] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Close modal if user logs out or session expires
|
||||
if (!user) {
|
||||
if (showModal) {
|
||||
console.debug('[TrialExpired] User logged out, closing modal');
|
||||
setShowModal(false);
|
||||
}
|
||||
if (checkoutOpened) {
|
||||
setCheckoutOpened(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check conditions when auth is fully loaded
|
||||
if (trialStatus === null || isPro === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build localStorage key unique to this user
|
||||
const storageKey = `trialExpiredModalShown_${user.id}`;
|
||||
const hasSeenModal = localStorage.getItem(storageKey) === 'true';
|
||||
|
||||
// If user is currently trialing, clear any previous "seen" flag
|
||||
// This handles the edge case where a user might re-enter a trial
|
||||
if (trialStatus.isTrialing) {
|
||||
if (hasSeenModal) {
|
||||
console.debug('[TrialExpired] User is trialing, clearing seen flag');
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all conditions are met to show the modal
|
||||
const isExpired =
|
||||
trialStatus.status === 'incomplete_expired' || trialStatus.status === 'canceled';
|
||||
const hasNoPayment = !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub;
|
||||
const wasDowngraded = !isPro;
|
||||
const trialEndedRecently = trialStatus.daysRemaining === 0;
|
||||
|
||||
const shouldShowModal =
|
||||
isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal;
|
||||
|
||||
if (shouldShowModal) {
|
||||
console.debug('[TrialExpired] Showing trial expired modal', {
|
||||
status: trialStatus.status,
|
||||
daysRemaining: trialStatus.daysRemaining,
|
||||
hasPaymentMethod: trialStatus.hasPaymentMethod,
|
||||
hasScheduledSub: trialStatus.hasScheduledSub,
|
||||
isPro,
|
||||
});
|
||||
setShowModal(true);
|
||||
}
|
||||
}, [user, trialStatus, isPro, showModal, checkoutOpened]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (user) {
|
||||
const storageKey = `trialExpiredModalShown_${user.id}`;
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
console.debug('[TrialExpired] Modal dismissed, marking as seen');
|
||||
}
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleSubscribe = () => {
|
||||
console.debug('[TrialExpired] User clicked Subscribe to Pro');
|
||||
setCheckoutOpened(true);
|
||||
};
|
||||
|
||||
const handleCheckoutSuccess = () => {
|
||||
console.debug('[TrialExpired] Subscription successful, refreshing page');
|
||||
// Close modal and refresh to update subscription status
|
||||
handleClose();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleCheckoutClose = () => {
|
||||
console.debug('[TrialExpired] Checkout closed');
|
||||
setCheckoutOpened(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrialExpiredModal
|
||||
opened={showModal && !checkoutOpened}
|
||||
onClose={handleClose}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<StripeCheckout
|
||||
opened={checkoutOpened}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType="subscription"
|
||||
planId="pro"
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) => console.error('[TrialExpired] Checkout error:', error)}
|
||||
isTrialConversion={false} // Trial already ended, so this is not a conversion
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
.guest-banner {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 4.5rem;
|
||||
z-index: 1000;
|
||||
background: var(--modal-content-bg, #111418);
|
||||
border: 1px solid var(--api-keys-card-border, rgba(255,255,255,0.08));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.35);
|
||||
padding: 12px 16px;
|
||||
max-width: 30rem;
|
||||
width: 30rem;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.guest-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guest-banner-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.guest-banner-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-text);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.guest-banner-message {
|
||||
font-size: 12px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guest-banner-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guest-banner-dismiss {
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guest-banner-dismiss:hover {
|
||||
background: var(--mantine-color-gray-1, rgba(255,255,255,0.05));
|
||||
}
|
||||
|
||||
.guest-banner-signup {
|
||||
white-space: nowrap;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--blue-6, #2563eb);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guest-banner-signup:hover {
|
||||
background: var(--blue-7, #1d4ed8);
|
||||
}
|
||||
|
||||
.guest-banner-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.guest-banner-signup-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { isUserAnonymous } from '@app/auth/supabase'
|
||||
import { withBasePath } from '@app/constants/app'
|
||||
import '@app/components/auth/GuestUserBanner.css'
|
||||
|
||||
interface GuestUserBannerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Ensure the toast only appears once per full page load, not on re-hydration
|
||||
let hasShownThisLoad = false
|
||||
|
||||
/**
|
||||
* Guest user toast encouraging account creation.
|
||||
* Appears 2s after load, top-right of the viewport, offset by right rail.
|
||||
*/
|
||||
export function GuestUserBanner({ className = '' }: GuestUserBannerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { session } = useAuth()
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const isAnon = Boolean(session?.user && isUserAnonymous(session.user))
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnon || hasShownThisLoad) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(true)
|
||||
hasShownThisLoad = true
|
||||
}, 2000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [isAnon])
|
||||
|
||||
if (!isAnon || isDismissed || !visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSignUp = () => {
|
||||
window.location.href = withBasePath('/signup')
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`guest-banner ${className || ''}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="guest-banner-content">
|
||||
<div className="guest-banner-text">
|
||||
<div className="guest-banner-title">
|
||||
{t('guestBanner.title', "You're using Stirling PDF as a guest!")}
|
||||
</div>
|
||||
<div className="guest-banner-message">
|
||||
{t('guestBanner.message', 'Create a free account to save your work, access more features, and support the project.')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="guest-banner-actions">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
aria-label={t('guestBanner.dismiss', 'Dismiss banner')}
|
||||
className="guest-banner-dismiss"
|
||||
>
|
||||
<CloseIcon className="guest-banner-icon" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignUp}
|
||||
className="guest-banner-signup"
|
||||
>
|
||||
<PersonAddIcon className="guest-banner-signup-icon" />
|
||||
{t('guestBanner.signUp', 'Sign Up Free')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GuestUserBanner
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
|
||||
|
||||
interface RequireAuthProps {
|
||||
fallbackPath?: string
|
||||
}
|
||||
|
||||
export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
|
||||
const { session, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
const { isAutoAuthenticating } = useAutoAnonymousAuth()
|
||||
|
||||
// Safe development-only auth bypass
|
||||
const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
|
||||
const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true')
|
||||
|
||||
if (devBypassEnabled) {
|
||||
console.warn('[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost')
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
// Wait for both auth bootstrap and auto-anon to finish
|
||||
if (loading || isAutoAuthenticating) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin h-6 w-6 mx-auto mb-3 border-2 border-gray-300 border-t-transparent rounded-full" />
|
||||
<p className="text-gray-600">Preparing your session…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Change the URL to /login
|
||||
return <Navigate to={fallbackPath} replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
// Render protected routes
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export default RequireAuth
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Userback from '@userback/widget';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
|
||||
interface UserbackWidgetProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface UserbackInstance {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export default function UserbackWidget({ token }: UserbackWidgetProps) {
|
||||
const { user } = useAuth();
|
||||
const userbackRef = useRef<UserbackInstance | null>(null);
|
||||
const initializingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || initializingRef.current) return;
|
||||
|
||||
initializingRef.current = true;
|
||||
|
||||
const initializeUserback = async () => {
|
||||
try {
|
||||
// Prepare user data options
|
||||
const userInfo: { name?: string; email?: string } = {};
|
||||
if (user.user_metadata?.full_name) userInfo.name = user.user_metadata.full_name;
|
||||
if (user.email) userInfo.email = user.email;
|
||||
|
||||
const options = {
|
||||
user_data: {
|
||||
id: user.id,
|
||||
info: userInfo
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize Userback
|
||||
userbackRef.current = await Userback(token, options);
|
||||
}
|
||||
finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
initializeUserback();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (userbackRef.current && typeof userbackRef.current.destroy === 'function') {
|
||||
userbackRef.current.destroy();
|
||||
}
|
||||
initializingRef.current = false;
|
||||
};
|
||||
}, [user, token]);
|
||||
|
||||
return null; // This component doesn't render anything visible
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import UserbackWidget from '@app/components/feedback/UserbackWidget';
|
||||
|
||||
export function HomePageExtensions() {
|
||||
const userbackToken = import.meta.env.VITE_USERBACK_TOKEN;
|
||||
return userbackToken ? <UserbackWidget token={userbackToken} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* SaaS stub — core tour system is suppressed in SaaS.
|
||||
* SaaS uses SaasOnboardingModal instead.
|
||||
*/
|
||||
export default function OnboardingTour() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
||||
import { renderButtons } from '@app/components/onboarding/renderButtons';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import { useSaasOnboardingState } from '@app/components/onboarding/useSaasOnboardingState';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
interface SaasOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const flow = useSaasOnboardingState(props);
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
flowState,
|
||||
handleButtonAction,
|
||||
} = flow;
|
||||
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === 'dual-icon') {
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === 'rocket' && (
|
||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
onClose={props.onClose}
|
||||
closeOnClickOutside={false}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: {
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
background: 'var(--bg-surface)',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={currentSlide.background.gradientStops}
|
||||
circles={currentSlide.background.circles}
|
||||
isActive
|
||||
slideKey={currentSlide.key}
|
||||
/>
|
||||
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
{currentSlide.title}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{currentSlide.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={currentStep} />
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{renderButtons({
|
||||
slideDefinition,
|
||||
flowState,
|
||||
onAction: handleButtonAction,
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Button, Group, ActionIcon } from '@mantine/core';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import { TFunction } from 'i18next';
|
||||
import { ButtonDefinition, type FlowState, type ButtonAction } from '@app/components/onboarding/saasOnboardingFlowConfig';
|
||||
|
||||
interface RenderButtonsProps {
|
||||
slideDefinition: {
|
||||
buttons: ButtonDefinition[];
|
||||
id: string;
|
||||
};
|
||||
flowState: FlowState;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) {
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition['variant']) =>
|
||||
variant === 'primary'
|
||||
? {
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
}
|
||||
: {
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
const resolveButtonLabel = (button: ButtonDefinition) => {
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? '';
|
||||
if (!label) return '';
|
||||
|
||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||
const fallback = label.split('.').pop() || label;
|
||||
return t(label, fallback);
|
||||
};
|
||||
|
||||
const renderButton = (button: ButtonDefinition) => {
|
||||
const disabled = button.disabledWhen?.(flowState) ?? false;
|
||||
|
||||
if (button.type === 'icon') {
|
||||
return (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
radius="md"
|
||||
size={40}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
const variant = button.variant ?? 'secondary';
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (leftButtons.length === 0) {
|
||||
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
if (rightButtons.length === 0) {
|
||||
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { TrialStatus } from '@app/auth/UseSession';
|
||||
import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig';
|
||||
|
||||
export interface FlowConfig {
|
||||
type: 'saas-trial' | 'saas-paid';
|
||||
ids: SlideId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the appropriate onboarding flow based on user's subscription status.
|
||||
*
|
||||
* @param trialStatus - User's trial information from Supabase
|
||||
* @param _isPro - Whether user has Pro subscription
|
||||
* @returns FlowConfig with the appropriate slide sequence
|
||||
*/
|
||||
export function resolveSaasFlow(
|
||||
trialStatus: TrialStatus | null,
|
||||
_isPro: boolean | null
|
||||
): FlowConfig {
|
||||
// Show free trial card if:
|
||||
// 1. User has active trial (isTrialing = true)
|
||||
// 2. Trial has not expired (daysRemaining > 0)
|
||||
// 3. User is not paid Pro (or Pro is from trial)
|
||||
const hasActiveTrial =
|
||||
trialStatus?.isTrialing === true &&
|
||||
trialStatus.daysRemaining > 0;
|
||||
|
||||
if (hasActiveTrial) {
|
||||
return {
|
||||
type: 'saas-trial',
|
||||
ids: FLOW_SEQUENCES.saasTrialUser,
|
||||
};
|
||||
}
|
||||
|
||||
// For paid users, expired trials, or no trial info
|
||||
return {
|
||||
type: 'saas-paid',
|
||||
ids: FLOW_SEQUENCES.saasPaidUser,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
|
||||
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
|
||||
import FreeTrialSlide from '@app/components/onboarding/slides/FreeTrialSlide';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { TrialStatus } from '@app/auth/UseSession';
|
||||
|
||||
export type SlideId = 'welcome' | 'free-trial' | 'desktop-install';
|
||||
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'diamond';
|
||||
|
||||
export type ButtonAction =
|
||||
| 'next'
|
||||
| 'prev'
|
||||
| 'close'
|
||||
| 'download-selected';
|
||||
|
||||
export type FlowState = Record<string, never>;
|
||||
|
||||
export interface OSOption {
|
||||
label: string;
|
||||
url: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SlideFactoryParams {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
osOptions?: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
trialStatus?: TrialStatus | null;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
type: HeroType;
|
||||
}
|
||||
|
||||
export interface ButtonDefinition {
|
||||
key: string;
|
||||
type: 'button' | 'icon';
|
||||
label?: string;
|
||||
icon?: 'chevron-left';
|
||||
variant?: 'primary' | 'secondary' | 'default';
|
||||
group: 'left' | 'right';
|
||||
action: ButtonAction;
|
||||
disabledWhen?: (state: FlowState) => boolean;
|
||||
}
|
||||
|
||||
export interface SlideDefinition {
|
||||
id: SlideId;
|
||||
createSlide: (params: SlideFactoryParams) => SlideConfig;
|
||||
hero: HeroDefinition;
|
||||
buttons: ButtonDefinition[];
|
||||
}
|
||||
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
'welcome': {
|
||||
id: 'welcome',
|
||||
createSlide: () => WelcomeSlide(),
|
||||
hero: { type: 'rocket' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'welcome-next',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.next',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'next',
|
||||
},
|
||||
],
|
||||
},
|
||||
'free-trial': {
|
||||
id: 'free-trial',
|
||||
createSlide: ({ trialStatus }) => {
|
||||
if (!trialStatus) {
|
||||
throw new Error('Trial status is required for free-trial slide');
|
||||
}
|
||||
return FreeTrialSlide({ trialStatus });
|
||||
},
|
||||
hero: { type: 'diamond' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'trial-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'trial-next',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.next',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'next',
|
||||
},
|
||||
],
|
||||
},
|
||||
'desktop-install': {
|
||||
id: 'desktop-install',
|
||||
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
|
||||
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
||||
hero: { type: 'dual-icon' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'desktop-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'desktop-skip',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipForNow',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'close',
|
||||
},
|
||||
{
|
||||
key: 'desktop-download',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.download',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'download-selected',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const FLOW_SEQUENCES = {
|
||||
saasTrialUser: ['welcome', 'free-trial', 'desktop-install'] as SlideId[],
|
||||
saasPaidUser: ['welcome', 'desktop-install'] as SlideId[],
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import { TrialStatus } from '@app/auth/UseSession';
|
||||
|
||||
interface FreeTrialSlideProps {
|
||||
trialStatus: TrialStatus;
|
||||
}
|
||||
|
||||
function FreeTrialSlideTitle() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span>
|
||||
{t('onboarding.freeTrial.title', 'Your 30-Day Pro Trial')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Format the trial end date
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
// Determine which message to show based on payment method
|
||||
const afterTrialMessage = trialStatus.hasScheduledSub
|
||||
? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
|
||||
: trialStatus.hasPaymentMethod
|
||||
? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
|
||||
: t('onboarding.freeTrial.afterTrialWithoutPayment', 'After your trial ends, you\'ll continue with our free tier. Add a payment method to keep Pro access.');
|
||||
|
||||
// Pluralize days remaining
|
||||
const daysText = trialStatus.daysRemaining === 1
|
||||
? t('onboarding.freeTrial.daysRemainingSingular', '{{days}} day remaining', { days: trialStatus.daysRemaining })
|
||||
: t('onboarding.freeTrial.daysRemaining', '{{days}} days remaining', { days: trialStatus.daysRemaining });
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<p>
|
||||
{t(
|
||||
'onboarding.freeTrial.body',
|
||||
'You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.'
|
||||
)}
|
||||
</p>
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
{daysText}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
||||
{t('onboarding.freeTrial.trialEnds', 'Trial ends {{date}}', { date: trialEndDate })}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
||||
{afterTrialMessage}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
|
||||
return {
|
||||
key: 'free-trial',
|
||||
title: <FreeTrialSlideTitle />,
|
||||
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
|
||||
background: {
|
||||
gradientStops: ['#10B981', '#06B6D4'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type ButtonAction,
|
||||
type FlowState,
|
||||
type SlideId,
|
||||
} from '@app/components/onboarding/saasOnboardingFlowConfig';
|
||||
import { resolveSaasFlow } from '@app/components/onboarding/saasFlowResolver';
|
||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
||||
|
||||
interface UseSaasOnboardingStateResult {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
|
||||
flowState: FlowState;
|
||||
handleButtonAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
interface UseSaasOnboardingStateProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useSaasOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
|
||||
const { trialStatus, isPro, loading } = useAuth();
|
||||
const osType = useOs();
|
||||
const selectedDownloadUrlRef = useRef<string>('');
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<number>(0);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Determine OS details for desktop download
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
|
||||
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
|
||||
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
|
||||
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
|
||||
];
|
||||
return options.filter(opt => opt.url);
|
||||
}, []);
|
||||
|
||||
// Store selected download URL
|
||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
||||
selectedDownloadUrlRef.current = url;
|
||||
}, []);
|
||||
|
||||
// Resolve flow based on trial status
|
||||
const resolvedFlow = useMemo(
|
||||
() => resolveSaasFlow(trialStatus, isPro),
|
||||
[trialStatus, isPro]
|
||||
);
|
||||
|
||||
const flowSlideIds = resolvedFlow.ids;
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
|
||||
// Ensure current step is within bounds
|
||||
useEffect(() => {
|
||||
if (currentStep >= flowSlideIds.length) {
|
||||
setCurrentStep(Math.max(flowSlideIds.length - 1, 0));
|
||||
}
|
||||
}, [flowSlideIds.length, currentStep]);
|
||||
|
||||
const currentSlideId = flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
// Create slide with appropriate params - must be called before any early returns
|
||||
const currentSlide = useMemo(() => {
|
||||
if (!slideDefinition) return null;
|
||||
return slideDefinition.createSlide({
|
||||
osLabel: os.label,
|
||||
osUrl: os.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
trialStatus: trialStatus ?? undefined,
|
||||
});
|
||||
}, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange, trialStatus]);
|
||||
|
||||
// Navigation functions
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, maxIndex));
|
||||
}, [maxIndex]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
// Handle button actions
|
||||
const handleButtonAction = useCallback(
|
||||
(action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case 'next':
|
||||
// If on last slide, close modal
|
||||
if (currentStep === maxIndex) {
|
||||
onClose();
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
return;
|
||||
case 'prev':
|
||||
goPrev();
|
||||
return;
|
||||
case 'close':
|
||||
onClose();
|
||||
return;
|
||||
case 'download-selected': {
|
||||
// Open download URL in new tab
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
// Then advance to next slide or close if last
|
||||
if (currentStep === maxIndex) {
|
||||
onClose();
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
console.warn(`Unhandled button action: ${action}`);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[currentStep, maxIndex, goNext, goPrev, onClose, os.url]
|
||||
);
|
||||
|
||||
const flowState: FlowState = {};
|
||||
|
||||
// Early return after all hooks have been called
|
||||
if (!slideDefinition || !currentSlide || loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
slideDefinition,
|
||||
currentSlide,
|
||||
flowState,
|
||||
handleButtonAction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Button, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { isUserAnonymous } from '@app/auth/supabase';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import Overview from '@app/components/shared/config/configSections/Overview';
|
||||
import { createSaasConfigNavSections } from '@app/components/shared/config/saasConfigNavSections';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import { withBasePath } from '@app/constants/app';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
const { signOut, user, creditBalance, refreshCredits } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// Check if user can access billing features (non-anonymous users only)
|
||||
const isAnonymous = user ? isUserAnonymous(user) : false;
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
// Listen for notice updates (e.g., "Not enough credits..." next to Plan title)
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey; notice?: string } | undefined;
|
||||
if (detail?.notice && (detail?.key ? detail.key === 'plan' : true)) {
|
||||
setNotice(detail.notice);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:notice', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:notice', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
// When the modal opens to Plan, proactively refresh credits and log values
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (active !== 'plan') return;
|
||||
console.log('[AppConfigModal] Opening Plan section. Current creditBalance:', creditBalance);
|
||||
(async () => {
|
||||
try {
|
||||
await refreshCredits();
|
||||
} catch (e) {
|
||||
console.warn('[AppConfigModal] Failed to refresh credits on Plan open:', e);
|
||||
}
|
||||
})();
|
||||
}, [opened, active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (active !== 'plan') return;
|
||||
console.log('[AppConfigModal] Credit balance updated while viewing Plan:', creditBalance);
|
||||
}, [opened, active, creditBalance]);
|
||||
|
||||
const colors = useMemo(() => ({
|
||||
navBg: 'var(--modal-nav-bg)',
|
||||
sectionTitle: 'var(--modal-nav-section-title)',
|
||||
navItem: 'var(--modal-nav-item)',
|
||||
navItemActive: 'var(--modal-nav-item-active)',
|
||||
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
|
||||
contentBg: 'var(--modal-content-bg)',
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(
|
||||
() =>
|
||||
createSaasConfigNavSections(Overview, openLogoutConfirm, {
|
||||
isDev,
|
||||
isAnonymous,
|
||||
t,
|
||||
}),
|
||||
[openLogoutConfirm, isDev, isAnonymous, t],
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return '';
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const activeComponent = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.component;
|
||||
}
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
<div
|
||||
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map(section => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map(item => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="modal-content">
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
background: colors.contentBg,
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">
|
||||
{activeLabel}
|
||||
{active === 'plan' && notice ? (
|
||||
<span style={{ marginLeft: 8, fontWeight: 600, color: 'var(--mantine-color-yellow-7)' }}>
|
||||
– {notice}
|
||||
</span>
|
||||
) : null}
|
||||
</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{activeComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* Confirm logout modal */}
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
title="Sign out"
|
||||
centered
|
||||
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
|
||||
>
|
||||
<div className="confirm-modal-content">
|
||||
<Text>Are you sure you want to sign out?</Text>
|
||||
<div className="confirm-modal-buttons">
|
||||
<Button variant="default" onClick={() => setConfirmOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} finally {
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
window.location.href = withBasePath('/login');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppConfigModal;
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
type InfoBannerTone = 'info' | 'warning';
|
||||
|
||||
const toneStyles: Record<
|
||||
InfoBannerTone,
|
||||
{
|
||||
background: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
buttonColor: string;
|
||||
}
|
||||
> = {
|
||||
info: {
|
||||
background: 'var(--mantine-color-blue-0)',
|
||||
border: 'var(--mantine-color-blue-2)',
|
||||
text: 'var(--mantine-color-blue-9)',
|
||||
icon: 'var(--mantine-color-blue-6)',
|
||||
buttonColor: 'blue',
|
||||
},
|
||||
warning: {
|
||||
background: 'var(--mantine-color-orange-0)',
|
||||
border: 'var(--mantine-color-orange-3)',
|
||||
text: 'var(--mantine-color-orange-9)',
|
||||
icon: 'var(--mantine-color-orange-7)',
|
||||
buttonColor: 'orange',
|
||||
},
|
||||
};
|
||||
|
||||
interface InfoBannerProps {
|
||||
icon?: string | ReactNode; // SaaS supports ReactNode (e.g., logo images)
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: InfoBannerTone;
|
||||
background?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
iconColor?: string;
|
||||
buttonColor?: string;
|
||||
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
|
||||
buttonTextColor?: string; // SaaS-specific for dark theme buttons
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string; // SaaS-specific for dark theme
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS-specific info banner with enhanced theming support
|
||||
* Supports ReactNode icons (e.g., logo images) and custom button text colors
|
||||
*/
|
||||
export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = 'check-circle-rounded',
|
||||
onButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = 'info',
|
||||
background,
|
||||
borderColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
buttonColor,
|
||||
buttonVariant = 'light',
|
||||
buttonTextColor,
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toneStyle = toneStyles[tone] ?? toneStyles.info;
|
||||
const handleDismiss = () => {
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="sm"
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{icon && (
|
||||
typeof icon === 'string' ? (
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{icon}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text fw={600} size="sm" style={{ color: textColor ?? toneStyle.text }}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size="sm"
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
lineClamp={2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
color={buttonColor ?? toneStyle.buttonColor}
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||
styles={
|
||||
buttonTextColor
|
||||
? {
|
||||
label: {
|
||||
color: buttonTextColor,
|
||||
},
|
||||
}
|
||||
: buttonVariant !== 'white' && buttonVariant !== 'filled'
|
||||
? {
|
||||
label: {
|
||||
color: textColor ?? toneStyle.text,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={closeIconColor ? undefined : 'gray'}
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss"
|
||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
import { Button } from '@mantine/core';
|
||||
import { usePlans } from '@app/hooks/usePlans';
|
||||
|
||||
interface TrialStatus {
|
||||
isTrialing: boolean;
|
||||
trialEnd: string;
|
||||
daysRemaining: number;
|
||||
hasPaymentMethod: boolean;
|
||||
hasScheduledSub: boolean;
|
||||
}
|
||||
|
||||
export function ManageBillingButton({
|
||||
returnUrl = typeof window !== 'undefined' ? window.location.href : '/',
|
||||
children = 'Manage billing',
|
||||
trialStatus,
|
||||
}: {
|
||||
returnUrl?: string;
|
||||
children?: React.ReactNode;
|
||||
trialStatus?: TrialStatus;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const { data } = usePlans();
|
||||
|
||||
// Hide for free plan users
|
||||
if (!data || data.currentPlan.id === 'free') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide for trial users who haven't scheduled a subscription yet
|
||||
if (trialStatus?.isTrialing && !trialStatus.hasScheduledSub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('manage-billing', {
|
||||
body: {
|
||||
name: 'Functions',
|
||||
return_url: returnUrl},
|
||||
})
|
||||
if (error) throw error;
|
||||
if (!data || 'error' in data) throw new Error((data as any)?.error ?? 'No portal URL');
|
||||
window.location.href = (data as any).url;
|
||||
} catch (e: any) {
|
||||
setErr(e.message ?? 'Could not open billing portal');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={onClick} disabled={loading} className="px-4 py-2 rounded bg-black text-white">
|
||||
{loading ? 'Opening…' : children}
|
||||
</Button>
|
||||
{err && <div className="mt-2 text-red-600">{err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS override of the OSS PrivateContent wrapper.
|
||||
* Adds both the PostHog no-capture class and the Userback opt-out class
|
||||
* while keeping the same API and layout behavior (display: contents).
|
||||
*/
|
||||
export const PrivateContent: React.FC<PrivateContentProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const baseClass = 'ph-no-capture userback-block';
|
||||
const combinedClassName = className ? `${baseClass} ${className}` : baseClass;
|
||||
const combinedStyle = {
|
||||
display: 'contents' as const,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={combinedClassName} style={combinedStyle} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_DEFAULT_KEY);
|
||||
|
||||
export type PurchaseType = 'subscription' | 'credits';
|
||||
export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null;
|
||||
export type PlanID = 'pro' | null;
|
||||
|
||||
interface StripeCheckoutProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
// Saas-specific props
|
||||
planId?: PlanID;
|
||||
purchaseType?: PurchaseType;
|
||||
creditsPack?: CreditsPack;
|
||||
planName?: string;
|
||||
planPrice?: number;
|
||||
currency?: string;
|
||||
isTrialConversion?: boolean;
|
||||
// Proprietary-specific props (for compatibility)
|
||||
planGroup?: any;
|
||||
minimumSeats?: number;
|
||||
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
|
||||
hostedCheckoutSuccess?: {
|
||||
isUpgrade: boolean;
|
||||
licenseKey?: string;
|
||||
} | null;
|
||||
// Common props
|
||||
onSuccess?: (sessionId: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
type CheckoutState = {
|
||||
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
|
||||
clientSecret?: string;
|
||||
error?: string;
|
||||
sessionParams?: {
|
||||
purchaseType: PurchaseType;
|
||||
planId: PlanID;
|
||||
creditsPack: CreditsPack;
|
||||
};
|
||||
};
|
||||
|
||||
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planId,
|
||||
purchaseType,
|
||||
creditsPack,
|
||||
planName,
|
||||
isTrialConversion,
|
||||
onSuccess,
|
||||
onError
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
try {
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('create-checkout', {
|
||||
body: {
|
||||
purchase_type: purchaseType,
|
||||
ui_mode: 'embedded',
|
||||
plan: planId,
|
||||
credits_pack: creditsPack,
|
||||
callback_base_url: window.location.origin,
|
||||
trial_conversion: isTrialConversion || false
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to create checkout session');
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No data received from server');
|
||||
}
|
||||
|
||||
const jsonData = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
|
||||
if (!jsonData?.clientSecret) {
|
||||
throw new Error('No client secret received from server');
|
||||
}
|
||||
|
||||
setState({
|
||||
status: 'ready',
|
||||
clientSecret: jsonData.clientSecret,
|
||||
sessionParams: {
|
||||
purchaseType: purchaseType!,
|
||||
planId: planId!,
|
||||
creditsPack: creditsPack!
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
|
||||
setState({
|
||||
status: 'error',
|
||||
error: errorMessage
|
||||
});
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentComplete = () => {
|
||||
setState({ status: 'success' });
|
||||
|
||||
// Call success callback immediately - parent will handle timing
|
||||
onSuccess?.('');
|
||||
|
||||
// Note: Parent (Plan.tsx) now handles the delay and modal closing
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state to idle to clean up the session
|
||||
setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Initialize checkout when modal opens or parameters change
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
// Check if we need a new session (first time or parameters changed)
|
||||
const needsNewSession =
|
||||
state.status === 'idle' ||
|
||||
!state.sessionParams ||
|
||||
state.sessionParams.purchaseType !== purchaseType ||
|
||||
state.sessionParams.planId !== planId ||
|
||||
state.sessionParams.creditsPack !== creditsPack;
|
||||
|
||||
if (needsNewSession) {
|
||||
console.log('Creating new checkout session:', { purchaseType, planId, creditsPack });
|
||||
createCheckoutSession();
|
||||
}
|
||||
} else if (!opened) {
|
||||
// Clean up state when modal closes
|
||||
setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
|
||||
}
|
||||
}, [opened, purchaseType, planId, creditsPack]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (state.status) {
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t('payment.preparing', 'Preparing your checkout...')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'ready':
|
||||
if (!state.clientSecret) return null;
|
||||
|
||||
return (
|
||||
<EmbeddedCheckoutProvider
|
||||
key={state.clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: state.clientSecret,
|
||||
onComplete: handlePaymentComplete
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t('payment.successMessage', 'Your plan has been upgraded successfully. You will receive a confirmation email shortly.')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('payment.autoClose', 'This window will close automatically...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<Alert color="red" title={t('payment.error', 'Payment Error')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{state.error}</Text>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common.close', 'Close')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
size="xl"
|
||||
centered
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeCheckout;
|
||||
export { StripeCheckout };
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Modal, Stack, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
interface TrialExpiredModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubscribe: () => void;
|
||||
}
|
||||
|
||||
export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpiredModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use CSS variables for theme colors
|
||||
const amberColor = getComputedStyle(document.documentElement).getPropertyValue('--color-amber-500').trim() || '#f59e0b';
|
||||
const redColor = getComputedStyle(document.documentElement).getPropertyValue('--color-red-500').trim() || '#ef4444';
|
||||
const gradientStops: [string, string] = [amberColor, redColor];
|
||||
|
||||
const circles = [
|
||||
{
|
||||
position: 'bottom-left' as const,
|
||||
size: 270, // 16.875rem
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
opacity: 0.9,
|
||||
amplitude: 24, // 1.5rem
|
||||
duration: 4.5,
|
||||
offsetX: 18, // 1.125rem
|
||||
offsetY: 14, // 0.875rem
|
||||
},
|
||||
{
|
||||
position: 'top-right' as const,
|
||||
size: 300, // 18.75rem
|
||||
color: 'rgba(255, 255, 255, 0.2)',
|
||||
opacity: 0.9,
|
||||
amplitude: 28, // 1.75rem
|
||||
duration: 4.5,
|
||||
delay: 0.5,
|
||||
offsetX: 24, // 1.5rem
|
||||
offsetY: 18, // 1.125rem
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}} // Prevent closing by clicking outside or ESC
|
||||
withCloseButton={false}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: {
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
background: 'var(--bg-surface)',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={gradientStops}
|
||||
circles={circles}
|
||||
isActive
|
||||
slideKey="trial-expired"
|
||||
/>
|
||||
<div className={styles.heroLogo}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
<DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<div className={`${styles.title} ${styles.titleText}`}>
|
||||
{t('plan.trial.expired', 'Your Trial Has Ended')}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{t(
|
||||
'plan.trial.expiredMessage',
|
||||
'Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{t('plan.trial.freeTierLimitations', 'Free tier includes basic PDF tools with usage limits.')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<style>{`
|
||||
@media (max-width: 30rem) {
|
||||
.trial-button-container {
|
||||
justify-content: center !important;
|
||||
}
|
||||
.trial-modal-button {
|
||||
flex: 1 1 100% !important;
|
||||
}
|
||||
}
|
||||
.trial-modal-button-primary:hover {
|
||||
background: linear-gradient(135deg, var(--color-amber-600), var(--color-red-600)) !important;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className="trial-button-container"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="trial-modal-button"
|
||||
style={{
|
||||
fontSize: '0.8125rem',
|
||||
padding: '0.5rem 1rem',
|
||||
height: 'auto',
|
||||
minWidth: '8.125rem',
|
||||
flex: '0 1 auto',
|
||||
border: '0'
|
||||
}}
|
||||
>
|
||||
{t('plan.trial.continueWithFree', 'Continue with Free')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onSubscribe}
|
||||
size="md"
|
||||
className="trial-modal-button trial-modal-button-primary"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 600,
|
||||
padding: '0.75rem 1.5rem',
|
||||
height: 'auto',
|
||||
border: 'none',
|
||||
minWidth: '10.625rem',
|
||||
flex: '0 1 auto',
|
||||
}}
|
||||
>
|
||||
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useBanner } from '@app/contexts/BannerContext';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||
import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
const SESSION_STORAGE_KEY = 'trialBannerDismissed';
|
||||
|
||||
export function TrialStatusBanner() {
|
||||
const { setBanner } = useBanner();
|
||||
const { t } = useTranslation();
|
||||
const { trialStatus } = useAuth();
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
return sessionStorage.getItem(SESSION_STORAGE_KEY) === 'true';
|
||||
});
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
|
||||
// Only show banner during ACTIVE trial (not after expiration - modal handles that)
|
||||
// Don't show if payment method already added (user has scheduled subscription)
|
||||
const shouldShowBanner =
|
||||
trialStatus &&
|
||||
trialStatus.isTrialing && // Only show during active trial
|
||||
trialStatus.daysRemaining > 0 && // Trial hasn't expired yet
|
||||
!trialStatus.hasPaymentMethod &&
|
||||
!trialStatus.hasScheduledSub &&
|
||||
!dismissed;
|
||||
|
||||
if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) {
|
||||
console.log('Subscription scheduled - hiding trial banner');
|
||||
}
|
||||
|
||||
const handleOpenCheckout = useCallback(() => {
|
||||
setCheckoutOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setDismissed(true);
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowBanner) {
|
||||
setBanner(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString('en-GB', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const message = t(
|
||||
'plan.trial.message',
|
||||
`Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? 's' : ''} (${trialEndDate}). Subscribe to continue Pro access.`,
|
||||
{ days: trialStatus.daysRemaining, date: trialEndDate }
|
||||
);
|
||||
|
||||
const logoIcon = (
|
||||
<img
|
||||
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||
alt="Stirling PDF"
|
||||
style={{
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
setBanner(
|
||||
<InfoBanner
|
||||
icon={logoIcon}
|
||||
tone="info"
|
||||
message={message}
|
||||
buttonText={t('plan.trial.subscribe', 'Subscribe to Pro')}
|
||||
buttonIcon="credit-card-rounded"
|
||||
onButtonClick={handleOpenCheckout}
|
||||
onDismiss={handleDismiss}
|
||||
dismissible={true}
|
||||
show={true}
|
||||
background="var(--mantine-color-dark-7)"
|
||||
borderColor="var(--mantine-color-dark-5)"
|
||||
textColor="rgba(255, 255, 255, 0.95)"
|
||||
iconColor="rgba(255, 255, 255, 0.95)"
|
||||
buttonColor="gray"
|
||||
buttonVariant="white"
|
||||
buttonTextColor="var(--mantine-color-dark-9)"
|
||||
closeIconColor="rgba(255, 255, 255, 0.7)"
|
||||
/>
|
||||
);
|
||||
|
||||
return () => {
|
||||
setBanner(null);
|
||||
};
|
||||
}, [shouldShowBanner, trialStatus, setBanner, t, handleOpenCheckout, handleDismiss]);
|
||||
|
||||
const handleCheckoutSuccess = () => {
|
||||
// Refresh to hide banner and show updated plan
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{trialStatus && (
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen}
|
||||
onClose={() => setCheckoutOpen(false)}
|
||||
purchaseType="subscription"
|
||||
planId="pro"
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) => console.error('Checkout error:', error)}
|
||||
isTrialConversion={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useEffect, useMemo, useRef, useCallback, useId } from 'react';
|
||||
import { Group, Loader, Text } from '@mantine/core';
|
||||
import * as d3 from 'd3';
|
||||
import { StackedBarChartProps, TooltipData, FractionData } from '@app/types/charts';
|
||||
import { generateTooltipHTML } from '@app/components/shared/charts/stackedBarChart/StackedBarTooltip';
|
||||
import { detectTheme, getChartThemeVars } from '@app/components/shared/charts/utils/themeUtils';
|
||||
import { createTooltipPositioner } from '@app/components/shared/charts/utils/tooltipUtils';
|
||||
import { createRoundedRectPath, createScale } from '@app/components/shared/charts/utils/d3Utils';
|
||||
|
||||
export default function StackedBarChart({
|
||||
fractions,
|
||||
width = 640,
|
||||
height = 22,
|
||||
showLegend = true,
|
||||
className = '',
|
||||
tooltipPosition = 'top',
|
||||
loading = false,
|
||||
animate = true,
|
||||
animationDurationMs = 900,
|
||||
ariaLabel,
|
||||
}: StackedBarChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasAnimatedRef = useRef(false);
|
||||
const reactId = useId();
|
||||
const clipId = useMemo(() => `clip-${reactId}`, [reactId]);
|
||||
|
||||
// Memoize theme detection to avoid recalculation
|
||||
const theme = useMemo(() => detectTheme(), []);
|
||||
const themeVars = useMemo(() => getChartThemeVars(theme.isDark), [theme.isDark]);
|
||||
|
||||
// Memoize tooltip positioner
|
||||
const tooltipPositioner = useMemo(() => createTooltipPositioner(tooltipPosition), [tooltipPosition]);
|
||||
|
||||
const positionTooltip = useCallback((event: MouseEvent) => {
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!tooltip) return;
|
||||
tooltipPositioner.positionTooltip(event, tooltip, containerRef.current!);
|
||||
}, [tooltipPositioner]);
|
||||
|
||||
const setTooltipContent = useCallback((labelHtml: string) => {
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!tooltip) return;
|
||||
tooltip.innerHTML = labelHtml;
|
||||
tooltip.style.background = themeVars.background;
|
||||
tooltip.style.color = themeVars.textPrimary;
|
||||
tooltip.style.border = themeVars.border;
|
||||
tooltip.style.boxShadow = themeVars.boxShadow;
|
||||
tooltip.style.padding = '8px 10px';
|
||||
tooltip.style.fontSize = '12px';
|
||||
tooltip.style.lineHeight = '1.25';
|
||||
tooltip.style.borderRadius = '8px';
|
||||
}, [themeVars]);
|
||||
|
||||
const hideTooltip = useCallback(() => {
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!tooltip) return;
|
||||
tooltip.style.opacity = '0';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
// Calculate total capacity (sum of all denominators)
|
||||
const totalCapacity = fractions.reduce((sum: number, fraction: FractionData) => sum + fraction.denominator, 0);
|
||||
|
||||
if (totalCapacity === 0 && !loading) return;
|
||||
|
||||
// Create data for the bar segments
|
||||
const data = fractions.map((fraction: FractionData) => ({
|
||||
...fraction,
|
||||
value: fraction.numerator,
|
||||
remaining: fraction.denominator - fraction.numerator
|
||||
}));
|
||||
|
||||
const radius = 8;
|
||||
|
||||
const svg = d3
|
||||
.select(container)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', height)
|
||||
.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
.attr('role', ariaLabel ? 'img' : null)
|
||||
.attr('aria-label', ariaLabel || null);
|
||||
|
||||
const x = createScale([0, totalCapacity], [0, width]);
|
||||
let cursor = 0;
|
||||
const g = svg.append('g');
|
||||
|
||||
// Skip drawing the bar visuals entirely when loading to avoid gray bar under spinner
|
||||
if (!loading) {
|
||||
// Create a single rounded rectangle for the entire bar background
|
||||
const totalBarWidth = x(totalCapacity);
|
||||
g.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', totalBarWidth)
|
||||
.attr('height', height)
|
||||
.attr('rx', radius)
|
||||
.attr('ry', radius)
|
||||
.attr('fill', themeVars.inactive)
|
||||
.attr('stroke', themeVars.cardBorder);
|
||||
|
||||
// Define a clipPath that will reveal the used portion from left to right
|
||||
const defs = svg.append('defs');
|
||||
const clipRect = defs
|
||||
.append('clipPath')
|
||||
.attr('id', clipId)
|
||||
.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', 0)
|
||||
.attr('height', height)
|
||||
.attr('rx', radius)
|
||||
.attr('ry', radius);
|
||||
|
||||
// Group to hold the used segments and apply clip-path
|
||||
const usedGroup = svg.append('g').attr('clip-path', `url(#${clipId})`);
|
||||
|
||||
// Render used segments on top of the background
|
||||
data.forEach((fraction: typeof data[number], index: number) => {
|
||||
if (fraction.value <= 0) return;
|
||||
|
||||
const segWidth = x(fraction.value);
|
||||
const xPos = cursor;
|
||||
cursor += segWidth;
|
||||
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === data.length - 1; // Last segment regardless of remaining
|
||||
|
||||
if (isFirst && isLast) {
|
||||
// Single segment: fully rounded
|
||||
usedGroup.append('rect')
|
||||
.attr('x', xPos)
|
||||
.attr('y', 0)
|
||||
.attr('width', segWidth)
|
||||
.attr('height', height)
|
||||
.attr('rx', radius)
|
||||
.attr('ry', radius)
|
||||
.attr('fill', fraction.color);
|
||||
} else if (isFirst) {
|
||||
// First segment: rounded on left side only
|
||||
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
|
||||
topLeft: true,
|
||||
topRight: false,
|
||||
bottomLeft: true,
|
||||
bottomRight: false
|
||||
});
|
||||
usedGroup.append('path')
|
||||
.attr('d', path)
|
||||
.attr('fill', fraction.color);
|
||||
} else if (isLast) {
|
||||
// Last segment: rounded on right side only
|
||||
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
|
||||
topLeft: false,
|
||||
topRight: true,
|
||||
bottomLeft: false,
|
||||
bottomRight: true
|
||||
});
|
||||
usedGroup.append('path')
|
||||
.attr('d', path)
|
||||
.attr('fill', fraction.color);
|
||||
} else {
|
||||
// Middle segments: no rounded edges
|
||||
usedGroup.append('rect')
|
||||
.attr('x', xPos)
|
||||
.attr('y', 0)
|
||||
.attr('width', segWidth)
|
||||
.attr('height', height)
|
||||
.attr('fill', fraction.color);
|
||||
}
|
||||
});
|
||||
|
||||
// Add a transparent overlay for hover events on the entire bar (outside clip path)
|
||||
svg.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', totalBarWidth)
|
||||
.attr('height', height)
|
||||
.attr('rx', radius)
|
||||
.attr('ry', radius)
|
||||
.attr('fill', 'transparent')
|
||||
.style('pointer-events', 'all')
|
||||
.on('mouseenter', (event: MouseEvent) => {
|
||||
const tooltipData: TooltipData = { fractions: data, isDark: theme.isDark };
|
||||
const html = generateTooltipHTML(tooltipData);
|
||||
setTooltipContent(html);
|
||||
const tooltip = tooltipRef.current;
|
||||
if (tooltip) tooltip.style.opacity = '1';
|
||||
positionTooltip(event as unknown as MouseEvent);
|
||||
})
|
||||
.on('mousemove', (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
|
||||
.on('mouseleave', hideTooltip);
|
||||
|
||||
// Animate reveal of used segments (only on first load, not on re-renders)
|
||||
const totalUsed = data.reduce((sum: number, f: typeof data[number]) => sum + f.value, 0);
|
||||
const revealTo = x(totalUsed);
|
||||
if (animate && !hasAnimatedRef.current) {
|
||||
clipRect.transition().duration(animationDurationMs).attr('width', revealTo);
|
||||
hasAnimatedRef.current = true;
|
||||
} else {
|
||||
clipRect.attr('width', revealTo);
|
||||
}
|
||||
}
|
||||
|
||||
return () => { container.innerHTML = ''; };
|
||||
}, [fractions, width, height, tooltipPosition, loading, animate, animationDurationMs, clipId, themeVars, setTooltipContent, hideTooltip, positionTooltip]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div ref={containerRef} />
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
transition: 'opacity 120ms ease',
|
||||
zIndex: 1000
|
||||
}}
|
||||
/>
|
||||
{loading && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Loader size="sm" color="blue" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showLegend && (
|
||||
<Group gap="lg" mt="sm">
|
||||
{fractions.map((fraction: FractionData, index: number) => (
|
||||
<Group key={index} gap={6}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: fraction.color,
|
||||
display: 'inline-block',
|
||||
borderRadius: 2
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">{fraction.name}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group gap={6}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: themeVars.inactive,
|
||||
display: 'inline-block',
|
||||
borderRadius: 2,
|
||||
outline: `1px solid ${themeVars.cardBorder}`
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">Remaining</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { TooltipData } from '@app/types/charts';
|
||||
|
||||
interface StackedBarTooltipProps {
|
||||
data: TooltipData;
|
||||
}
|
||||
|
||||
export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
|
||||
const { fractions } = data;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', whiteSpace: 'nowrap' }}>
|
||||
{fractions.map((f, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: f.color,
|
||||
borderRadius: '2px'
|
||||
}}></span>
|
||||
<span>
|
||||
<strong>{f.name}</strong> — {f.numeratorLabel}: {f.numerator} · {f.denominatorLabel}: {f.denominator - f.numerator}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateTooltipHTML(data: TooltipData): string {
|
||||
const { fractions } = data;
|
||||
|
||||
return `
|
||||
<div style="display:flex;flex-direction:column;gap:6px;white-space:nowrap;">
|
||||
${fractions.map(f => `
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<span style="display:inline-block;width:10px;height:10px;background:${f.color};border-radius:2px"></span>
|
||||
<span><strong>${f.name}</strong> — ${f.numeratorLabel}: ${f.numerator} · ${f.denominatorLabel}: ${Math.max(0, f.denominator - f.numerator)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Reusable D3 utility functions for chart creation
|
||||
*/
|
||||
|
||||
import * as d3 from 'd3';
|
||||
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
margin?: {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnimationConfig {
|
||||
duration: number;
|
||||
easing?: (t: number) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a basic SVG element with proper attributes
|
||||
* @param container The container element
|
||||
* @param dimensions Chart dimensions
|
||||
* @param className Optional CSS class name
|
||||
* @returns The created SVG selection
|
||||
*/
|
||||
export function createSVG(
|
||||
container: HTMLElement,
|
||||
dimensions: ChartDimensions,
|
||||
className?: string
|
||||
): d3.Selection<SVGSVGElement, unknown, null, undefined> {
|
||||
const svg = d3
|
||||
.select(container)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', dimensions.height)
|
||||
.attr('viewBox', `0 0 ${dimensions.width} ${dimensions.height}`)
|
||||
.attr('class', className || '');
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a clip path for revealing content with animation
|
||||
* @param svg The SVG selection
|
||||
* @param clipId Unique ID for the clip path
|
||||
* @param dimensions Chart dimensions
|
||||
* @returns The clip rect selection
|
||||
*/
|
||||
export function createClipPath(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
clipId: string,
|
||||
dimensions: ChartDimensions
|
||||
): d3.Selection<SVGRectElement, unknown, null, undefined> {
|
||||
const defs = svg.append('defs');
|
||||
const clipRect = defs
|
||||
.append('clipPath')
|
||||
.attr('id', clipId)
|
||||
.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', 0)
|
||||
.attr('height', dimensions.height);
|
||||
|
||||
return clipRect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates a clip path to reveal content
|
||||
* @param clipRect The clip rect selection
|
||||
* @param targetWidth The target width to animate to
|
||||
* @param config Animation configuration
|
||||
*/
|
||||
export function animateClipReveal(
|
||||
clipRect: d3.Selection<SVGRectElement, unknown, null, undefined>,
|
||||
targetWidth: number,
|
||||
config: AnimationConfig
|
||||
): void {
|
||||
clipRect
|
||||
.transition()
|
||||
.duration(config.duration)
|
||||
.ease(config.easing || d3.easeCubicInOut)
|
||||
.attr('width', targetWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a rounded rectangle path for D3
|
||||
* @param x X position
|
||||
* @param y Y position
|
||||
* @param width Width
|
||||
* @param height Height
|
||||
* @param radius Corner radius
|
||||
* @param corners Which corners to round (default: all)
|
||||
* @returns SVG path string
|
||||
*/
|
||||
export function createRoundedRectPath(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
corners: { topLeft?: boolean; topRight?: boolean; bottomLeft?: boolean; bottomRight?: boolean } = {}
|
||||
): string {
|
||||
const { topLeft = true, topRight = true, bottomLeft = true, bottomRight = true } = corners;
|
||||
|
||||
if (width <= 0 || height <= 0) return '';
|
||||
|
||||
const topLeftRadius = topLeft ? radius : 0;
|
||||
const topRightRadius = topRight ? radius : 0;
|
||||
const bottomRightRadius = bottomRight ? radius : 0;
|
||||
const bottomLeftRadius = bottomLeft ? radius : 0;
|
||||
|
||||
let path = `M ${x + topLeftRadius} ${y}`;
|
||||
|
||||
if (topRight) {
|
||||
path += ` L ${x + width - topRightRadius} ${y}`;
|
||||
path += ` A ${topRightRadius} ${topRightRadius} 0 0 1 ${x + width} ${y + topRightRadius}`;
|
||||
} else {
|
||||
path += ` L ${x + width} ${y}`;
|
||||
}
|
||||
|
||||
if (bottomRight) {
|
||||
path += ` L ${x + width} ${y + height - bottomRightRadius}`;
|
||||
path += ` A ${bottomRightRadius} ${bottomRightRadius} 0 0 1 ${x + width - bottomRightRadius} ${y + height}`;
|
||||
} else {
|
||||
path += ` L ${x + width} ${y + height}`;
|
||||
}
|
||||
|
||||
if (bottomLeft) {
|
||||
path += ` L ${x + bottomLeftRadius} ${y + height}`;
|
||||
path += ` A ${bottomLeftRadius} ${bottomLeftRadius} 0 0 1 ${x} ${y + height - bottomLeftRadius}`;
|
||||
} else {
|
||||
path += ` L ${x} ${y + height}`;
|
||||
}
|
||||
|
||||
if (topLeft) {
|
||||
path += ` L ${x} ${y + topLeftRadius}`;
|
||||
path += ` A ${topLeftRadius} ${topLeftRadius} 0 0 1 ${x + topLeftRadius} ${y}`;
|
||||
} else {
|
||||
path += ` L ${x} ${y}`;
|
||||
}
|
||||
|
||||
return path + ' Z';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reusable scale factory
|
||||
* @param domain Domain values
|
||||
* @param range Range values
|
||||
* @returns D3 scale function
|
||||
*/
|
||||
export function createScale(domain: [number, number], range: [number, number]) {
|
||||
return d3.scaleLinear().domain(domain).range(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounces a function call
|
||||
* @param func The function to debounce
|
||||
* @param wait The wait time in milliseconds
|
||||
* @returns Debounced function
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Utility functions for theme detection and styling in D3 charts
|
||||
*/
|
||||
|
||||
export interface ThemeInfo {
|
||||
isDark: boolean;
|
||||
schemeAttr: string | null | undefined;
|
||||
prefersDark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the current theme from various sources
|
||||
* @returns ThemeInfo object with theme detection results
|
||||
*/
|
||||
export function detectTheme(): ThemeInfo {
|
||||
const rootEl = typeof document !== 'undefined' ? document.documentElement : null;
|
||||
const schemeAttr = rootEl?.getAttribute('data-mantine-color-scheme');
|
||||
const prefersDark = typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const isDark = schemeAttr ? schemeAttr === 'dark' : prefersDark;
|
||||
|
||||
return {
|
||||
isDark,
|
||||
schemeAttr,
|
||||
prefersDark
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets CSS custom properties for chart styling
|
||||
* @param isDark Whether the theme is dark
|
||||
* @returns Object with CSS custom property values
|
||||
*/
|
||||
export function getChartThemeVars(isDark: boolean) {
|
||||
return {
|
||||
background: 'var(--bg-surface)',
|
||||
textPrimary: 'var(--text-primary)',
|
||||
border: isDark ? '1px solid var(--border-subtle)' : '1px solid transparent',
|
||||
boxShadow: isDark ? 'none' : 'var(--shadow-md)',
|
||||
inactive: 'var(--usage-inactive)',
|
||||
cardBorder: 'var(--api-keys-card-border)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies consistent tooltip styling
|
||||
* @param tooltipElement The tooltip DOM element
|
||||
* @param isDark Whether the theme is dark
|
||||
*/
|
||||
export function applyTooltipStyles(tooltipElement: HTMLElement, isDark: boolean) {
|
||||
const themeVars = getChartThemeVars(isDark);
|
||||
|
||||
tooltipElement.style.background = themeVars.background;
|
||||
tooltipElement.style.color = themeVars.textPrimary;
|
||||
tooltipElement.style.border = themeVars.border;
|
||||
tooltipElement.style.boxShadow = themeVars.boxShadow;
|
||||
tooltipElement.style.padding = '8px 10px';
|
||||
tooltipElement.style.fontSize = '12px';
|
||||
tooltipElement.style.lineHeight = '1.25';
|
||||
tooltipElement.style.borderRadius = '8px';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Utility functions for tooltip positioning and management in D3 charts
|
||||
*/
|
||||
|
||||
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
export interface TooltipPositioner {
|
||||
positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void;
|
||||
hideTooltip: (tooltip: HTMLElement) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tooltip positioner for the specified position
|
||||
* @param position The tooltip position preference
|
||||
* @returns TooltipPositioner object with positioning functions
|
||||
*/
|
||||
export function createTooltipPositioner(position: TooltipPosition): TooltipPositioner {
|
||||
const positionTooltip = (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => {
|
||||
const bounds = container.getBoundingClientRect();
|
||||
const offsetX = event.clientX - bounds.left;
|
||||
const offsetY = event.clientY - bounds.top;
|
||||
|
||||
// Get tooltip dimensions after content is set
|
||||
const tooltipHeight = tooltip.offsetHeight;
|
||||
const tooltipWidth = tooltip.offsetWidth;
|
||||
const gap = 16; // 1rem gap
|
||||
|
||||
// Position tooltip based on the specified position
|
||||
switch (position) {
|
||||
case 'top':
|
||||
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
|
||||
tooltip.style.top = `${offsetY - tooltipHeight - gap}px`;
|
||||
break;
|
||||
case 'bottom':
|
||||
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
|
||||
tooltip.style.top = `${offsetY + gap}px`;
|
||||
break;
|
||||
case 'left':
|
||||
tooltip.style.left = `${Math.max(10, offsetX - tooltipWidth - gap)}px`;
|
||||
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
|
||||
break;
|
||||
case 'right':
|
||||
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, offsetX + gap)}px`;
|
||||
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
|
||||
break;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const hideTooltip = (tooltip: HTMLElement) => {
|
||||
tooltip.style.opacity = '0';
|
||||
};
|
||||
|
||||
return { positionTooltip, hideTooltip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reusable tooltip element with consistent styling
|
||||
* @param container The container element to append the tooltip to
|
||||
* @returns The created tooltip element
|
||||
*/
|
||||
export function createTooltipElement(container: HTMLElement): HTMLElement {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.style.position = 'absolute';
|
||||
tooltip.style.left = '0';
|
||||
tooltip.style.top = '0';
|
||||
tooltip.style.pointerEvents = 'none';
|
||||
tooltip.style.opacity = '0';
|
||||
tooltip.style.transition = 'opacity 120ms ease';
|
||||
tooltip.style.zIndex = '1000';
|
||||
|
||||
container.appendChild(tooltip);
|
||||
return tooltip;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal, Button, Stack, Slider, Alert, Text, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { getCroppedImage, type Area } from '@app/utils/cropImage';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface ProfilePictureCropperProps {
|
||||
file: File | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCropComplete: (croppedBlob: Blob) => void;
|
||||
}
|
||||
|
||||
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
file,
|
||||
opened,
|
||||
onClose,
|
||||
onCropComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State management
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load image when file changes
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setImageSrc(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImageSrc(reader.result as string);
|
||||
setError(null);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError(t('config.account.profilePicture.cropper.invalidImage', 'Invalid image file. Please select a valid PNG, JPG, or WebP file.'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc);
|
||||
}
|
||||
};
|
||||
}, [file, t]);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
setProcessing(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Called when crop area changes
|
||||
const onCropChange = useCallback((newCrop: { x: number; y: number }) => {
|
||||
setCrop(newCrop);
|
||||
}, []);
|
||||
|
||||
// Called when zoom changes
|
||||
const onZoomChange = useCallback((newZoom: number) => {
|
||||
setZoom(newZoom);
|
||||
}, []);
|
||||
|
||||
// Called when crop is complete (stores the crop area in pixels)
|
||||
const onCropCompleteCallback = useCallback(
|
||||
(_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Process and save the cropped image
|
||||
const handleSave = async () => {
|
||||
if (!imageSrc || !croppedAreaPixels) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Crop the image
|
||||
const croppedBlob = await getCroppedImage(imageSrc, croppedAreaPixels);
|
||||
|
||||
// Validate size (2MB limit)
|
||||
const maxSize = 2 * 1024 * 1024; // 2MB in bytes
|
||||
if (croppedBlob.size > maxSize) {
|
||||
setError(
|
||||
t(
|
||||
'config.account.profilePicture.cropper.sizeErrorAfterCrop',
|
||||
'Cropped image is too large. Please zoom out or crop a smaller area.'
|
||||
)
|
||||
);
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call parent callback with cropped blob
|
||||
onCropComplete(croppedBlob);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Error cropping image:', err);
|
||||
setError(
|
||||
t(
|
||||
'config.account.profilePicture.cropper.cropError',
|
||||
'Failed to crop image. Please try again.'
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('config.account.profilePicture.cropper.title', 'Crop Profile Picture')}
|
||||
size="lg"
|
||||
centered
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{error && (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Cropper area */}
|
||||
<Box style={{ position: 'relative', width: '100%', height: 400 }}>
|
||||
{imageSrc && (
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
onCropChange={onCropChange}
|
||||
onZoomChange={onZoomChange}
|
||||
onCropComplete={onCropCompleteCallback}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('config.account.profilePicture.cropper.zoom', 'Zoom')}
|
||||
</Text>
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={setZoom}
|
||||
disabled={processing}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<Button variant="subtle" onClick={onClose} disabled={processing}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={processing}>
|
||||
{processing
|
||||
? t('config.account.profilePicture.cropper.processing', 'Processing crop...')
|
||||
: t('config.account.profilePicture.cropper.save', 'Save Cropped Image')}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from "react";
|
||||
import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
|
||||
import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection";
|
||||
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
|
||||
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
|
||||
import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits";
|
||||
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
|
||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
export default function ApiKeys() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [showRefreshModal, setShowRefreshModal] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
|
||||
const { data: credits, isLoading: creditsLoading } = useCredits();
|
||||
const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch, hasAttempted } = useApiKey();
|
||||
|
||||
const copy = async (text: string, tag: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(tag);
|
||||
setTimeout(() => setCopied(null), 1600);
|
||||
} catch (e) {
|
||||
// noop – you can surface a notification here
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshKeys = async () => {
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setShowRefreshModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goToAccount = () => {
|
||||
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'overview' } }));
|
||||
};
|
||||
|
||||
const showUsage = Boolean(credits);
|
||||
|
||||
return (
|
||||
<Stack gap={20} p={0}>
|
||||
{showUsage && (
|
||||
<UsageSection
|
||||
apiUsage={credits!}
|
||||
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
|
||||
overlayMessage={t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
|
||||
loading={creditsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isAnonymous && apiKeyError && (
|
||||
<Text size="sm" c="red.5">
|
||||
{t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
|
||||
<Anchor component="button" underline="always" onClick={refetch} c="red.4">
|
||||
{t('common.retry', 'Retry')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isAnonymous ? (
|
||||
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
|
||||
<Stack gap={10}>
|
||||
<Text fw={500}>{t('config.apiKeys.label', 'API Key')}</Text>
|
||||
<Group justify="space-between" wrap="nowrap" align="center" style={{ gap: '1rem' }}>
|
||||
<Text size="sm" c="dimmed" style={{ flex: 1 }}>
|
||||
{t('config.apiKeys.guestInfo', 'Guest users do not receive API keys. Create an account to get an API key you can use in your applications.')}
|
||||
</Text>
|
||||
<Button size="sm" onClick={goToAccount}>
|
||||
{t('config.apiKeys.goToAccount', 'Go to Account')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
apiKeyLoading ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" style={{ marginBottom: 8 }}>
|
||||
{t('config.apiKeys.description', "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.")}
|
||||
</Text>
|
||||
<div style={{ padding: 18, borderRadius: 12, background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
|
||||
<Group align="center" gap={12} wrap="nowrap">
|
||||
<SkeletonLoader type="block" width="100%" height={36} />
|
||||
<SkeletonLoader type="block" width={76} height={32} />
|
||||
<SkeletonLoader type="block" width={92} height={32} />
|
||||
</Group>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ApiKeySection
|
||||
publicKey={apiKey ?? ""}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onRefresh={() => setShowRefreshModal(true)}
|
||||
disabled={isRefreshing}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<RefreshModal
|
||||
opened={showRefreshModal}
|
||||
onClose={() => setShowRefreshModal(false)}
|
||||
onConfirm={refreshKeys}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Avatar, Button, Divider, FileButton, Group, Image, LoadingOverlay, PasswordInput, Text, TextInput, Modal } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from '@app/auth/supabase';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { oauthProviders } from '@app/constants/authProviders';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
import { synchronizeUserUpgrade } from '@app/services/userService';
|
||||
import { ProfilePictureCropper } from '@app/components/shared/config/ProfilePictureCropper';
|
||||
import { updateProfilePictureMetadata } from '@app/services/avatarSyncService';
|
||||
import { deleteCurrentAccount } from '@app/services/accountDeletion';
|
||||
import { alert as showToast } from '@app/components/toast';
|
||||
|
||||
interface OverviewProps {
|
||||
onLogoutClick: () => void;
|
||||
}
|
||||
|
||||
const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { user, refreshSession, signOut, profilePictureUrl, profilePictureMetadata, refreshProfilePicture, refreshProfilePictureMetadata } = useAuth();
|
||||
|
||||
const PROFILE_BUCKET = 'profile-pictures';
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [upgradeError, setUpgradeError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [profileUploading, setProfileUploading] = useState(false);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [cropperFile, setCropperFile] = useState<File | null>(null);
|
||||
const [cropperOpen, setCropperOpen] = useState(false);
|
||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [confirmEmail, setConfirmEmail] = useState('');
|
||||
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const isOAuthPicture = profilePictureMetadata?.source === 'oauth';
|
||||
const provider = profilePictureMetadata?.provider;
|
||||
|
||||
const profilePath = user ? `${user.id}/avatar` : null;
|
||||
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || 'U';
|
||||
|
||||
const handleProfileUpload = async (file: File | null) => {
|
||||
if (!file || !user || !profilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Open cropper instead of uploading directly
|
||||
setProfileError(null);
|
||||
setCropperFile(file);
|
||||
setCropperOpen(true);
|
||||
};
|
||||
|
||||
const handleCropComplete = async (croppedBlob: Blob) => {
|
||||
if (!user || !profilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate cropped size (2MB limit)
|
||||
if (croppedBlob.size > 2 * 1024 * 1024) {
|
||||
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
|
||||
setCropperOpen(false);
|
||||
setCropperFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileUploading(true);
|
||||
setProfileError(null);
|
||||
|
||||
const { error } = await supabase
|
||||
.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.upload(profilePath, croppedBlob, {
|
||||
upsert: true,
|
||||
cacheControl: '3600',
|
||||
contentType: 'image/png'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setProfileError(error.message || 'Failed to upload profile picture');
|
||||
} else {
|
||||
// Mark as manual upload in metadata
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: 'upload',
|
||||
provider: null,
|
||||
});
|
||||
await refreshProfilePictureMetadata();
|
||||
await refreshProfilePicture();
|
||||
}
|
||||
|
||||
setProfileUploading(false);
|
||||
setCropperOpen(false);
|
||||
setCropperFile(null);
|
||||
};
|
||||
|
||||
const handleProfileRemove = async () => {
|
||||
if (!user || !profilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileUploading(true);
|
||||
setProfileError(null);
|
||||
|
||||
const { error } = await supabase
|
||||
.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.remove([profilePath]);
|
||||
|
||||
if (error) {
|
||||
setProfileError(error.message || 'Failed to remove profile picture');
|
||||
} else {
|
||||
// Clear metadata when removing picture
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: 'upload',
|
||||
provider: null,
|
||||
});
|
||||
await refreshProfilePictureMetadata();
|
||||
await refreshProfilePicture();
|
||||
}
|
||||
|
||||
setProfileUploading(false);
|
||||
};
|
||||
|
||||
const handleUseCustomPicture = async () => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileUploading(true);
|
||||
setProfileError(null);
|
||||
|
||||
try {
|
||||
// Update metadata to allow manual uploads
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: 'upload',
|
||||
provider: null,
|
||||
});
|
||||
|
||||
await refreshProfilePictureMetadata();
|
||||
setSuccess(t('config.account.profilePicture.switchedToCustom', 'Switched to custom picture. You can now upload your own.'));
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (error: any) {
|
||||
setProfileError(error.message || 'Failed to switch to custom picture');
|
||||
} finally {
|
||||
setProfileUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailUpgrade = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
setUpgradeError('Email is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setUpgradeError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// First, upgrade the account in Supabase
|
||||
await linkEmailIdentity(email.trim(), password || undefined);
|
||||
|
||||
// Synchronize with backend database (using "email" as auth method for email/password)
|
||||
await synchronizeUserUpgrade('email');
|
||||
|
||||
// Refresh the session to reflect changes
|
||||
await refreshSession();
|
||||
|
||||
setSuccess('Account upgraded successfully! You can now sign in with your email.');
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
} catch (err: any) {
|
||||
setUpgradeError(err?.message || 'Failed to upgrade account');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthUpgrade = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setUpgradeError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// Store provider info for post-redirect handling
|
||||
sessionStorage.setItem('pendingUpgrade', 'true');
|
||||
sessionStorage.setItem('upgradeProvider', provider);
|
||||
|
||||
// Redirect back to homepage after OAuth completes
|
||||
// The UseSession hook will handle the pendingUpgrade synchronization
|
||||
const redirectUrl = absoluteWithBasePath('/auth/callback?next=/');
|
||||
const result = await linkOAuthIdentity(provider, redirectUrl);
|
||||
|
||||
if (result.data?.url) {
|
||||
window.location.href = result.data.url;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`;
|
||||
setUpgradeError(errorMessage);
|
||||
setIsLoading(false);
|
||||
sessionStorage.removeItem('pendingUpgrade');
|
||||
sessionStorage.removeItem('upgradeProvider');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (isAnonymous) return;
|
||||
try {
|
||||
setIsDeletingAccount(true);
|
||||
await deleteCurrentAccount();
|
||||
setDeleteModalOpen(false);
|
||||
setConfirmEmail('');
|
||||
await signOut();
|
||||
window.location.href = absoluteWithBasePath('/login');
|
||||
} catch (err) {
|
||||
const fallbackMessage = t('config.account.overview.deleteFailed', 'Failed to delete account.');
|
||||
const message = err instanceof Error ? err.message : fallbackMessage;
|
||||
console.error('[Overview] Delete account failed:', err);
|
||||
showToast({
|
||||
alertType: 'error',
|
||||
title: t('config.account.overview.deleteFailedTitle', 'Unable to delete account'),
|
||||
body: message,
|
||||
expandable: true,
|
||||
location: 'top-right',
|
||||
durationMs: 7000
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
setDeleteModalOpen(false);
|
||||
setConfirmEmail('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}>
|
||||
<LoadingOverlay visible={isLoading || isDeletingAccount} />
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('config.account.overview.title', 'Account Settings')}
|
||||
</h3>
|
||||
<p style={{ margin: '0.25rem 0 0 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
|
||||
{isAnonymous
|
||||
? t('config.account.overview.guestDescription', 'You are signed in as a guest. Consider upgrading your account below.')
|
||||
: t('config.account.overview.manageAccountPreferences', 'Manage your account preferences')
|
||||
}
|
||||
</p>
|
||||
{user?.email && (
|
||||
<p style={{ margin: '0.25rem 0 0 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
|
||||
{t('config.account.overview.signedInAs', 'Signed in as')}: {user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button color="red" variant="filled" onClick={onLogoutClick}>
|
||||
{t('logOut', 'Log out')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('config.account.profilePicture.title', 'Profile picture')}
|
||||
</h3>
|
||||
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
|
||||
{t('config.account.profilePicture.description', 'Upload an image to personalize your account.')}
|
||||
</p>
|
||||
|
||||
{profileError && (
|
||||
<Alert color="red" mb="md">
|
||||
{profileError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert color="green" mb="md">
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isOAuthPicture ? (
|
||||
<Group align="center" gap="md">
|
||||
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('config.account.profilePicture.usingProvider', 'Using {{provider}} profile picture', {
|
||||
provider: provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : 'OAuth'
|
||||
})}
|
||||
</Text>
|
||||
<Button variant="outline" onClick={handleUseCustomPicture} disabled={profileUploading}>
|
||||
{t('config.account.profilePicture.useCustom', 'Use custom picture')}
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group align="center" gap="md">
|
||||
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Group gap="sm">
|
||||
<FileButton
|
||||
onChange={handleProfileUpload}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={!user || profileUploading}
|
||||
>
|
||||
{(props) => (
|
||||
<Button {...props} loading={profileUploading}>
|
||||
{t('config.account.profilePicture.upload', 'Upload')}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleProfileRemove}
|
||||
disabled={!profilePictureUrl || profileUploading}
|
||||
>
|
||||
{t('config.account.profilePicture.remove', 'Remove')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="var(--mantine-color-dimmed)">
|
||||
{t('config.account.profilePicture.help', 'PNG, JPG, or WebP up to 2MB.')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProfilePictureCropper
|
||||
file={cropperFile}
|
||||
opened={cropperOpen}
|
||||
onClose={() => {
|
||||
setCropperOpen(false);
|
||||
setCropperFile(null);
|
||||
}}
|
||||
onCropComplete={handleCropComplete}
|
||||
/>
|
||||
|
||||
{isAnonymous && <Divider />}
|
||||
|
||||
{isAnonymous && (
|
||||
<div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('config.account.upgrade.title', 'Upgrade Guest Account')}
|
||||
</h3>
|
||||
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
|
||||
{t('config.account.upgrade.description', 'Link your account to preserve your history and access more features!')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{upgradeError && (
|
||||
<Alert color="red" mb="md">
|
||||
{upgradeError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert color="green" mb="md">
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
|
||||
{t('config.account.upgrade.socialLogin', 'Upgrade with Social Account')}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{oauthProviders
|
||||
.filter(provider => !provider.isDisabled)
|
||||
.map((provider) => (
|
||||
<Tooltip
|
||||
key={provider.id}
|
||||
content={`${t('config.account.upgrade.linkWith', 'Link with')} ${provider.label}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<Image
|
||||
src={`${BASE_PATH}/Login/${provider.file}`}
|
||||
alt={provider.label}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleOAuthUpgrade(provider.id as any)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{provider.label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
|
||||
{t('config.account.upgrade.emailPassword', 'or enter your email & password')}
|
||||
</Text>
|
||||
<form onSubmit={handleEmailUpgrade}>
|
||||
<Group align="end" gap="sm">
|
||||
<TextInput
|
||||
label={t('config.account.upgrade.email', 'Email')}
|
||||
placeholder={t('config.account.upgrade.emailPlaceholder', 'Enter your email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('config.account.upgrade.password', 'Password (optional)')}
|
||||
placeholder={t('config.account.upgrade.passwordPlaceholder', 'Set a password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
description={t('config.account.upgrade.passwordNote', 'Leave empty to use email verification only')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{t('config.account.upgrade.upgradeButton', 'Upgrade Account')}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account Section */}
|
||||
{!isAnonymous && (
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
paddingTop: '1.5rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
borderTop: '1px solid var(--mantine-color-default-border)'
|
||||
}}>
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
>
|
||||
{t('config.account.overview.deleteAccount', 'Delete Account')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation Modal */}
|
||||
<Modal
|
||||
opened={deleteModalOpen}
|
||||
onClose={closeDeleteModal}
|
||||
title={t('config.account.overview.deleteAccountTitle', 'Delete Account')}
|
||||
centered
|
||||
zIndex={10000}
|
||||
>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) {
|
||||
handleDeleteAccount();
|
||||
}
|
||||
}}>
|
||||
<Text size="sm" mb="md">
|
||||
{t('config.account.overview.deleteWarning',
|
||||
'This action is permanent and cannot be undone. All your data will be deleted.')}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t('config.account.overview.enterEmailConfirm',
|
||||
'To confirm deletion, please type your email address ({{email}}) below:',
|
||||
{ email: user?.email })}
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder={user?.email || ''}
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
mb="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeDeleteModal} type="button">
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={confirmEmail.toLowerCase() !== user?.email?.toLowerCase()}
|
||||
type="submit"
|
||||
loading={isDeletingAccount}
|
||||
>
|
||||
{t('config.account.overview.confirmDelete', 'Delete My Account')}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
const PasswordSecurity: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [didUpdate, setDidUpdate] = useState(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!newPassword || !confirmPassword) {
|
||||
setError(t('signup.pleaseFillAllFields', 'Please fill in all fields'));
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError(t('signup.passwordTooShort', 'Password must be at least 6 characters long'));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t('signup.passwordsDoNotMatch', 'Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// Update to the new password directly
|
||||
const { error: updateError } = await supabase.auth.updateUser({ password: newPassword });
|
||||
if (updateError) {
|
||||
setError(updateError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'));
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setDidUpdate(true);
|
||||
|
||||
// Replace form with success text, then close after 2s
|
||||
setTimeout(() => {
|
||||
// refresh session after closing to avoid UI jank
|
||||
void refreshSession();
|
||||
setOpened(false);
|
||||
setDidUpdate(false);
|
||||
}, 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to change password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('config.account.security.title', 'Passwords & Security')}
|
||||
</h3>
|
||||
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
|
||||
{t('config.account.security.description', 'Manage your password and security settings.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setOpened(true)} variant="filled">
|
||||
{t('config.account.security.changePassword', 'Change password')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} centered title={t('config.account.security.changePassword', 'Change password')} zIndex={Z_INDEX_OVER_SETTINGS_MODAL}>
|
||||
{error && (
|
||||
<Alert color="red" mb="md">{error}</Alert>
|
||||
)}
|
||||
|
||||
{didUpdate ? (
|
||||
<Alert color="green" mb="md">{success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')}</Alert>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<PasswordInput
|
||||
label={t('account.newPassword', 'New Password')}
|
||||
placeholder={t('account.newPassword', 'New Password')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('account.confirmNewPassword', 'Confirm New Password')}
|
||||
placeholder={t('account.confirmNewPassword', 'Confirm New Password')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Divider my="sm" />
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="default" onClick={() => setOpened(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleChangePassword} loading={isLoading}>
|
||||
{t('config.account.security.update', 'Update password')}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordSecurity;
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Divider, Loader, Alert, Select, Group, Text } from '@mantine/core';
|
||||
import { usePlans, PlanTier } from '@app/hooks/usePlans';
|
||||
import StripeCheckout, { PurchaseType, CreditsPack, PlanID } from '@app/components/shared/StripeCheckoutSaas';
|
||||
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
|
||||
import ApiPackagesSection from '@app/components/shared/config/configSections/plan/ApiPackagesSection';
|
||||
import ActivePlanSection from '@app/components/shared/config/configSections/plan/ActivePlanSection';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
|
||||
const Plan: React.FC = () => {
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
|
||||
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
|
||||
const [purchaseType, setPurchaseType] = useState<PurchaseType>('subscription');
|
||||
const [selectedCreditsPack, setSelectedCreditsPack] = useState<CreditsPack>(null);
|
||||
const [currency, setCurrency] = useState<string>('gbp');
|
||||
const { trialStatus } = useAuth();
|
||||
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
|
||||
{ value: 'usd', label: 'US dollar (USD, $)' },
|
||||
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
|
||||
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
|
||||
{ value: 'eur', label: 'Euro (EUR, €)' },
|
||||
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
|
||||
{ value: 'gbp', label: 'British pound (GBP, £)' }
|
||||
];
|
||||
|
||||
const handleUpgradeClick = useCallback((plan: PlanTier) => {
|
||||
if (!data) return;
|
||||
|
||||
if (plan.isContactOnly) {
|
||||
// Open contact form or redirect to contact page
|
||||
window.open('mailto:[email protected]?subject=Enterprise Plan Inquiry', '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.id !== data.currentPlan.id) {
|
||||
setSelectedPlan(plan);
|
||||
setPurchaseType('subscription');
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleCreditPurchaseClick = useCallback((creditsPack: CreditsPack) => {
|
||||
if (!data) return;
|
||||
|
||||
setSelectedCreditsPack(creditsPack);
|
||||
setPurchaseType('credits');
|
||||
setSelectedPlan(null);
|
||||
setCheckoutOpen(true);
|
||||
}, [data]);
|
||||
|
||||
const handlePaymentSuccess = useCallback((sessionId: string) => {
|
||||
console.log('Payment successful, session:', sessionId);
|
||||
|
||||
// Update local state immediately - no page reload needed
|
||||
if (selectedPlan && purchaseType === 'subscription') {
|
||||
updateCurrentPlan(selectedPlan.id);
|
||||
}
|
||||
|
||||
// Close modal after brief delay to show success message
|
||||
setTimeout(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, 2000);
|
||||
}, [selectedPlan, purchaseType, updateCurrentPlan]);
|
||||
|
||||
const handlePaymentError = useCallback((error: string) => {
|
||||
console.error('Payment error:', error);
|
||||
// Error is already displayed in the StripeCheckout component
|
||||
}, []);
|
||||
|
||||
const handleCheckoutClose = useCallback(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, []);
|
||||
|
||||
const handleAddPaymentClick = useCallback(() => {
|
||||
if (!data) return;
|
||||
|
||||
// Find Pro plan from available plans
|
||||
const proPlan = Array.from(data.plans.values()).find(plan => plan.id === 'pro');
|
||||
|
||||
if (proPlan) {
|
||||
setSelectedPlan(proPlan);
|
||||
setPurchaseType('subscription');
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Check URL parameters for action=add-payment
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('action') === 'add-payment') {
|
||||
handleAddPaymentClick();
|
||||
// Clean up URL
|
||||
params.delete('action');
|
||||
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [data, handleAddPaymentClick]);
|
||||
|
||||
// Early returns after all hooks are called
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title="Error loading plans">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="yellow" title="No data available">
|
||||
Plans data is not available at the moment.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } = data;
|
||||
const plansArray = Array.from(plans.values());
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Currency Selector */}
|
||||
<div>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text size="lg" fw={600}>Currency</Text>
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={(value) => setCurrency(value || 'gbp')}
|
||||
data={currencyOptions}
|
||||
searchable
|
||||
clearable={true}
|
||||
w={300}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<ActivePlanSection
|
||||
currentPlan={currentPlan}
|
||||
_activeSince={activeSince}
|
||||
_nextBillingDate={nextBillingDate}
|
||||
trialStatus={trialStatus ?? undefined}
|
||||
onAddPaymentClick={handleAddPaymentClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plansArray}
|
||||
currentPlan={currentPlan}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ApiPackagesSection
|
||||
apiPackages={apiPackages}
|
||||
selectedCredits={selectedCredits}
|
||||
onSelectedCreditsChange={setSelectedCredits}
|
||||
onCreditPurchaseClick={handleCreditPurchaseClick}
|
||||
/>
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen && (selectedPlan !== null || selectedCreditsPack !== null)}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={purchaseType === 'subscription' ? (selectedPlan?.id as PlanID) : null}
|
||||
creditsPack={purchaseType === 'credits' ? selectedCreditsPack : null}
|
||||
planName={
|
||||
purchaseType === 'subscription'
|
||||
? selectedPlan?.name || ''
|
||||
: data?.apiPackages.find(pkg => pkg.id === selectedCreditsPack)?.name || ''
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={trialStatus?.isTrialing && purchaseType === 'subscription'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Plan;
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Paper,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
|
||||
import { FractionData } from '@app/types/charts';
|
||||
import { ApiCredits as ApiUsage } from "@app/types/credits";
|
||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||
import { formatUTC } from "@app/components/shared/utils/date";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Using shared ApiCredits type as ApiUsage
|
||||
|
||||
interface UsageSectionProps {
|
||||
apiUsage: ApiUsage;
|
||||
obscured?: boolean;
|
||||
overlayMessage?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function UsageSection({ apiUsage, obscured, overlayMessage, loading }: UsageSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const weeklyUsed = apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
|
||||
const boughtUsed = apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
|
||||
|
||||
// Totals for overall usage visualization
|
||||
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
|
||||
|
||||
const formatDate = (iso: string, withTime: boolean) => formatUTC(iso, withTime);
|
||||
|
||||
// Prepare data for the stacked bar chart
|
||||
const fractions: FractionData[] = [
|
||||
{
|
||||
name: t('config.apiKeys.includedCredits', 'Included credits'),
|
||||
numerator: Math.max(0, weeklyUsed),
|
||||
denominator: Math.max(0, apiUsage.weeklyCreditsAllocated),
|
||||
numeratorLabel: t('common.used', 'used'),
|
||||
denominatorLabel: t('common.available', 'available'),
|
||||
color: "var(--usage-weekly-active)"
|
||||
},
|
||||
{
|
||||
name: t('config.apiKeys.purchasedCredits', 'Purchased credits'),
|
||||
numerator: Math.max(0, boughtUsed),
|
||||
denominator: Math.max(0, apiUsage.totalBoughtCredits),
|
||||
numeratorLabel: t('common.used', 'used'),
|
||||
denominatorLabel: t('common.available', 'available'),
|
||||
color: "var(--usage-bought-active)"
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
|
||||
<Stack gap={12}
|
||||
style={{
|
||||
fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>
|
||||
{t('config.apiKeys.creditsRemaining', 'Credits Remaining')}: {loading ? (
|
||||
<SkeletonLoader type="block" width={40} height={14} />
|
||||
) : (
|
||||
totalRemaining
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<StackedBarChart
|
||||
fractions={fractions}
|
||||
width={640}
|
||||
height={22}
|
||||
showLegend={true}
|
||||
tooltipPosition="top"
|
||||
loading={Boolean(loading)}
|
||||
animate={!loading}
|
||||
animationDurationMs={900}
|
||||
ariaLabel={t('config.apiKeys.chartAriaLabel', {
|
||||
includedUsed: Math.max(0, weeklyUsed),
|
||||
includedTotal: Math.max(0, apiUsage.weeklyCreditsAllocated),
|
||||
purchasedUsed: Math.max(0, boughtUsed),
|
||||
purchasedTotal: Math.max(0, apiUsage.totalBoughtCredits),
|
||||
defaultValue: 'Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}'
|
||||
})}
|
||||
/>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('config.apiKeys.nextReset', 'Next Reset')}: {loading ? (
|
||||
<SkeletonLoader type="block" width={120} height={12} />
|
||||
) : (
|
||||
formatDate(apiUsage.weeklyResetDate, false)
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('config.apiKeys.lastApiUse', 'Last API Use')}: {loading ? (
|
||||
<SkeletonLoader type="block" width={160} height={12} />
|
||||
) : (
|
||||
formatDate(apiUsage.lastApiUsage, true)
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{obscured && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
padding: 16,
|
||||
color: "var(--mantine-color-text)",
|
||||
background: "rgba(16, 18, 27, 0.55)",
|
||||
backdropFilter: "blur(6px)",
|
||||
WebkitBackdropFilter: "blur(6px)",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{overlayMessage || t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
export function useApiKey() {
|
||||
const { session, loading, user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
||||
|
||||
const fetchKey = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Backend is POST for get and update
|
||||
const res = await apiClient.post("/api/v1/user/get-api-key");
|
||||
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
|
||||
if (typeof value === "string") setApiKey(value);
|
||||
} catch (e: any) {
|
||||
// If not found, try to create one by calling update endpoint
|
||||
if (e?.response?.status === 404) {
|
||||
try {
|
||||
const createRes = await apiClient.post("/api/v1/user/update-api-key");
|
||||
const created =
|
||||
typeof createRes.data === "string"
|
||||
? createRes.data
|
||||
: createRes.data?.apiKey;
|
||||
if (typeof created === "string") setApiKey(created);
|
||||
} catch (createErr: any) {
|
||||
setError(createErr);
|
||||
}
|
||||
} else {
|
||||
setError(e);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setHasAttempted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiClient.post("/api/v1/user/update-api-key");
|
||||
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
|
||||
if (typeof value === "string") setApiKey(value);
|
||||
} catch (e: any) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && session && !hasAttempted && !isAnonymous) {
|
||||
fetchKey();
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchKey]);
|
||||
|
||||
return { apiKey, isLoading, isRefreshing, error, refetch: fetchKey, refresh, hasAttempted } as const;
|
||||
}
|
||||
|
||||
export default useApiKey;
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { ApiCredits } from "@app/types/credits";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
function coerceNumber(value: unknown, fallback = 0): number {
|
||||
const n = typeof value === "string" ? Number(value) : (value as number);
|
||||
return Number.isFinite(n) ? (n as number) : fallback;
|
||||
}
|
||||
|
||||
function normalizeCredits(raw: any): ApiCredits {
|
||||
// Accept a variety of possible backend keys to be resilient
|
||||
return {
|
||||
weeklyCreditsRemaining: coerceNumber(
|
||||
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left
|
||||
),
|
||||
weeklyCreditsAllocated: coerceNumber(
|
||||
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total
|
||||
),
|
||||
boughtCreditsRemaining: coerceNumber(
|
||||
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left
|
||||
),
|
||||
totalBoughtCredits: coerceNumber(
|
||||
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total
|
||||
),
|
||||
totalAvailableCredits: coerceNumber(
|
||||
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total
|
||||
),
|
||||
weeklyResetDate:
|
||||
(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date) || "",
|
||||
lastApiUsage:
|
||||
(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at) || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
const { session, loading, user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const [data, setData] = useState<ApiCredits | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
||||
|
||||
const fetchCredits = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiClient.get("/api/v1/credits");
|
||||
const normalized = normalizeCredits(res.data);
|
||||
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
|
||||
const isEmpty = !normalized.weeklyCreditsAllocated &&
|
||||
!normalized.weeklyCreditsRemaining &&
|
||||
!normalized.totalBoughtCredits &&
|
||||
!normalized.boughtCreditsRemaining &&
|
||||
!normalized.totalAvailableCredits &&
|
||||
!normalized.weeklyResetDate &&
|
||||
!normalized.lastApiUsage;
|
||||
setData(isEmpty ? null : normalized);
|
||||
} catch (e: any) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setHasAttempted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && session && !hasAttempted && !isAnonymous) {
|
||||
fetchCredits();
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
|
||||
|
||||
return { data, isLoading, error, refetch: fetchCredits, hasAttempted } as const;
|
||||
}
|
||||
|
||||
export default useCredits;
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Group, Flex, Alert, Button, Badge } from '@mantine/core';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import CreditCardIcon from '@mui/icons-material/CreditCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTier } from '@app/hooks/usePlans';
|
||||
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
|
||||
|
||||
interface TrialStatus {
|
||||
isTrialing: boolean;
|
||||
trialEnd: string;
|
||||
daysRemaining: number;
|
||||
hasPaymentMethod: boolean;
|
||||
hasScheduledSub: boolean;
|
||||
}
|
||||
|
||||
interface ActivePlanSectionProps {
|
||||
currentPlan: PlanTier;
|
||||
_activeSince?: string;
|
||||
_nextBillingDate?: string;
|
||||
trialStatus?: TrialStatus;
|
||||
onAddPaymentClick?: () => void;
|
||||
}
|
||||
|
||||
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
currentPlan,
|
||||
_activeSince,
|
||||
_nextBillingDate,
|
||||
trialStatus,
|
||||
onAddPaymentClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center">
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.activePlan.title', 'Active Plan')}
|
||||
</h3>
|
||||
<ManageBillingButton
|
||||
returnUrl={`${window.location.origin}/account`}
|
||||
trialStatus={trialStatus}
|
||||
/>
|
||||
</Flex>
|
||||
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
|
||||
{t('plan.activePlan.subtitle', 'Your current subscription details')}
|
||||
</p>
|
||||
|
||||
{/* Trial Status Alert */}
|
||||
{trialStatus?.isTrialing && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
|
||||
mt="md"
|
||||
mb="md"
|
||||
title={t('plan.trial.title', 'Free Trial Active')}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('plan.trial.daysRemaining', 'Your trial ends in {{days}} days', {
|
||||
days: trialStatus.daysRemaining
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('plan.trial.endDate', 'Expires: {{date}}', {
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString()
|
||||
})}
|
||||
</Text>
|
||||
{trialStatus.hasScheduledSub ? (
|
||||
<Text size="xs" c="green" fw={500} mt="sm">
|
||||
✓ {t('plan.trial.subscriptionScheduled', 'Subscription scheduled - starts {{date}}', {
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString()
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
onAddPaymentClick && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
mt="sm"
|
||||
onClick={onAddPaymentClick}
|
||||
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text size="lg" fw={600}>
|
||||
{currentPlan.name}
|
||||
</Text>
|
||||
{trialStatus?.isTrialing && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t('plan.trial.badge', 'Trial')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{/* {activeSince && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.activeSince', 'Active since {{date}}', { date: activeSince })}
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Text size="xl" fw={700}>
|
||||
{currentPlan.currency}{currentPlan.price}/month
|
||||
</Text>
|
||||
{/* {nextBillingDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })}
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivePlanSection;
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Text, Stack, Flex, Slider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CreditsPack } from '@app/components/shared/StripeCheckoutSaas';
|
||||
|
||||
interface ApiPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
credits: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ApiPackagesSectionProps {
|
||||
apiPackages: ApiPackage[];
|
||||
selectedCredits: number;
|
||||
onSelectedCreditsChange: (value: number) => void;
|
||||
onCreditPurchaseClick: (creditsPack: CreditsPack) => void;
|
||||
}
|
||||
|
||||
const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
apiPackages,
|
||||
selectedCredits,
|
||||
onSelectedCreditsChange,
|
||||
onCreditPurchaseClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.apiPackages.title', 'API Credit Packages')}
|
||||
</h3>
|
||||
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
|
||||
{t('plan.apiPackages.subtitle', 'Purchase API credits for your applications')}
|
||||
</p>
|
||||
|
||||
<Card padding="xl" radius="md" className="mb-4">
|
||||
<Stack gap="xl">
|
||||
{/* Credits Selection */}
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t('plan.selectCredits', 'Select Credit Amount')}
|
||||
</Text>
|
||||
|
||||
<div className="px-4">
|
||||
<Slider
|
||||
value={selectedCredits}
|
||||
onChange={onSelectedCreditsChange}
|
||||
onChangeEnd={(value) => onSelectedCreditsChange(Math.round(value))}
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.01}
|
||||
marks={[
|
||||
{ value: 0, label: '100' },
|
||||
{ value: 1, label: '500' },
|
||||
{ value: 2, label: '1K' },
|
||||
{ value: 3, label: '5K' }
|
||||
]}
|
||||
size="lg"
|
||||
className="mb-6"
|
||||
label={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Package Display */}
|
||||
<Flex gap={"xl"} justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[Math.round(selectedCredits)].credits.toLocaleString()} Credits
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{apiPackages[Math.round(selectedCredits)].description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[Math.round(selectedCredits)].currency}{apiPackages[Math.round(selectedCredits)].price}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.totalCost', 'Total Cost')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => onCreditPurchaseClick(apiPackages[Math.round(selectedCredits)].id as CreditsPack)}
|
||||
>
|
||||
{t('plan.purchase', 'Purchase')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiPackagesSection;
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTier } from '@app/hooks/usePlans';
|
||||
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentPlan?: PlanTier;
|
||||
currentLicenseInfo?: any;
|
||||
onUpgradeClick: (plan: PlanTier | any) => void; // Accept PlanTierGroup for compatibility
|
||||
onManageClick?: (plan: PlanTier | any) => void; // Accept PlanTierGroup for compatibility
|
||||
currency?: string;
|
||||
onCurrencyChange?: (currency: string) => void;
|
||||
currencyOptions?: Array<{ value: string; label: string }>;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentPlan,
|
||||
onUpgradeClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
const isUserProOrAbove = currentPlan?.id === 'pro' || currentPlan?.id === 'enterprise';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.availablePlans.title', 'Available Plans')}
|
||||
</h3>
|
||||
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
|
||||
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
|
||||
</p>
|
||||
|
||||
<div className="flex h-[20rem] mb-4 " style={{ gap: '1rem', overflowX: 'auto' }}>
|
||||
{plans.map(plan => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrentPlan={plan.id === currentPlan?.id}
|
||||
isUserProOrAbove={isUserProOrAbove}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowComparison(!showComparison)}
|
||||
>
|
||||
{showComparison
|
||||
? t('plan.hideComparison', 'Hide Feature Comparison')
|
||||
: t('plan.showComparison', 'Compare All Features')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={showComparison}>
|
||||
<Card padding="lg" radius="md" withBorder className="mt-4">
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t('plan.featureComparison', 'Feature Comparison')}
|
||||
</Text>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">{t('plan.feature.title', 'Feature')}</th>
|
||||
{plans.map(plan => (
|
||||
<th key={plan.id} className="text-center p-2 min-w-24 relative">
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="filled"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0rem',
|
||||
right: '-2rem',
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: '500',
|
||||
height: '1rem',
|
||||
padding: '0 0.1rem'
|
||||
}}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans[0].features.map((_, featureIndex) => (
|
||||
<tr key={featureIndex} className="border-b">
|
||||
<td className="p-2">
|
||||
{plans[0].features[featureIndex].name}
|
||||
</td>
|
||||
{plans.map(plan => (
|
||||
<td key={plan.id} className="text-center p-2">
|
||||
{plan.features[featureIndex].included ? (
|
||||
<Text c="green" fw={600}>✓</Text>
|
||||
) : (
|
||||
<Text c="gray">-</Text>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailablePlansSection;
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTier } from '@app/hooks/usePlans';
|
||||
|
||||
interface PlanCardProps {
|
||||
plan?: PlanTier;
|
||||
planGroup?: any; // For proprietary PlanTierGroup compatibility
|
||||
isCurrentPlan?: boolean;
|
||||
isCurrentTier?: boolean;
|
||||
isDowngrade?: boolean;
|
||||
isUserProOrAbove?: boolean;
|
||||
currentLicenseInfo?: any;
|
||||
currentTier?: string | null; // Accept null for proprietary compatibility
|
||||
onUpgradeClick?: (plan: any) => void; // Accept PlanTierGroup or PlanTier
|
||||
onManageClick?: (plan: any) => void;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({
|
||||
plan: propPlan,
|
||||
planGroup,
|
||||
isCurrentPlan,
|
||||
isCurrentTier: _isCurrentTier,
|
||||
isDowngrade: _isDowngrade,
|
||||
isUserProOrAbove,
|
||||
currentLicenseInfo: _currentLicenseInfo,
|
||||
currentTier: _currentTier,
|
||||
onUpgradeClick,
|
||||
onManageClick: _onManageClick,
|
||||
loginEnabled: _loginEnabled
|
||||
}) => {
|
||||
// Use plan from props, or extract from planGroup if proprietary is using it
|
||||
const plan = propPlan || (planGroup as any)?.monthly || (planGroup as any)?.yearly;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!plan) return null; // Safety check
|
||||
|
||||
const shouldHideUpgrade = plan.id === 'free' && isUserProOrAbove;
|
||||
|
||||
return (
|
||||
<Card key={plan.id} padding="lg" radius="sm" withBorder className="h-full w-[33%] relative">
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size='xs'
|
||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md" className="h-full">
|
||||
<div>
|
||||
<Text size="lg" fw={600}>{plan.name}</Text>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="2xl" fw={700}>
|
||||
{plan.isContactOnly ? t('plan.customPricing', 'Custom') : `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{!plan.isContactOnly && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.period}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight: string, index: number) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div className="flex-grow" />
|
||||
|
||||
{!shouldHideUpgrade && (
|
||||
<Button
|
||||
variant={isCurrentPlan ? "filled" : plan.isContactOnly ? "outline" : "filled"}
|
||||
disabled={isCurrentPlan}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick?.(plan)}
|
||||
>
|
||||
{isCurrentPlan
|
||||
? t('plan.current', 'Current Plan')
|
||||
: plan.isContactOnly
|
||||
? t('plan.contact', 'Get in Touch')
|
||||
: t('plan.upgrade', 'Upgrade')
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { type TFunction } from 'i18next';
|
||||
import {
|
||||
createConfigNavSections as createCoreConfigNavSections,
|
||||
type ConfigNavSection,
|
||||
} from '@core/components/shared/config/configNavSections';
|
||||
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
import PasswordSecurity from '@app/components/shared/config/configSections/PasswordSecurity';
|
||||
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
|
||||
import Plan from '@app/components/shared/config/configSections/Plan';
|
||||
|
||||
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
|
||||
|
||||
interface CreateSaasConfigNavSectionsOptions {
|
||||
isDev?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
t: TFunction<'translation', undefined>;
|
||||
}
|
||||
|
||||
function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSection[] {
|
||||
const preferencesIndex = sections.findIndex(section => section.title === 'Preferences');
|
||||
|
||||
if (preferencesIndex === -1) {
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: 'Preferences',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
icon: 'settings-rounded',
|
||||
component: <GeneralSection />,
|
||||
},
|
||||
{
|
||||
key: 'hotkeys',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'keyboard-rounded',
|
||||
component: <HotkeysSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[] {
|
||||
const hasDeveloper = sections.some(section =>
|
||||
section.items.some(item => item.key === 'developer' || item.key === 'api-keys'),
|
||||
);
|
||||
|
||||
if (hasDeveloper) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: 'Developer',
|
||||
items: [
|
||||
{
|
||||
key: 'api-keys',
|
||||
label: 'API Keys',
|
||||
icon: 'key-rounded',
|
||||
component: <ApiKeys />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function appendBillingSection(
|
||||
sections: ConfigNavSection[],
|
||||
t: TFunction<'translation', undefined>,
|
||||
): ConfigNavSection[] {
|
||||
const hasPlan = sections.some(section =>
|
||||
section.items.some(item => item.key === 'plan'),
|
||||
);
|
||||
|
||||
if (hasPlan) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: 'Billing',
|
||||
items: [
|
||||
{
|
||||
key: 'plan',
|
||||
label: t('config.plan', 'Plan'),
|
||||
icon: 'credit-card',
|
||||
component: <Plan />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createSaasConfigNavSections(
|
||||
Overview: OverviewComponent,
|
||||
onLogoutClick: () => void,
|
||||
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
|
||||
): ConfigNavSection[] {
|
||||
const baseSections = createCoreConfigNavSections(false, false, false);
|
||||
|
||||
// Create Account section as the first section with Overview and Passwords & Security
|
||||
const accountSection: ConfigNavSection = {
|
||||
title: t('config.account.overview.title', 'Account Settings'),
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
label: t('config.account.overview.label', 'Overview'),
|
||||
icon: 'account-circle',
|
||||
component: <Overview onLogoutClick={onLogoutClick} />,
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
label: 'Passwords & Security',
|
||||
icon: 'lock',
|
||||
component: <PasswordSecurity />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let sections = [accountSection, ...baseSections];
|
||||
|
||||
// Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS
|
||||
sections = sections.map(section => ({
|
||||
...section,
|
||||
items: section.items.map(item =>
|
||||
item.key === 'general'
|
||||
? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> }
|
||||
: item
|
||||
),
|
||||
}));
|
||||
|
||||
sections = ensurePreferencesSection(sections);
|
||||
sections = appendDeveloperSection(sections);
|
||||
|
||||
if (!isAnonymous) {
|
||||
sections = appendBillingSection(sections, t);
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
console.debug('[AppConfigModal] SaaS navigation sections', sections);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Re-export from core for compatibility
|
||||
// Override VALID_NAV_KEYS to include saas-specific keys
|
||||
export const VALID_NAV_KEYS = [
|
||||
'overview',
|
||||
'password-security',
|
||||
'preferences',
|
||||
'notifications',
|
||||
'connections',
|
||||
'general',
|
||||
'people',
|
||||
'teams',
|
||||
'security',
|
||||
'identity',
|
||||
'plan',
|
||||
'payments',
|
||||
'requests',
|
||||
'developer',
|
||||
'api-keys',
|
||||
'hotkeys',
|
||||
'adminGeneral',
|
||||
'adminSecurity',
|
||||
'adminConnections',
|
||||
'adminPrivacy',
|
||||
'adminDatabase',
|
||||
'adminAdvanced',
|
||||
'adminLegal',
|
||||
'adminPremium',
|
||||
'adminFeatures',
|
||||
'adminPlan',
|
||||
'adminAudit',
|
||||
'adminUsage',
|
||||
'adminEndpoints'
|
||||
] as const;
|
||||
|
||||
// Extend NavKey to include saas-specific keys
|
||||
export type NavKey =
|
||||
| 'overview'
|
||||
| 'password-security'
|
||||
| 'preferences'
|
||||
| 'notifications'
|
||||
| 'connections'
|
||||
| 'general'
|
||||
| 'people'
|
||||
| 'teams'
|
||||
| 'security'
|
||||
| 'identity'
|
||||
| 'plan'
|
||||
| 'payments'
|
||||
| 'requests'
|
||||
| 'developer'
|
||||
| 'api-keys'
|
||||
| 'hotkeys'
|
||||
| 'adminGeneral'
|
||||
| 'adminSecurity'
|
||||
| 'adminConnections'
|
||||
| 'adminPrivacy'
|
||||
| 'adminDatabase'
|
||||
| 'adminAdvanced'
|
||||
| 'adminLegal'
|
||||
| 'adminPremium'
|
||||
| 'adminFeatures'
|
||||
| 'adminPlan'
|
||||
| 'adminAudit'
|
||||
| 'adminUsage'
|
||||
| 'adminEndpoints';
|
||||
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
@@ -0,0 +1,14 @@
|
||||
export function formatUTC(iso: string, withTime: boolean): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
const formatted = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
...(withTime ? { hour: "2-digit", minute: "2-digit", hour12: false } : {}),
|
||||
timeZone: "UTC",
|
||||
}).format(date);
|
||||
return withTime ? `${formatted} UTC` : formatted;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/* Toast Container Styles */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-container--top-left {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--top-right {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--bottom-left {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toast-container--bottom-right {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* Toast Item Styles */
|
||||
.toast-item {
|
||||
min-width: 320px;
|
||||
max-width: 560px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Toast Alert Type Colors */
|
||||
.toast-item--success {
|
||||
background: var(--color-green-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-item--error {
|
||||
background: var(--color-red-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-item--warning {
|
||||
background: var(--color-orange-100); /* deeper orange background */
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-orange-300); /* deeper orange border */
|
||||
}
|
||||
|
||||
.toast-item--neutral {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Toast Header Row */
|
||||
.toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-title-container {
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-expand-button {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.toast-expand-button--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.toast-progress-container {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
background: var(--bg-muted);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
height: 100%;
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.toast-progress-bar--success {
|
||||
background: var(--color-green-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--error {
|
||||
background: var(--color-red-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--warning {
|
||||
background: var(--color-orange-400); /* deeper orange for progress */
|
||||
}
|
||||
|
||||
.toast-progress-bar--neutral {
|
||||
background: var(--color-gray-500);
|
||||
}
|
||||
|
||||
/* Toast Body */
|
||||
.toast-body {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Toast Action Button */
|
||||
.toast-action-container {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toast-action-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toast-action-button--success {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-action-button--error {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-action-button--warning {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-orange-300);
|
||||
}
|
||||
|
||||
.toast-action-button--neutral {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
// Re-export all constants from core
|
||||
export * from '@core/constants/app';
|
||||
|
||||
// SaaS-specific overrides
|
||||
// Get base URL with fallback (for use outside React components)
|
||||
export const getBaseUrl = (): string => {
|
||||
// Try to get from window object if set by app config
|
||||
const baseUrl = (window as any).__STIRLING_PDF_BASE_URL__ || window.location.origin;
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
// Helper to set base URL (to be called when app config loads)
|
||||
export const setBaseUrl = (url: string): void => {
|
||||
(window as any).__STIRLING_PDF_BASE_URL__ = url;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export type OAuthProviderId = 'google' | 'apple' | 'github' | 'azure'
|
||||
|
||||
export type OAuthProviderMeta = {
|
||||
id: OAuthProviderId
|
||||
label: string
|
||||
file: string
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export const oauthProviders: readonly OAuthProviderMeta[] = [
|
||||
{ id: 'google', label: 'Google', file: 'google.svg', isDisabled: false },
|
||||
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
|
||||
] as const
|
||||
@@ -0,0 +1 @@
|
||||
export const devApiLink = "https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/";
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* SaaS stub — core onboarding is suppressed in SaaS builds.
|
||||
* OnboardingBootstrap calls these to clear any pending core tour state.
|
||||
*/
|
||||
export function useOnboarding() {
|
||||
return {
|
||||
clearPendingTourRequest: () => {},
|
||||
setStartAfterToolModeSelection: (_value: boolean) => {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { signInAnonymously, supabase } from '@app/auth/supabase'
|
||||
import { isAuthRoute, isHomeRoute, isToolRoute } from '@app/utils/pathUtils'
|
||||
|
||||
interface AutoAnonymousAuthState {
|
||||
isAutoAuthenticating: boolean
|
||||
autoAuthError: string | null
|
||||
shouldTriggerAutoAuth: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically signs in users anonymously on direct tool access when not authenticated.
|
||||
*/
|
||||
export function useAutoAnonymousAuth() {
|
||||
const { session, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
const [state, setState] = useState<AutoAnonymousAuthState>({
|
||||
isAutoAuthenticating: false,
|
||||
autoAuthError: null,
|
||||
shouldTriggerAutoAuth: false
|
||||
})
|
||||
|
||||
const shouldAutoAuthenticate = useCallback((): boolean => {
|
||||
const currentPath = location.pathname
|
||||
if (isAuthRoute(currentPath)) return false
|
||||
if (isHomeRoute(currentPath)) return false
|
||||
return isToolRoute(currentPath)
|
||||
}, [location.pathname])
|
||||
|
||||
const waitForToken = useCallback(async (timeoutMs = 7000) => {
|
||||
// Prefer event-driven approach; fallback to polling if needed
|
||||
let resolved = false
|
||||
const done = (ok: boolean) => {
|
||||
resolved = true
|
||||
return ok
|
||||
}
|
||||
|
||||
const hasToken = async () => {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
return !!session?.access_token
|
||||
}
|
||||
|
||||
if (await hasToken()) return true
|
||||
|
||||
const unsub = supabase.auth.onAuthStateChange(async (_evt, _session) => {
|
||||
if (!resolved && (await hasToken())) {
|
||||
unsub.data.subscription.unsubscribe()
|
||||
done(true)
|
||||
}
|
||||
})
|
||||
|
||||
const started = Date.now()
|
||||
while (!resolved && Date.now() - started < timeoutMs) {
|
||||
if (await hasToken()) {
|
||||
unsub.data.subscription.unsubscribe()
|
||||
return done(true)
|
||||
}
|
||||
// gentle backoff
|
||||
await new Promise(r => setTimeout(r, 120))
|
||||
}
|
||||
|
||||
try { unsub.data.subscription.unsubscribe() } catch (err) {
|
||||
// Ignore unsubscribe errors during cleanup
|
||||
console.debug('[useAutoAnonymousAuth] Unsubscribe cleanup error:', err)
|
||||
}
|
||||
return done(false)
|
||||
}, [])
|
||||
|
||||
const triggerAnonymousAuth = useCallback(async () => {
|
||||
if (state.isAutoAuthenticating) return
|
||||
|
||||
setState(prev => ({ ...prev, isAutoAuthenticating: true, autoAuthError: null }))
|
||||
try {
|
||||
console.log('[useAutoAnonymousAuth] anonymous auth starting')
|
||||
|
||||
const { error } = await signInAnonymously()
|
||||
if (error) throw error
|
||||
|
||||
// Wait for a usable token so first API calls won't 401/redirect
|
||||
const ok = await waitForToken(7000)
|
||||
if (!ok) {
|
||||
throw new Error('Timed out waiting for anonymous session token')
|
||||
}
|
||||
|
||||
console.log('[useAutoAnonymousAuth] anonymous auth complete')
|
||||
setState(prev => ({ ...prev, isAutoAuthenticating: false, shouldTriggerAutoAuth: false }))
|
||||
} catch (e) {
|
||||
console.error('[useAutoAnonymousAuth] anonymous auth failed', e)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isAutoAuthenticating: false,
|
||||
autoAuthError: e instanceof Error ? e.message : 'Anonymous authentication failed'
|
||||
}))
|
||||
}
|
||||
}, [state.isAutoAuthenticating, waitForToken])
|
||||
|
||||
// Decide whether to auto-auth on mount & whenever location/auth changes
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
if (session) return
|
||||
if (state.isAutoAuthenticating) return
|
||||
|
||||
const shouldAuth = shouldAutoAuthenticate()
|
||||
if (state.shouldTriggerAutoAuth !== shouldAuth) {
|
||||
setState(prev => ({ ...prev, shouldTriggerAutoAuth: shouldAuth }))
|
||||
}
|
||||
|
||||
if (shouldAuth) {
|
||||
console.log('[useAutoAnonymousAuth] tool route detected, auto-auth')
|
||||
triggerAnonymousAuth()
|
||||
}
|
||||
}, [loading, session, state.isAutoAuthenticating, state.shouldTriggerAutoAuth, shouldAutoAuthenticate, triggerAnonymousAuth])
|
||||
|
||||
// Clear error if route is no longer a tool route, or once authenticated
|
||||
useEffect(() => {
|
||||
if (session || !shouldAutoAuthenticate()) {
|
||||
setState(prev => ({ ...prev, autoAuthError: null, shouldTriggerAutoAuth: false }))
|
||||
}
|
||||
}, [session, shouldAutoAuthenticate])
|
||||
|
||||
return {
|
||||
isAutoAuthenticating: state.isAutoAuthenticating,
|
||||
autoAuthError: state.autoAuthError,
|
||||
shouldTriggerAutoAuth: state.shouldTriggerAutoAuth,
|
||||
triggerAnonymousAuth
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Avatar } from '@mantine/core';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
|
||||
export function useConfigButtonIcon(): React.ReactNode {
|
||||
const { profilePictureUrl } = useAuth();
|
||||
return profilePictureUrl ? <Avatar src={profilePictureUrl} radius="xl" size={24} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCredits } from '@app/hooks/useCredits';
|
||||
import { getToolCreditCost } from '@app/utils/creditCosts';
|
||||
import { openPlanSettings } from '@app/utils/appSettings';
|
||||
import type { ToolId } from '@app/types/toolId';
|
||||
|
||||
export function useCreditCheck(operationType?: string) {
|
||||
const { hasSufficientCredits, isPro, creditBalance, refreshCredits } = useCredits();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const checkCredits = useCallback(async (): Promise<string | null> => {
|
||||
const requiredCredits = getToolCreditCost(operationType as ToolId);
|
||||
const creditCheck = hasSufficientCredits(requiredCredits);
|
||||
|
||||
if (creditBalance === null) {
|
||||
try { await refreshCredits(); } catch (_e) { void _e; }
|
||||
return t('loadingCredits', 'Checking credits...');
|
||||
}
|
||||
|
||||
if (isPro === null) {
|
||||
return t('loadingProStatus', 'Checking subscription status...');
|
||||
}
|
||||
|
||||
if (!isPro && !creditCheck.hasSufficientCredits) {
|
||||
const shortfall = creditCheck.shortfall || 0;
|
||||
const error = t(
|
||||
'insufficientCredits',
|
||||
'Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}',
|
||||
{ requiredCredits, currentBalance: creditCheck.currentBalance, shortfall },
|
||||
);
|
||||
const notice = t('noticeTopUpOrPlan', 'Not enough credits, please top up or upgrade to a plan');
|
||||
openPlanSettings(notice);
|
||||
return error;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [hasSufficientCredits, isPro, creditBalance, refreshCredits, operationType, t]);
|
||||
|
||||
return { checkCredits };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
|
||||
/**
|
||||
* Hook for credit management and checking in tools.
|
||||
* Provides easy access to credit balance, subscription info, and validation functions.
|
||||
*/
|
||||
export const useCredits = () => {
|
||||
const {
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
hasSufficientCredits,
|
||||
updateCredits,
|
||||
refreshCredits
|
||||
} = useAuth()
|
||||
|
||||
|
||||
/**
|
||||
* Get user-friendly credit status message
|
||||
*/
|
||||
const getCreditStatusMessage = (): string => {
|
||||
if (creditBalance === 0) {
|
||||
return 'No credits remaining'
|
||||
}
|
||||
if (creditBalance === null) {
|
||||
return 'Credits loading...'
|
||||
}
|
||||
return `${creditBalance} credits available`
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
|
||||
// Actions
|
||||
refreshCredits,
|
||||
updateCredits,
|
||||
|
||||
// Utilities
|
||||
getCreditStatusMessage,
|
||||
hasSufficientCredits
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
|
||||
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
||||
const globalFetchedSets = new Set<string>();
|
||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||
/**
|
||||
* Hook to check if a specific endpoint is enabled
|
||||
* This wraps the context for single endpoint checks
|
||||
*/
|
||||
export function useEndpointEnabled(endpoint: string): {
|
||||
enabled: boolean | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEndpointStatus = async () => {
|
||||
if (!endpoint) {
|
||||
setEnabled(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
|
||||
|
||||
const isEnabled = response.data;
|
||||
setEnabled(isEnabled);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEndpointStatus();
|
||||
}, [endpoint]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchEndpointStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check multiple endpoints at once using batch API
|
||||
* Returns a map of endpoint -> enabled status
|
||||
*/
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchAllEndpointStatuses = async (force = false) => {
|
||||
const endpointsKey = [...endpoints].sort().join(',');
|
||||
|
||||
// Skip if we already fetched these exact endpoints globally
|
||||
if (!force && globalFetchedSets.has(endpointsKey)) {
|
||||
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check which endpoints we haven't fetched yet
|
||||
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
||||
if (newEndpoints.length === 0) {
|
||||
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Use batch API for efficiency - only fetch new endpoints
|
||||
const endpointsParam = newEndpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const statusMap = response.data;
|
||||
|
||||
// Update global cache with new results
|
||||
Object.entries(statusMap).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
// Get all requested endpoints from cache (including previously cached ones)
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
} catch (err: any) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
globalEndpointCache[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllEndpointStatuses();
|
||||
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: () => fetchAllEndpointStatuses(true),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* SaaS no-op: Supabase handles auth, no JWT event listener needed,
|
||||
* and config is fetched on all pages (401 handling covers unauthenticated state).
|
||||
*/
|
||||
export function useJwtConfigSync(_fetchConfig: (force?: boolean) => void): { isAuthPage: boolean } {
|
||||
return { isAuthPage: false };
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
|
||||
// Currency mapping
|
||||
const getCurrencySymbol = (currency: string): string => {
|
||||
const currencySymbols: { [key: string]: string } = {
|
||||
'gbp': '£',
|
||||
'usd': '$',
|
||||
'eur': '€',
|
||||
'cny': '¥',
|
||||
'inr': '₹',
|
||||
'brl': 'R$',
|
||||
'idr': 'Rp'
|
||||
};
|
||||
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
|
||||
};
|
||||
|
||||
export interface PlanFeature {
|
||||
name: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
export interface PlanTier {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
popular?: boolean;
|
||||
features: PlanFeature[];
|
||||
highlights: string[];
|
||||
isContactOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
credits: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PlansData {
|
||||
plans: Map<string, PlanTier>;
|
||||
apiPackages: ApiPackage[];
|
||||
currentPlan: PlanTier;
|
||||
nextBillingDate?: string;
|
||||
activeSince?: string;
|
||||
}
|
||||
|
||||
export const usePlans = (currency: string = 'gbp') => {
|
||||
const { t } = useTranslation();
|
||||
const { isPro, refreshProStatus } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPlanId, setCurrentPlanId] = useState<string>('free');
|
||||
const [dynamicPrices, setDynamicPrices] = useState<Map<string, { unit_amount: number; currency: string }>>(new Map());
|
||||
|
||||
const fetchPricing = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const lookupKeys = ['plan:pro', 'api:xsmall', 'api:small', 'api:medium', 'api:large'];
|
||||
|
||||
const { data, error } = await supabase.functions.invoke<{
|
||||
prices: Record<string, { unit_amount: number; currency: string }>;
|
||||
missing: string[];
|
||||
}>('stripe-price-lookup', {
|
||||
body: { lookup_keys: lookupKeys, currency }
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || !data.prices || !data.missing) throw new Error('No pricing data returned');
|
||||
console.log('Fetched pricing data:', data);
|
||||
|
||||
const priceMap = new Map<string, { unit_amount: number; currency: string }>();
|
||||
// map your UI keys to lookup keys (if names differ)
|
||||
const keyMap: Record<string, string> = {
|
||||
pro: 'plan:pro',
|
||||
xsmall: 'api:xsmall',
|
||||
small: 'api:small',
|
||||
medium: 'api:medium',
|
||||
large: 'api:large',
|
||||
};
|
||||
|
||||
for (const [uiKey, lookupKey] of Object.entries(keyMap)) {
|
||||
const p = data?.prices?.[lookupKey];
|
||||
if (p) {
|
||||
priceMap.set(uiKey, {
|
||||
unit_amount: p.unit_amount ?? 0,
|
||||
currency: p.currency
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.missing.length) {
|
||||
console.warn('Missing prices for', data.missing, 'in', currency);
|
||||
// Optionally re-request with a fallback currency (e.g., 'usd')
|
||||
}
|
||||
|
||||
setDynamicPrices(priceMap);
|
||||
} catch (err) {
|
||||
console.error('Error fetching pricing:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch pricing data');
|
||||
// continue with static prices if needed
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Memoize static plan and package data to prevent recreation on every render
|
||||
const staticPlansData = useMemo(() => {
|
||||
const plans: PlanTier[] = [
|
||||
{
|
||||
id: 'free',
|
||||
name: t('plan.free.name', 'Free'),
|
||||
price: 0,
|
||||
currency: getCurrencySymbol(currency),
|
||||
period: t('plan.period.month', '/month'),
|
||||
highlights: [
|
||||
t('plan.free.highlight1', "Limited Tool Usage Per week"),
|
||||
t('plan.free.highlight2', "Access to all tools"),
|
||||
t('plan.free.highlight3', 'Community support')
|
||||
],
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: false },
|
||||
{ name: t('plan.feature.automation', 'automate tool workflows'), included: false },
|
||||
{ name: t('plan.feature.api', 'API Access'), included: false },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
|
||||
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: t('plan.pro.name', 'Pro'),
|
||||
price: dynamicPrices.get('pro') ? dynamicPrices.get('pro')!.unit_amount / 100 : 8,
|
||||
currency: dynamicPrices.get('pro') ? getCurrencySymbol(dynamicPrices.get('pro')!.currency) : getCurrencySymbol(currency),
|
||||
period: t('plan.period.month', '/month'),
|
||||
popular: true,
|
||||
highlights: [
|
||||
t('plan.pro.highlight1', 'Unlimited Tool Usage'),
|
||||
t('plan.pro.highlight2', 'Advanced PDF tools'),
|
||||
t('plan.pro.highlight3', 'No watermarks')
|
||||
],
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
|
||||
{ name: t('plan.feature.automation', 'automate tool workflows'), included: true },
|
||||
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
|
||||
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: t('plan.enterprise.name', 'Enterprise'),
|
||||
price: 0,
|
||||
currency: getCurrencySymbol(currency),
|
||||
period: '',
|
||||
isContactOnly: true,
|
||||
highlights: [
|
||||
t('plan.enterprise.highlight1', 'Custom pricing'),
|
||||
t('plan.enterprise.highlight2', 'Dedicated support'),
|
||||
t('plan.enterprise.highlight3', 'Latest features')
|
||||
],
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
|
||||
{ name: t('plan.feature.automation', 'automate tool workflows'), included: true },
|
||||
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: true },
|
||||
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: true },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Helper function to get price info
|
||||
const getPriceInfo = (key: string, fallbackPrice: number) => {
|
||||
const priceObj = dynamicPrices.get(key);
|
||||
const price = priceObj ? priceObj.unit_amount / 100 : fallbackPrice;
|
||||
const currencySymbol = priceObj ? getCurrencySymbol(priceObj.currency) : getCurrencySymbol(currency);
|
||||
return { price, currencySymbol };
|
||||
};
|
||||
|
||||
const xsmallPrice = getPriceInfo('xsmall', 4);
|
||||
const smallPrice = getPriceInfo('small', 15);
|
||||
const mediumPrice = getPriceInfo('medium', 25);
|
||||
const largePrice = getPriceInfo('large', 90);
|
||||
|
||||
// Calculate dynamic discounts based on per-credit cost (using xsmall as baseline)
|
||||
const xsmallPerCredit = xsmallPrice.price / 100;
|
||||
const smallPerCredit = smallPrice.price / 500;
|
||||
const mediumPerCredit = mediumPrice.price / 1000;
|
||||
const largePerCredit = largePrice.price / 5000;
|
||||
|
||||
const smallDiscount = Math.round((1 - smallPerCredit / xsmallPerCredit) * 100);
|
||||
const mediumDiscount = Math.round((1 - mediumPerCredit / xsmallPerCredit) * 100);
|
||||
const largeDiscount = Math.round((1 - largePerCredit / xsmallPerCredit) * 100);
|
||||
|
||||
const apiPackages: ApiPackage[] = [
|
||||
{
|
||||
id: 'xsmall',
|
||||
name: t('plan.api.xsmall', '100 Credits'),
|
||||
price: xsmallPrice.price,
|
||||
currency: xsmallPrice.currencySymbol,
|
||||
credits: 100,
|
||||
description: `${xsmallPrice.currencySymbol}${(xsmallPrice.price / 100).toFixed(3)} per credit`
|
||||
},
|
||||
{
|
||||
id: 'small',
|
||||
name: t('plan.api.small', '500 Credits'),
|
||||
price: smallPrice.price,
|
||||
currency: smallPrice.currencySymbol,
|
||||
credits: 500,
|
||||
description: `${smallPrice.currencySymbol}${(smallPrice.price / 500).toFixed(3)} per credit${smallDiscount > 0 ? ` • ${smallDiscount}% discount` : ''}`
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
name: t('plan.api.medium', '1,000 Credits'),
|
||||
price: mediumPrice.price,
|
||||
currency: mediumPrice.currencySymbol,
|
||||
credits: 1000,
|
||||
description: `${mediumPrice.currencySymbol}${(mediumPrice.price / 1000).toFixed(3)} per credit${mediumDiscount > 0 ? ` • ${mediumDiscount}% discount` : ''}`
|
||||
},
|
||||
{
|
||||
id: 'large',
|
||||
name: t('plan.api.large', '5,000 Credits'),
|
||||
price: largePrice.price,
|
||||
currency: largePrice.currencySymbol,
|
||||
credits: 5000,
|
||||
description: `${largePrice.currencySymbol}${(largePrice.price / 5000).toFixed(3)} per credit${largeDiscount > 0 ? ` • ${largeDiscount}% discount` : ''}`
|
||||
}
|
||||
];
|
||||
|
||||
const plansMap = new Map(plans.map(plan => [plan.id, plan]));
|
||||
return { plans: plansMap, apiPackages };
|
||||
}, [t, dynamicPrices]);
|
||||
|
||||
// Create final data object with current plan info
|
||||
const data = useMemo<PlansData | null>(() => {
|
||||
if (!staticPlansData) return null;
|
||||
|
||||
const currentPlan = staticPlansData.plans.get(currentPlanId);
|
||||
if (!currentPlan) return null;
|
||||
|
||||
return {
|
||||
plans: staticPlansData.plans,
|
||||
apiPackages: staticPlansData.apiPackages,
|
||||
currentPlan,
|
||||
nextBillingDate: 'Feb 15, 2025',
|
||||
activeSince: 'January 2025'
|
||||
};
|
||||
}, [staticPlansData, currentPlanId]);
|
||||
|
||||
|
||||
// Update currentPlanId when isPro changes
|
||||
useEffect(() => {
|
||||
if (isPro !== null) {
|
||||
setCurrentPlanId(isPro ? 'pro' : 'free');
|
||||
}
|
||||
}, [isPro]);
|
||||
|
||||
// Initial load - fetch pricing data
|
||||
useEffect(() => {
|
||||
fetchPricing();
|
||||
}, [currency]); // Re-fetch when currency changes
|
||||
|
||||
const updateCurrentPlan = (newPlanId: string) => {
|
||||
setCurrentPlanId(newPlanId);
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
plans: data ? Array.from(data.plans.values()) : [], // Convert Map to array for compatibility with proprietary code
|
||||
loading,
|
||||
error,
|
||||
refetch: refreshProStatus, // Refetch pro status from auth context
|
||||
updateCurrentPlan // Add local plan update function
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '@app/auth/supabase'
|
||||
import { Button } from '@mantine/core'
|
||||
import { withBasePath } from '@app/constants/app'
|
||||
|
||||
interface CallbackState {
|
||||
status: 'processing' | 'success' | 'error'
|
||||
message: string
|
||||
details?: Record<string, any>
|
||||
}
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
const [state, setState] = useState<CallbackState>({
|
||||
status: 'processing',
|
||||
message: 'Processing authentication...'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const error = url.searchParams.get('error')
|
||||
const errorDescription = url.searchParams.get('error_description')
|
||||
const next = url.searchParams.get('next') || '/'
|
||||
|
||||
console.log('[Auth Callback Debug] URL parameters:', {
|
||||
hasCode: !!code,
|
||||
hasError: !!error,
|
||||
error,
|
||||
errorDescription,
|
||||
next,
|
||||
fullUrl: window.location.href
|
||||
})
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
console.error('[Auth Callback Debug] OAuth error:', { error, errorDescription })
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Authentication failed: ${errorMsg}`,
|
||||
details: { error, errorDescription }
|
||||
})
|
||||
|
||||
// Redirect to login page after 3 seconds
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
// If PKCE/SSR-style code is present, exchange it for a session
|
||||
if (code) {
|
||||
console.log('[Auth Callback Debug] Exchanging code for session...')
|
||||
|
||||
setState({
|
||||
status: 'processing',
|
||||
message: 'Exchanging authorization code...'
|
||||
})
|
||||
|
||||
const { data, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code)
|
||||
|
||||
if (exchangeError) {
|
||||
console.error('[Auth Callback Debug] Code exchange error:', exchangeError)
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Failed to complete sign in: ${exchangeError.message}`,
|
||||
details: { exchangeError }
|
||||
})
|
||||
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Auth Callback Debug] Code exchange successful:', {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email
|
||||
})
|
||||
|
||||
setState({
|
||||
status: 'success',
|
||||
message: 'Sign in successful! Redirecting...',
|
||||
details: {
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
provider: data.session?.user?.app_metadata?.provider
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// No code present - might already be authenticated
|
||||
console.log('[Auth Callback Debug] No code present, checking existing session...')
|
||||
|
||||
const { data: sessionData } = await supabase.auth.getSession()
|
||||
|
||||
if (sessionData.session) {
|
||||
console.log('[Auth Callback Debug] Existing session found')
|
||||
setState({
|
||||
status: 'success',
|
||||
message: 'Already signed in! Redirecting...'
|
||||
})
|
||||
} else {
|
||||
console.log('[Auth Callback Debug] No session found')
|
||||
setState({
|
||||
status: 'error',
|
||||
message: 'No authentication data found'
|
||||
})
|
||||
setTimeout(() => navigate('/login', { replace: true }), 2000)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to the intended destination
|
||||
const destination = next.startsWith('/') ? next : '/'
|
||||
console.log('[Auth Callback Debug] Redirecting to:', destination)
|
||||
|
||||
setTimeout(() => navigate(destination, { replace: true }), 1500)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Auth Callback Debug] Unexpected error:', err)
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
||||
details: { error: err }
|
||||
})
|
||||
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [navigate])
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (state.status) {
|
||||
case 'processing': return 'text-blue-600'
|
||||
case 'success': return 'text-green-600'
|
||||
case 'error': return 'text-red-600'
|
||||
default: return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
switch (state.status) {
|
||||
case 'processing': return 'Signing you in'
|
||||
case 'success': return 'You\'re all set!'
|
||||
case 'error': return 'Authentication failed'
|
||||
default: return 'Authentication'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute -top-32 -left-32 h-96 w-96 rounded-full bg-blue-200/40 blur-3xl"></div>
|
||||
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-emerald-200/40 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className={`w-full max-w-md rounded-2xl bg-white/80 backdrop-blur shadow-xl p-8`}>
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={withBasePath("/branding/StirlingPDFLogoNoTextDark.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="mx-auto mb-5 h-8 opacity-80"
|
||||
/>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{getTitle()}
|
||||
</h1>
|
||||
<p className={`text-base ${getStatusColor()}`}>
|
||||
{state.message}
|
||||
</p>
|
||||
|
||||
{state.status === 'processing' && (
|
||||
<div className="mt-6">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div className="mt-3 h-1 overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="h-full w-1/2 animate-pulse rounded-full bg-blue-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action button - only show if error */}
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{(() => {
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
className="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{import.meta.env.DEV && state.details && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Debug Information
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(state.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
|
||||
import { isToolRoute } from '@app/utils/pathUtils'
|
||||
import HomePage from '@app/pages/HomePage'
|
||||
import Login from '@app/routes/Login'
|
||||
import GuestUserBanner from '@app/components/auth/GuestUserBanner'
|
||||
import { TrialStatusBanner } from '@app/components/shared/TrialStatusBanner'
|
||||
|
||||
export default function Landing() {
|
||||
const { session, loading } = useAuth()
|
||||
const { isAutoAuthenticating, autoAuthError, shouldTriggerAutoAuth } = useAutoAnonymousAuth()
|
||||
const location = useLocation()
|
||||
|
||||
// Check if current path is a tool (prevents premature navigation on first render)
|
||||
const isCurrentPathTool = useMemo(() => isToolRoute(location.pathname), [location.pathname])
|
||||
|
||||
// Match the same guarded bypass used in RequireAuth
|
||||
const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
|
||||
const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true')
|
||||
|
||||
console.log('[Landing] State:', {
|
||||
pathname: location.pathname,
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
isAutoAuthenticating,
|
||||
shouldTriggerAutoAuth,
|
||||
isCurrentPathTool,
|
||||
autoAuthError
|
||||
})
|
||||
|
||||
// Show loading while checking auth, while auto-authenticating, OR while preparing to auto-authenticate
|
||||
// CRITICAL: Also wait if shouldTriggerAutoAuth is true OR if we're on a tool route (prevents navigation before hook evaluates)
|
||||
if (loading || isAutoAuthenticating || (!session && (shouldTriggerAutoAuth || isCurrentPathTool) && !autoAuthError)) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||
<div className="text-gray-600">
|
||||
{isAutoAuthenticating ? 'Setting up your session...' : 'Loading...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If we have a session or dev bypass is enabled, show the main app
|
||||
if (session || devBypassEnabled) {
|
||||
return (
|
||||
<>
|
||||
<GuestUserBanner />
|
||||
<TrialStatusBanner />
|
||||
<HomePage />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// If auto-authentication failed, navigate to login with error state
|
||||
if (autoAuthError && shouldTriggerAutoAuth) {
|
||||
return <Navigate to="/login" replace state={{ autoAuthError, from: location }} />
|
||||
}
|
||||
|
||||
// If we're at home route ("/"), show login directly (marketing/landing page)
|
||||
// Otherwise navigate to login (fixes URL mismatch for tool routes)
|
||||
const isHome = location.pathname === '/' || location.pathname === ''
|
||||
if (isHome) {
|
||||
return <Login />
|
||||
}
|
||||
|
||||
// For non-home routes without auth, navigate to login (preserves from location)
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase, signInAnonymously } from '@app/auth/supabase'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta'
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout'
|
||||
import '@app/routes/authShared/auth.css'
|
||||
import '@app/routes/authShared/saas-auth.css'
|
||||
import GuestSignInButton from '@app/routes/authShared/GuestSignInButton'
|
||||
|
||||
// Import login components
|
||||
import LoginHeader from '@app/routes/login/LoginHeader'
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage'
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'
|
||||
import MagicLinkForm from '@app/routes/login/MagicLinkForm'
|
||||
import OAuthButtons from '@app/routes/login/OAuthButtons'
|
||||
import DividerWithText from '@app/components/shared/DividerWithText'
|
||||
import LoggedInState from '@app/routes/login/LoggedInState'
|
||||
import { absoluteWithBasePath, getBaseUrl } from '@app/constants/app'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { session, loading, refreshSession } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const [isSigningIn, setIsSigningIn] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showMagicLink, setShowMagicLink] = useState(false)
|
||||
const [showEmailForm, setShowEmailForm] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [magicLinkEmail, setMagicLinkEmail] = useState('')
|
||||
// Prefill email from query param (e.g. after password reset)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const emailFromQuery = url.searchParams.get('email')
|
||||
if (emailFromQuery) {
|
||||
setEmail(emailFromQuery)
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t('login.title', 'Sign in')} - Stirling PDF`,
|
||||
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogTitle: `${t('login.title', 'Sign in')} - Stirling PDF`,
|
||||
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`
|
||||
})
|
||||
|
||||
// Show logged in state if authenticated
|
||||
if (session && !loading) {
|
||||
return <LoggedInState />
|
||||
}
|
||||
|
||||
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
const redirectTo = absoluteWithBasePath('/auth/callback')
|
||||
console.log(`[Login] Signing in with ${provider}`)
|
||||
|
||||
const oauthOptions: any = { redirectTo }
|
||||
if (provider === 'apple') {
|
||||
oauthOptions.queryParams = { scope: 'email name' }
|
||||
} else if (provider === 'azure') {
|
||||
oauthOptions.queryParams = { scope: 'openid profile email' }
|
||||
} else {
|
||||
oauthOptions.queryParams = {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: oauthOptions
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(`[Login] ${provider} error:`, error)
|
||||
setError(t('login.failedToSignIn', { provider, message: error.message }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Login] Unexpected error:`, err)
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithEmail = async () => {
|
||||
if (!email || !password) {
|
||||
setError(t('login.pleaseEnterBoth'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[Login] Signing in with email:', email)
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Login] Email sign in error:', error)
|
||||
setError(error.message)
|
||||
} else if (data.user) {
|
||||
console.log('[Login] Email sign in successful')
|
||||
// User will be redirected by the auth state change
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Unexpected error]:', err)
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithMagicLink = async () => {
|
||||
if (!magicLinkEmail) {
|
||||
setError(t('login.pleaseEnterEmail'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[Login] Sending magic link to:', magicLinkEmail)
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email: magicLinkEmail.trim(),
|
||||
options: {
|
||||
emailRedirectTo: absoluteWithBasePath('/auth/callback')
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Login] Magic link error:', error)
|
||||
setError(error.message)
|
||||
} else {
|
||||
setError(null)
|
||||
alert(t('login.magicLinkSent', { email: magicLinkEmail }))
|
||||
setMagicLinkEmail('')
|
||||
setShowMagicLink(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Unexpected error:', err)
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
navigate('/auth/reset')
|
||||
}
|
||||
|
||||
const handleAnonymousSignIn = async () => {
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
console.log('[Login] Signing in anonymously')
|
||||
|
||||
const { data, error } = await signInAnonymously()
|
||||
|
||||
if (error) {
|
||||
console.error('[Login] Anonymous sign in error:', error)
|
||||
setError((error as any)?.message || 'Unknown error')
|
||||
} else if (data.user) {
|
||||
console.log('[Login] Anonymous sign in successful, refreshing session...')
|
||||
|
||||
// Refresh session to ensure backend endpoints are properly synchronized
|
||||
await refreshSession()
|
||||
|
||||
console.log('[Login] Session refreshed, user will be redirected by auth state change')
|
||||
// User will be redirected by the auth state change after session refresh
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Unexpected error:', err)
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm}>
|
||||
<LoginHeader title={t('login.login')} subtitle={t('login.subtitle', 'Sign back in to Stirling PDF')} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="fullwidth"
|
||||
/>
|
||||
|
||||
{/* Divider between OAuth and Email */}
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
{/* Sign in with email button (primary color to match signup CTA) */}
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
disabled={isSigningIn}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{t('login.useEmailInstead', 'Sign in with email')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={isSigningIn ? t('login.loggingIn') : t('login.login')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEmailForm && (
|
||||
<div className="auth-section-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('login.forgotPassword', 'Forgot your password?')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider then Guest */}
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
<GuestSignInButton
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningIn}
|
||||
label={isSigningIn ? t('login.signingIn', 'Signing in...') : t('login.signInAnonymously', 'Sign in as a Guest')}
|
||||
/>
|
||||
|
||||
<div className="auth-bottom-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMagicLink(true)}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('login.useMagicLink', 'Sign in with magic link')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/signup')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('signup.signUp', 'Sign up')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Magic link form renders on demand */}
|
||||
{showMagicLink && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<MagicLinkForm
|
||||
showMagicLink={showMagicLink}
|
||||
magicLinkEmail={magicLinkEmail}
|
||||
setMagicLinkEmail={setMagicLinkEmail}
|
||||
setShowMagicLink={setShowMagicLink}
|
||||
onSubmit={signInWithMagicLink}
|
||||
isSubmitting={isSigningIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout'
|
||||
import LoginHeader from '@app/routes/login/LoginHeader'
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage'
|
||||
import SuccessMessage from '@app/routes/login/SuccessMessage'
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'
|
||||
import NavigationLink from '@app/routes/login/NavigationLink'
|
||||
import { supabase } from '@app/auth/supabase'
|
||||
import { absoluteWithBasePath } from '@app/constants/app'
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
|
||||
export default function ResetPassword() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isRecovery, setIsRecovery] = useState(false)
|
||||
const [didUpdate, setDidUpdate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
const type = url.searchParams.get('type')
|
||||
const code = url.searchParams.get('code')
|
||||
|
||||
// Also parse hash params (Supabase puts tokens & type in the hash)
|
||||
const hash = url.hash || ''
|
||||
const hashParams = new URLSearchParams(hash.startsWith('#') ? hash.substring(1) : hash)
|
||||
const hashType = hashParams.get('type')
|
||||
const hashError = hashParams.get('error')
|
||||
const hashErrorDescription = hashParams.get('error_description')
|
||||
|
||||
if (hashError) {
|
||||
// Show a human-readable error and fall back to email-entry form
|
||||
setError(hashErrorDescription || hashError)
|
||||
setIsRecovery(false)
|
||||
}
|
||||
|
||||
// Consider either source (query or hash) to decide if we're in recovery mode
|
||||
const inRecovery = type === 'recovery' || hashType === 'recovery'
|
||||
setIsRecovery(inRecovery)
|
||||
|
||||
// If a PKCE-style code is present, exchange it for a session immediately
|
||||
const tryExchange = async () => {
|
||||
if (code) {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setIsRecovery(false)
|
||||
} else if (data.session) {
|
||||
setIsRecovery(true)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setIsRecovery(false)
|
||||
}
|
||||
} else {
|
||||
// If no code, see if Supabase already set the session from hash
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (data.session && inRecovery) {
|
||||
setIsRecovery(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
void tryExchange()
|
||||
|
||||
// Clear sensitive tokens from the URL hash
|
||||
if (hash.includes('access_token') || hashError) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname + (inRecovery ? '?type=recovery' : ''))
|
||||
}
|
||||
|
||||
// Listen for Supabase auth state changes to confirm recovery state
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((event) => {
|
||||
if (event === 'PASSWORD_RECOVERY') {
|
||||
setIsRecovery(true)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
sub.subscription.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!email) {
|
||||
setError(t('login.pleaseEnterEmail'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
const redirectTo = absoluteWithBasePath('/auth/reset?type=recovery')
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
|
||||
redirectTo
|
||||
})
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else {
|
||||
setSuccess(t('login.passwordResetSent', { email }))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatePassword = async () => {
|
||||
if (!password || !confirmPassword) {
|
||||
setError(t('signup.pleaseFillAllFields'))
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError(t('signup.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('signup.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
const { data, error } = await supabase.auth.updateUser({ password })
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
return
|
||||
}
|
||||
if (data.user) {
|
||||
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'))
|
||||
// Clear the form fields
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
// Show success-only state and then redirect after a short delay
|
||||
setDidUpdate(true)
|
||||
setTimeout(async () => {
|
||||
const { data: sessionData } = await supabase.auth.getSession()
|
||||
const { data: userData } = await supabase.auth.getUser()
|
||||
const derivedEmail = userData.user?.email || email
|
||||
if (sessionData.session) {
|
||||
navigate('/')
|
||||
} else {
|
||||
const query = derivedEmail ? `?email=${encodeURIComponent(derivedEmail)}` : ''
|
||||
navigate(`/login${query}`)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={isRecovery ? t('login.resetYourPassword', 'Reset your password') : t('login.forgotPassword', 'Forgot your password?')} />
|
||||
{!didUpdate && <SuccessMessage success={success} />}
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{didUpdate ? (
|
||||
<>
|
||||
<SuccessMessage success={success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')} />
|
||||
<NavigationLink
|
||||
onClick={() => navigate('/login')}
|
||||
text={t('login.backToSignIn', 'Back to sign in')}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
) : isRecovery ? (
|
||||
<>
|
||||
<div className="auth-fields">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="password" className="auth-label">{t('signup.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="new-password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('signup.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label htmlFor="confirmPassword" className="auth-label">{t('signup.confirmPassword')}</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="new-password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('signup.confirmPasswordPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdatePassword}
|
||||
disabled={isSubmitting || !password || !confirmPassword}
|
||||
className="auth-button"
|
||||
>
|
||||
{isSubmitting ? t('login.sending', 'Sending…') : t('login.updatePassword', 'Update password')}
|
||||
</button>
|
||||
<NavigationLink
|
||||
onClick={() => navigate('/login')}
|
||||
text={t('login.backToSignIn', 'Back to sign in')}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={''}
|
||||
setEmail={setEmail}
|
||||
setPassword={() => {}}
|
||||
onSubmit={handleSendEmail}
|
||||
isSubmitting={isSubmitting}
|
||||
submitButtonText={t('login.sendResetLink', 'Send reset link')}
|
||||
showPasswordField={false}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-3">
|
||||
{t('login.resetHelp', 'Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one.')}
|
||||
</p>
|
||||
<NavigationLink
|
||||
onClick={() => navigate('/login')}
|
||||
text={t('login.backToSignIn', 'Back to sign in')}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { signInAnonymously } from '@app/auth/supabase'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta'
|
||||
import { getBaseUrl } from '@app/constants/app'
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout'
|
||||
import '@app/routes/authShared/auth.css'
|
||||
import '@app/routes/authShared/saas-auth.css'
|
||||
import GuestSignInButton from '@app/routes/authShared/GuestSignInButton'
|
||||
import { alert } from '@app/components/toast'
|
||||
|
||||
// Import signup components
|
||||
import LoginHeader from '@app/routes/login/LoginHeader'
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage'
|
||||
import OAuthButtons from '@app/routes/login/OAuthButtons'
|
||||
import DividerWithText from '@app/components/shared/DividerWithText'
|
||||
import SignupForm from '@app/routes/signup/SignupForm'
|
||||
import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'
|
||||
import { useAuthService } from '@app/routes/signup/AuthService'
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { session, loading, refreshSession } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const [isSigningUp, setIsSigningUp] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showEmailForm, setShowEmailForm] = useState(false)
|
||||
const [name, setName] = useState(undefined as string | undefined)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [agree, setAgree] = useState(true)
|
||||
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({})
|
||||
|
||||
// Check if we were redirected here with an auto-auth error
|
||||
useEffect(() => {
|
||||
const state = location.state as { autoAuthError?: string } | null
|
||||
if (state?.autoAuthError) {
|
||||
setError(`Unable to access tool: ${state.autoAuthError}`)
|
||||
}
|
||||
}, [location.state])
|
||||
|
||||
// Redirect back to original tool URL once session appears (after auto-anon completes)
|
||||
useEffect(() => {
|
||||
if (!loading && session) {
|
||||
const state = location.state as { from?: { pathname?: string; search?: string; hash?: string } } | null
|
||||
const from = state?.from
|
||||
if (from?.pathname && from.pathname !== '/signup' && from.pathname !== '/login') {
|
||||
const target = `${from.pathname}${from.search ?? ''}${from.hash ?? ''}`
|
||||
console.log('[Signup] Session detected, redirecting back to:', target)
|
||||
navigate(target, { replace: true })
|
||||
}
|
||||
}
|
||||
}, [loading, session, location.state, navigate])
|
||||
|
||||
const handleAnonymousSignIn = async () => {
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[Signup] Initiating anonymous sign-in...')
|
||||
const { data, error } = await signInAnonymously()
|
||||
|
||||
if (error) {
|
||||
console.error('[Signup] Anonymous sign-in error:', error)
|
||||
setError(`Failed to create guest account: ${(error as any)?.message || 'Unknown error'}`)
|
||||
} else if (data.user) {
|
||||
console.log('[Signup] Anonymous sign-in successful, refreshing session...')
|
||||
|
||||
// Refresh session to ensure backend endpoints are properly synchronized
|
||||
await refreshSession()
|
||||
|
||||
console.log('[Signup] Session refreshed, redirecting to home page')
|
||||
// Redirect to home page after successful anonymous login and session refresh
|
||||
navigate('/')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Signup] Anonymous sign-in unexpected error:', err)
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t('signup.title', 'Create an account')} - Stirling PDF`,
|
||||
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`,
|
||||
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`
|
||||
})
|
||||
|
||||
const { validateSignupForm } = useSignupFormValidation()
|
||||
const { signUp, signInWithProvider } = useAuthService()
|
||||
|
||||
const handleSignUp = async () => {
|
||||
const validation = validateSignupForm(email, password, confirmPassword, name)
|
||||
if (!validation.isValid) {
|
||||
setError(validation.error)
|
||||
setFieldErrors(validation.fieldErrors || {})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
setFieldErrors({})
|
||||
|
||||
const result = await signUp(email, password, name)
|
||||
|
||||
if (result.requiresEmailConfirmation) {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('signup.checkEmailConfirmation'),
|
||||
location: 'top-right',
|
||||
isPersistentPopup: true
|
||||
})
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('signup.accountCreatedSuccessfully'),
|
||||
location: 'top-right',
|
||||
durationMs: 3000
|
||||
})
|
||||
setTimeout(() => navigate('/login'), 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Signup] Unexpected error:', err)
|
||||
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderSignIn = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
await signInWithProvider(provider)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm}>
|
||||
<LoginHeader title={t('signup.title')} subtitle={t('signup.subtitle')} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<OAuthButtons
|
||||
onProviderClick={handleProviderSignIn}
|
||||
isSubmitting={isSigningUp}
|
||||
layout="fullwidth"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider between OAuth and Email */}
|
||||
<div style={{ margin: '0.5rem 0' }}>
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
</div>
|
||||
|
||||
{/* Use Email Instead button (toggles email form) */}
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSigningUp}
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{t('signup.useEmailInstead', 'Use Email Instead')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<SignupForm
|
||||
name={name}
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
agree={agree}
|
||||
setName={setName}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
setAgree={setAgree}
|
||||
onSubmit={handleSignUp}
|
||||
isSubmitting={isSigningUp}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="auth-section-sm">
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
</div>
|
||||
|
||||
<GuestSignInButton
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningUp}
|
||||
label={isSigningUp ? t('login.signingIn', 'Signing in...') : t('login.signInAnonymously', 'Sign in as a Guest')}
|
||||
/>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="auth-bottom-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('login.logIn', 'Log In')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.authContainer {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--auth-bg-color-light-only);
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Main area above footer: keep card centered even with footer visible */
|
||||
.authMain {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 95vh;
|
||||
}
|
||||
|
||||
.authCard {
|
||||
width: min(45rem, 96vw);
|
||||
height: min(50.875rem, 96vh);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
background-color: var(--auth-card-bg);
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
max-height: 96vh;
|
||||
}
|
||||
|
||||
.authCardTwoColumns {
|
||||
width: min(73.75rem, 96vw);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.authLeftPanel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.authLeftPanelCentered {
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.authLeftPanelScrollable {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.authLeftPanel::-webkit-scrollbar {
|
||||
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
|
||||
}
|
||||
|
||||
.authContent {
|
||||
max-width: 26.25rem; /* 420px */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.authLeftPanelScrollable .authContent {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
|
||||
import buildLoginSlides from '@app/components/shared/loginSlides';
|
||||
import styles from '@app/routes/authShared/AuthLayout.module.css';
|
||||
import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
|
||||
import Footer from '@app/components/shared/Footer';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode
|
||||
isEmailFormExpanded?: boolean
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children, isEmailFormExpanded = false }: AuthLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const leftPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
const logoVariant = useLogoVariant();
|
||||
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
|
||||
const isOverflowing = useIsOverflowing(leftPanelRef);
|
||||
|
||||
// Use either overflow detection or email form expansion to determine scrollable state
|
||||
const shouldBeScrollable = isOverflowing || isEmailFormExpanded;
|
||||
|
||||
// Force light mode on auth pages
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
|
||||
|
||||
// Set light mode
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
|
||||
|
||||
// Cleanup: restore previous theme when leaving auth pages
|
||||
return () => {
|
||||
if (previousColorScheme) {
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
// Use viewport to avoid hysteresis when the card is already in single-column mode
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
|
||||
const columnWidth = cardWidthIfTwoCols / 2;
|
||||
const tooNarrow = columnWidth < 470;
|
||||
const tooShort = viewportHeight < 740;
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div className={styles.authMain}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||
>
|
||||
<div
|
||||
ref={leftPanelRef}
|
||||
className={`${styles.authLeftPanel} ${shouldBeScrollable ? styles.authLeftPanelScrollable : styles.authLeftPanelCentered}`}
|
||||
>
|
||||
<div className={styles.authContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '100vw', marginTop: 'auto', marginLeft: '-1.5rem', marginRight: '-1.5rem' }}>
|
||||
<Footer forceLightMode={true} analyticsEnabled />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import '@app/routes/authShared/auth.css'
|
||||
import '@app/routes/authShared/saas-auth.css'
|
||||
|
||||
interface GuestSignInButtonProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function GuestSignInButton({ label, onClick, disabled }: GuestSignInButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed auth-guest-button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* SaaS-specific auth styles — imported alongside the base auth.css */
|
||||
|
||||
.oauth-container-fullwidth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.625rem;
|
||||
background-color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auth-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-dropdown-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.auth-dropdown {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
margin-top: 0.5rem;
|
||||
min-width: 16rem;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.auth-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-guest-button {
|
||||
background-color: #ffffff;
|
||||
color: #9c2f30;
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
import '@app/routes/authShared/saas-auth.css';
|
||||
|
||||
interface EmailPasswordFormProps {
|
||||
email: string
|
||||
password: string
|
||||
setEmail: (email: string) => void
|
||||
setPassword: (password: string) => void
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
submitButtonText: string
|
||||
showPasswordField?: boolean
|
||||
fieldErrors?: {
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailPasswordForm({
|
||||
email,
|
||||
password,
|
||||
setEmail,
|
||||
setPassword,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
submitButtonText,
|
||||
showPasswordField = true,
|
||||
fieldErrors = {}
|
||||
}: EmailPasswordFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="auth-fields">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="email" className="auth-label">{t('login.email', 'Email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username email"
|
||||
placeholder={t('login.enterEmail', 'Enter email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<div className="auth-field-error">{fieldErrors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPasswordField && (
|
||||
<div className="auth-field">
|
||||
<label htmlFor="password" className="auth-label">{t('login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="current-password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t('login.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<div className="auth-field-error">{fieldErrors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email || (showPasswordField && !password)}
|
||||
className="auth-button"
|
||||
>
|
||||
{submitButtonText}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
|
||||
export default function LoadingState() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f3f4f6'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '16px' }}>⏳</div>
|
||||
<p style={{ color: '#6b7280' }}>{t('loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
import '@app/routes/authShared/auth.css'
|
||||
import '@app/routes/authShared/saas-auth.css'
|
||||
|
||||
interface MagicLinkFormProps {
|
||||
showMagicLink: boolean
|
||||
magicLinkEmail: string
|
||||
setMagicLinkEmail: (email: string) => void
|
||||
setShowMagicLink: (show: boolean) => void
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export default function MagicLinkForm({
|
||||
showMagicLink,
|
||||
magicLinkEmail,
|
||||
setMagicLinkEmail,
|
||||
setShowMagicLink,
|
||||
onSubmit,
|
||||
isSubmitting
|
||||
}: MagicLinkFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!showMagicLink) {
|
||||
return (
|
||||
<div className="auth-toggle-wrapper">
|
||||
<button
|
||||
onClick={() => { setShowMagicLink(true) }}
|
||||
disabled={isSubmitting}
|
||||
className="auth-toggle-link"
|
||||
>
|
||||
{t('login.useMagicLink')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-magic-row">
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t('login.enterEmailForMagicLink')}
|
||||
value={magicLinkEmail}
|
||||
onChange={(e) => setMagicLinkEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
|
||||
className="auth-input"
|
||||
/>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !magicLinkEmail}
|
||||
className="auth-magic-button"
|
||||
>
|
||||
{isSubmitting ? t('login.sending') : t('login.sendMagicLink')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { oauthProviders } from '@app/constants/authProviders'
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
import { Tooltip } from '@app/components/shared/Tooltip'
|
||||
import { withBasePath } from '@app/constants/app'
|
||||
|
||||
// Exports for compatibility with proprietary code
|
||||
export const DEBUG_SHOW_ALL_PROVIDERS = false;
|
||||
export const oauthProviderConfig = {
|
||||
google: { label: 'Google', file: 'google.svg' },
|
||||
github: { label: 'GitHub', file: 'github.svg' },
|
||||
apple: { label: 'Apple', file: 'apple.svg' },
|
||||
azure: { label: 'Microsoft', file: 'microsoft.svg' },
|
||||
};
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
onProviderClick: (provider: 'github' | 'google') => void
|
||||
isSubmitting: boolean
|
||||
layout?: 'vertical' | 'grid' | 'icons' | 'fullwidth'
|
||||
enabledProviders?: string[] // List of enabled provider IDs from backend
|
||||
}
|
||||
|
||||
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders: _enabledProviders = [] }: OAuthButtonsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (layout === 'icons') {
|
||||
return (
|
||||
<div className="oauth-container-icons">
|
||||
{oauthProviders.map((p) => (
|
||||
<Tooltip
|
||||
key={p.id}
|
||||
content={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-icon"
|
||||
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
>
|
||||
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-small ${p.isDisabled ? 'opacity-20' : ''}`}/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className="oauth-container-grid">
|
||||
{oauthProviders.map((p) => (
|
||||
<Tooltip
|
||||
key={p.id}
|
||||
content={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-grid"
|
||||
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
>
|
||||
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-medium ${p.isDisabled ? 'opacity-20' : ''}`}/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'fullwidth') {
|
||||
return (
|
||||
<div className="oauth-container-fullwidth">
|
||||
{oauthProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-fullwidth"
|
||||
title={p.label}
|
||||
>
|
||||
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-medium ${p.isDisabled ? 'opacity-20' : ''}`} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{oauthProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-vertical"
|
||||
title={p.label}
|
||||
>
|
||||
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-tiny ${p.isDisabled ? 'opacity-20' : ''}`} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface SuccessMessageProps {
|
||||
success: string | null
|
||||
}
|
||||
|
||||
export default function SuccessMessage({ success }: SuccessMessageProps) {
|
||||
if (!success) return null
|
||||
|
||||
return (
|
||||
<div className="success-message">
|
||||
<p className="success-message-text">{success}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { supabase } from '@app/auth/supabase'
|
||||
import { absoluteWithBasePath } from '@app/constants/app'
|
||||
|
||||
export const useAuthService = () => {
|
||||
|
||||
const signUp = async (
|
||||
email: string,
|
||||
password: string,
|
||||
name?: string
|
||||
) => {
|
||||
console.log('[Signup] Creating account for:', email)
|
||||
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
options: {
|
||||
emailRedirectTo: absoluteWithBasePath('/auth/callback'),
|
||||
data: { full_name: name }
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Signup] Sign up error:', error)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
console.log('[Signup] Sign up successful:', data.user)
|
||||
return {
|
||||
user: data.user,
|
||||
session: data.session,
|
||||
requiresEmailConfirmation: data.user && !data.session
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unknown error occurred during signup')
|
||||
}
|
||||
|
||||
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: { redirectTo: absoluteWithBasePath('/auth/callback') }
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signUp,
|
||||
signInWithProvider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
|
||||
interface DeleteAccountOptions {
|
||||
notifyUser?: boolean;
|
||||
}
|
||||
|
||||
interface DeleteUserResponse {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
deleted_supabase_id?: string;
|
||||
stripe_redaction_job_id?: string | null;
|
||||
}
|
||||
|
||||
export async function deleteCurrentAccount(options?: DeleteAccountOptions): Promise<void> {
|
||||
const { data, error } = await supabase.functions.invoke<DeleteUserResponse>('delete-user', {
|
||||
body: {
|
||||
notify_user: options?.notifyUser ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error || !data?.success) {
|
||||
const serverMessage = data?.error;
|
||||
const errorMessage = serverMessage || error?.message || 'Failed to delete account';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
|
||||
// Mock supabase
|
||||
vi.mock('@app/auth/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('apiClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset modules to get fresh instance of apiClient
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should add JWT token to request headers when session exists', async () => {
|
||||
const mockToken = 'test-jwt-token-12345';
|
||||
const mockSession: any = {
|
||||
access_token: mockToken,
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
user: { id: 'user-123' },
|
||||
};
|
||||
|
||||
// Mock getSession to return a session with token
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: mockSession },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import('@app/services/apiClient');
|
||||
|
||||
// Create a mock adapter to intercept the request
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Verify the Authorization header is set correctly
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${mockToken}`);
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request
|
||||
await apiClient.get('/api/v1/test');
|
||||
|
||||
// Verify the request was made with the token
|
||||
expect(mockAdapter).toHaveBeenCalled();
|
||||
expect(supabase.auth.getSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle requests when no session exists', async () => {
|
||||
// Mock getSession to return no session
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: null },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import('@app/services/apiClient');
|
||||
|
||||
// Create a mock adapter to intercept the request
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Verify no Authorization header is set
|
||||
expect(config.headers.Authorization).toBeUndefined();
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request
|
||||
await apiClient.get('/api/v1/test');
|
||||
|
||||
// Verify the request was made without a token
|
||||
expect(mockAdapter).toHaveBeenCalled();
|
||||
expect(supabase.auth.getSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh token on 401 response', async () => {
|
||||
const oldToken = 'old-token';
|
||||
const newToken = 'new-refreshed-token';
|
||||
|
||||
const oldSession: any = {
|
||||
access_token: oldToken,
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
user: { id: 'user-123' },
|
||||
};
|
||||
|
||||
const newSession: any = {
|
||||
access_token: newToken,
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
user: { id: 'user-123' },
|
||||
};
|
||||
|
||||
// Mock initial session for first request
|
||||
let getSessionCallCount = 0;
|
||||
vi.mocked(supabase.auth.getSession).mockImplementation(async () => {
|
||||
getSessionCallCount++;
|
||||
// First call returns old session, subsequent calls return new session
|
||||
if (getSessionCallCount === 1) {
|
||||
return { data: { session: oldSession }, error: null };
|
||||
}
|
||||
return { data: { session: newSession }, error: null };
|
||||
});
|
||||
|
||||
// Mock refresh to return new session
|
||||
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
|
||||
data: { user: null, session: newSession },
|
||||
error: null as any,
|
||||
} as any);
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import('@app/services/apiClient');
|
||||
|
||||
let requestCount = 0;
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
requestCount++;
|
||||
|
||||
// First request returns 401
|
||||
if (requestCount === 1) {
|
||||
// Verify first request has old token
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${oldToken}`);
|
||||
const error: any = new Error('Unauthorized');
|
||||
error.response = {
|
||||
status: 401,
|
||||
data: { error: 'Unauthorized' },
|
||||
};
|
||||
error.config = config;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Second request (after refresh) should have new token
|
||||
// The interceptor will call getSession again, which now returns the new session
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${newToken}`);
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request that will trigger 401 and retry
|
||||
const response = await apiClient.get('/api/v1/test');
|
||||
|
||||
// Verify the token was refreshed and request retried
|
||||
expect(response.data).toEqual({ success: true });
|
||||
expect(supabase.auth.refreshSession).toHaveBeenCalled();
|
||||
expect(mockAdapter).toHaveBeenCalledTimes(2);
|
||||
expect(getSessionCallCount).toBe(3); // Called for initial request, for checking if refresh is possible, and for retry
|
||||
});
|
||||
|
||||
it('should handle refresh token failure', async () => {
|
||||
const oldToken = 'old-token';
|
||||
|
||||
const oldSession = {
|
||||
access_token: oldToken,
|
||||
user: { id: 'user-123' },
|
||||
};
|
||||
|
||||
// Mock initial session
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: oldSession },
|
||||
error: null,
|
||||
} as any);
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: oldSession },
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
// Mock refresh to fail
|
||||
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
|
||||
data: { user: null, session: null },
|
||||
error: { name: 'AuthError', message: 'Refresh failed', status: 400, code: 'auth_error', __isAuthError: true } as any,
|
||||
} as any);
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import('@app/services/apiClient');
|
||||
|
||||
// Mock window.location for redirect test
|
||||
delete (window as any).location;
|
||||
window.location = { href: '' } as any;
|
||||
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Always return 401 to trigger refresh
|
||||
const error: any = new Error('Unauthorized');
|
||||
error.response = {
|
||||
status: 401,
|
||||
data: { error: 'Unauthorized' },
|
||||
};
|
||||
error.config = config;
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request that will trigger 401
|
||||
try {
|
||||
await apiClient.get('/api/v1/test');
|
||||
// Should not reach here
|
||||
expect(true).toBe(false);
|
||||
} catch (_) {
|
||||
// Verify refresh was attempted
|
||||
expect(supabase.auth.refreshSession).toHaveBeenCalled();
|
||||
// Verify redirect to login
|
||||
expect(window.location.href).toBe('/login');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import axios from 'axios';
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
import { handleHttpError } from '@app/services/httpErrorHandler';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { openPlanSettings } from '@app/utils/appSettings';
|
||||
|
||||
// Global credit update callback - will be set by the AuthProvider
|
||||
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
|
||||
|
||||
// Function to set the global credit update callback
|
||||
export const setGlobalCreditUpdateCallback = (callback: (credits: number) => void) => {
|
||||
globalCreditUpdateCallback = callback;
|
||||
};
|
||||
|
||||
// Helper: decode base64url JWT payload safely
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=');
|
||||
const json = typeof atob !== 'undefined'
|
||||
? atob(padded)
|
||||
: Buffer.from(padded, 'base64').toString('binary');
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
console.warn('[API Client] Failed to decode JWT payload:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create axios instance with default config
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/', // Use env var or relative path (proxied by Vite in dev)
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
const LOW_CREDIT_THRESHOLD = 10;
|
||||
function notifyLowCredits(credits: number) {
|
||||
const title = 'Credit balance low';
|
||||
const body = `You have ${credits} credits remaining.`;
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title,
|
||||
body,
|
||||
buttonText: 'Top up',
|
||||
buttonCallback: () => openPlanSettings(),
|
||||
isPersistentPopup: true,
|
||||
location: 'bottom-right'
|
||||
});
|
||||
}
|
||||
// Request interceptor to add JWT token to all requests
|
||||
apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
try {
|
||||
// Get the current session from Supabase
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('[API Client] Error getting session:', error);
|
||||
}
|
||||
|
||||
// If we have a session with an access token, add it to the Authorization header
|
||||
if (session?.access_token) {
|
||||
config.headers.Authorization = `Bearer ${session.access_token}`;
|
||||
const payload = decodeJwtPayload(session.access_token);
|
||||
const role = (payload?.['role'] as string) || (payload?.['user_role'] as string) || undefined;
|
||||
const aud = payload?.['aud'] as string | undefined;
|
||||
const isAnon = role === 'anon' || aud === 'anon';
|
||||
|
||||
// Debug logs for visibility during integration
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[API Client] Added JWT token to request:', config.url);
|
||||
console.debug('[API Client] JWT payload:', payload);
|
||||
console.debug('[API Client] Token role:', role, '| aud:', aud, '| isAnon:', isAnon);
|
||||
}
|
||||
} else {
|
||||
console.debug('[API Client] No JWT token available for request:', config.url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[API Client] Error in request interceptor:', error);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// List of endpoints that don't require authentication
|
||||
const publicEndpoints = [
|
||||
'/api/v1/config/app-config',
|
||||
'/api/v1/info/status',
|
||||
'/api/v1/config/public-config',
|
||||
'/api/v1/config/endpoints-enabled',
|
||||
];
|
||||
|
||||
// Response interceptor for handling token refresh and credit updates
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Check for X-Credits-Remaining header and update credits automatically
|
||||
const creditsRemaining = response.headers['x-credits-remaining'];
|
||||
if (creditsRemaining && globalCreditUpdateCallback) {
|
||||
const credits = parseInt(creditsRemaining, 10);
|
||||
if (!isNaN(credits) && credits >= 0) {
|
||||
console.debug('[API Client] Updating credits from response header:', credits, 'for URL:', response.config?.url);
|
||||
globalCreditUpdateCallback(credits);
|
||||
// Show low-credit toast with top-up button when below threshold
|
||||
if (credits < LOW_CREDIT_THRESHOLD) {
|
||||
notifyLowCredits(credits);
|
||||
}
|
||||
} else {
|
||||
console.warn('[API Client] Invalid credits value in response header:', creditsRemaining);
|
||||
}
|
||||
}
|
||||
if (response.config?.url?.includes('/api/v1/credits')) {
|
||||
console.debug('[API Client] Credits endpoint response headers:', response.headers);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
const isPublicEndpoint = publicEndpoints.some(endpoint =>
|
||||
originalRequest.url?.includes(endpoint)
|
||||
);
|
||||
|
||||
// If we get a 401 and haven't already tried to refresh, and it's not a public endpoint
|
||||
if (error.response?.status === 401 && !originalRequest._retry && !isPublicEndpoint) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Check if we have a session to refresh
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
// Only try to refresh if we actually have a session
|
||||
if (session) {
|
||||
const { data: { session: refreshedSession }, error: refreshError } = await supabase.auth.refreshSession();
|
||||
|
||||
if (refreshError) {
|
||||
console.error('[API Client] Token refresh failed:', refreshError);
|
||||
|
||||
// Only redirect to login for protected endpoints, not public ones
|
||||
const isPublicEndpoint = originalRequest.url?.includes('/api/v1/config/') ||
|
||||
originalRequest.url?.includes('/api/v1/info/');
|
||||
|
||||
if (!isPublicEndpoint) {
|
||||
// Redirect to login only for protected endpoints
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (refreshedSession?.access_token) {
|
||||
// Update the Authorization header with the new token
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${refreshedSession.access_token}`;
|
||||
console.debug('[API Client] Retrying request with refreshed token');
|
||||
|
||||
// Retry the original request with the new token
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} else {
|
||||
// No session exists, only redirect if not already on login page
|
||||
console.debug('[API Client] No session to refresh, 401 on protected endpoint');
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('[API Client] Error during token refresh:', refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// For public endpoints with 401, just log and continue (don't redirect)
|
||||
if (isPublicEndpoint && error.response?.status === 401) {
|
||||
console.debug('[API Client] 401 on public endpoint, continuing without auth:', originalRequest.url);
|
||||
}
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
const method = error.config?.method?.toUpperCase();
|
||||
|
||||
console.error('[API Client] HTTP Error', {
|
||||
status,
|
||||
method,
|
||||
url,
|
||||
error: error.message,
|
||||
data: error.response?.data
|
||||
});
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Avatar sync service for OAuth provider profile pictures
|
||||
* Downloads, optimizes, and syncs profile pictures from OAuth providers
|
||||
*/
|
||||
|
||||
import { supabase } from '@app/auth/supabase'
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
|
||||
const PROFILE_BUCKET = 'profile-pictures'
|
||||
const AVATAR_SIZE = 256 // 256x256 pixels
|
||||
const MAX_AVATAR_SIZE = 500 * 1024 // 500KB max file size after optimization
|
||||
const SYNC_INTERVAL_DAYS = 7 // Resync every 7 days
|
||||
|
||||
// Client-side cache to prevent repeated sync attempts in same browser session
|
||||
const sessionSyncCache = new Map<string, { timestamp: number; success: boolean }>()
|
||||
|
||||
export interface ProfilePictureMetadata {
|
||||
user_id: string
|
||||
source: 'oauth' | 'upload'
|
||||
provider: 'google' | 'github' | 'apple' | 'azure' | null
|
||||
last_synced_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract avatar URL from OAuth provider user metadata
|
||||
* @param user Supabase User object
|
||||
* @returns Avatar URL or null if not available
|
||||
*/
|
||||
export function getProviderAvatarUrl(user: User): string | null {
|
||||
const provider = user.app_metadata?.provider
|
||||
const metadata = user.user_metadata
|
||||
|
||||
if (!provider || !metadata) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
case 'azure':
|
||||
// Google and Azure use 'picture' field
|
||||
return metadata.picture || null
|
||||
case 'github':
|
||||
// GitHub uses 'avatar_url' field
|
||||
return metadata.avatar_url || null
|
||||
case 'apple':
|
||||
// Apple doesn't provide profile pictures via OAuth
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and optimize an avatar image
|
||||
* Resizes to 256x256 and converts to PNG format
|
||||
* @param url Avatar URL from OAuth provider
|
||||
* @returns Optimized image blob
|
||||
*/
|
||||
export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
|
||||
try {
|
||||
// 1. Fetch image from provider URL
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download avatar: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
|
||||
// 2. Create image bitmap
|
||||
const img = await createImageBitmap(blob)
|
||||
|
||||
// 3. Create canvas and draw scaled image
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = AVATAR_SIZE
|
||||
canvas.height = AVATAR_SIZE
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context')
|
||||
}
|
||||
|
||||
// Draw image scaled to fit (maintains aspect ratio, centered)
|
||||
const scale = Math.min(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height)
|
||||
const x = (AVATAR_SIZE - img.width * scale) / 2
|
||||
const y = (AVATAR_SIZE - img.height * scale) / 2
|
||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
|
||||
|
||||
// 4. Convert to PNG blob with quality optimization
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(optimizedBlob) => {
|
||||
if (!optimizedBlob) {
|
||||
reject(new Error('Failed to create optimized blob'))
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (optimizedBlob.size > MAX_AVATAR_SIZE) {
|
||||
console.warn('[Avatar Sync] Optimized avatar exceeds max size:', optimizedBlob.size)
|
||||
// Try with lower quality
|
||||
canvas.toBlob(
|
||||
(lowerQualityBlob) => {
|
||||
if (lowerQualityBlob) {
|
||||
resolve(lowerQualityBlob)
|
||||
} else {
|
||||
reject(new Error('Failed to create lower quality blob'))
|
||||
}
|
||||
},
|
||||
'image/png',
|
||||
0.7
|
||||
)
|
||||
} else {
|
||||
resolve(optimizedBlob)
|
||||
}
|
||||
},
|
||||
'image/png',
|
||||
0.9
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Avatar Sync] Failed to download and optimize avatar:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload avatar blob to Supabase Storage
|
||||
* @param userId User ID
|
||||
* @param blob Optimized avatar blob
|
||||
*/
|
||||
export async function uploadAvatarToStorage(userId: string, blob: Blob): Promise<void> {
|
||||
try {
|
||||
const profilePath = `${userId}/avatar`
|
||||
|
||||
console.debug('[Avatar Sync] Uploading avatar to storage:', profilePath)
|
||||
|
||||
// Upload to Supabase Storage (overwrites existing file)
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.upload(profilePath, blob, {
|
||||
upsert: true, // Overwrite existing file
|
||||
contentType: 'image/png',
|
||||
cacheControl: '3600', // Cache for 1 hour
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
throw uploadError
|
||||
}
|
||||
|
||||
console.debug('[Avatar Sync] Avatar uploaded successfully')
|
||||
} catch (error) {
|
||||
console.error('[Avatar Sync] Failed to upload avatar to storage:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profile picture metadata for a user
|
||||
* @param userId User ID
|
||||
* @returns Metadata or null if not found
|
||||
*/
|
||||
export async function getProfilePictureMetadata(
|
||||
userId: string
|
||||
): Promise<ProfilePictureMetadata | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profile_picture_metadata')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
// If table doesn't exist, that's expected before migration runs
|
||||
if (error.code === 'PGRST116' || error.message?.includes('does not exist')) {
|
||||
console.debug('[Avatar Sync] Metadata table not found - migration may not be applied yet')
|
||||
return null
|
||||
}
|
||||
console.error('[Avatar Sync] Failed to fetch profile picture metadata:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('[Avatar Sync] Unexpected error fetching metadata:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or insert profile picture metadata
|
||||
* @param userId User ID
|
||||
* @param data Partial metadata to update
|
||||
*/
|
||||
export async function updateProfilePictureMetadata(
|
||||
userId: string,
|
||||
data: Partial<Omit<ProfilePictureMetadata, 'user_id' | 'created_at' | 'updated_at'>>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { error } = await supabase.from('profile_picture_metadata').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id',
|
||||
}
|
||||
)
|
||||
|
||||
if (error) {
|
||||
// If table doesn't exist, log but don't crash
|
||||
if (error.code === 'PGRST116' || error.message?.includes('does not exist')) {
|
||||
console.warn('[Avatar Sync] Cannot update metadata - table does not exist. Run migration first.')
|
||||
return // Don't throw, allow feature to work without metadata tracking
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
console.debug('[Avatar Sync] Metadata updated successfully')
|
||||
} catch (error) {
|
||||
console.error('[Avatar Sync] Failed to update metadata:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to sync OAuth avatar for a user
|
||||
* Downloads avatar from OAuth provider and uploads to Supabase Storage
|
||||
* Only syncs if:
|
||||
* - User is authenticated via OAuth provider that supports avatars
|
||||
* - User hasn't manually uploaded a picture (source !== 'upload')
|
||||
* - Last sync was more than SYNC_INTERVAL_DAYS ago (or never synced)
|
||||
*
|
||||
* @param user Supabase User object
|
||||
* @returns true if sync was performed, false if skipped
|
||||
*/
|
||||
export async function syncOAuthAvatar(user: User): Promise<boolean> {
|
||||
const cacheKey = user.id
|
||||
|
||||
try {
|
||||
// 0. Check client-side session cache first (prevent repeated attempts)
|
||||
const cached = sessionSyncCache.get(cacheKey)
|
||||
if (cached) {
|
||||
const minutesSinceLastAttempt = (Date.now() - cached.timestamp) / (1000 * 60)
|
||||
if (minutesSinceLastAttempt < 60) {
|
||||
console.debug('[Avatar Sync] Skipping sync - already attempted in this session:', {
|
||||
minutesAgo: minutesSinceLastAttempt.toFixed(1),
|
||||
lastSuccess: cached.success
|
||||
})
|
||||
return cached.success
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Check if user is OAuth authenticated
|
||||
const provider = user.app_metadata?.provider
|
||||
console.debug('[Avatar Sync] Checking user for sync:', {
|
||||
provider,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
hasUserMetadata: !!user.user_metadata,
|
||||
userMetadataKeys: user.user_metadata ? Object.keys(user.user_metadata) : []
|
||||
})
|
||||
|
||||
if (!provider || !['google', 'github', 'azure'].includes(provider)) {
|
||||
console.debug('[Avatar Sync] Skipping sync - not an OAuth provider with avatar support')
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Get metadata to check if sync is needed
|
||||
const metadata = await getProfilePictureMetadata(user.id)
|
||||
|
||||
// Skip if user has manually uploaded a picture
|
||||
if (metadata?.source === 'upload') {
|
||||
console.debug('[Avatar Sync] Skipping sync - user has manual upload')
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if synced recently (within SYNC_INTERVAL_DAYS)
|
||||
if (metadata?.last_synced_at) {
|
||||
const lastSync = new Date(metadata.last_synced_at)
|
||||
const daysSinceSync = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (daysSinceSync < SYNC_INTERVAL_DAYS) {
|
||||
console.debug('[Avatar Sync] Skipping sync - synced recently:', {
|
||||
daysSinceSync: daysSinceSync.toFixed(1),
|
||||
threshold: SYNC_INTERVAL_DAYS,
|
||||
})
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extract provider avatar URL
|
||||
const avatarUrl = getProviderAvatarUrl(user)
|
||||
console.debug('[Avatar Sync] Avatar URL extraction:', {
|
||||
provider,
|
||||
avatarUrl,
|
||||
hasAvatarUrl: !!avatarUrl
|
||||
})
|
||||
|
||||
if (!avatarUrl) {
|
||||
console.debug('[Avatar Sync] No avatar URL available from provider')
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
|
||||
return false
|
||||
}
|
||||
|
||||
console.debug('[Avatar Sync] Starting sync for provider:', provider, 'with URL:', avatarUrl)
|
||||
|
||||
// 4. Download and optimize avatar
|
||||
const optimizedBlob = await downloadAndOptimizeAvatar(avatarUrl)
|
||||
|
||||
// 5. Upload to Supabase Storage
|
||||
await uploadAvatarToStorage(user.id, optimizedBlob)
|
||||
|
||||
// 6. Update metadata
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: 'oauth',
|
||||
provider: provider as ProfilePictureMetadata['provider'],
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
console.debug('[Avatar Sync] Sync completed successfully')
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Avatar Sync] Failed to sync OAuth avatar:', error)
|
||||
// Cache the failure to prevent repeated attempts
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
|
||||
// Don't throw - gracefully degrade to existing picture or initials
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||
|
||||
export type StorageType = 'backend' | 'localStorage';
|
||||
|
||||
interface SignatureStorageCapabilities {
|
||||
supportsBackend: boolean;
|
||||
storageType: StorageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS-specific signature storage service that always uses localStorage.
|
||||
*
|
||||
* In SaaS mode, the proprietary backend signature API is not available
|
||||
* (requires Spring Security JWT, not Supabase JWT), so we skip detection
|
||||
* and force localStorage-only mode to avoid unnecessary 401/403 errors.
|
||||
*/
|
||||
class SignatureStorageService {
|
||||
private capabilities: SignatureStorageCapabilities | null = null;
|
||||
private blobUrls: Set<string> = new Set();
|
||||
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
|
||||
|
||||
/**
|
||||
* Detect capabilities - in SaaS mode, always returns localStorage
|
||||
*/
|
||||
async detectCapabilities(): Promise<SignatureStorageCapabilities> {
|
||||
if (this.capabilities) {
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
// SaaS mode always uses localStorage (no backend signature API available)
|
||||
console.log('[SignatureStorage] SaaS mode - using localStorage (backend not available)');
|
||||
this.capabilities = {
|
||||
supportsBackend: false,
|
||||
storageType: 'localStorage',
|
||||
};
|
||||
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current storage type
|
||||
*/
|
||||
async getStorageType(): Promise<StorageType> {
|
||||
const capabilities = await this.detectCapabilities();
|
||||
return capabilities.storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all signatures
|
||||
*/
|
||||
async loadSignatures(): Promise<SavedSignature[]> {
|
||||
// Clean up old blob URLs before loading new ones
|
||||
this.cleanup();
|
||||
|
||||
// Always use localStorage in SaaS mode
|
||||
return this._loadFromLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a signature
|
||||
*/
|
||||
async saveSignature(signature: SavedSignature): Promise<void> {
|
||||
// Force scope to localStorage for SaaS mode
|
||||
signature.scope = 'localStorage';
|
||||
this._saveToLocalStorage(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a signature
|
||||
*/
|
||||
async deleteSignature(id: string): Promise<void> {
|
||||
this._deleteFromLocalStorage(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update signature label
|
||||
*/
|
||||
async updateSignatureLabel(id: string, label: string): Promise<void> {
|
||||
this._updateLabelInLocalStorage(id, label);
|
||||
}
|
||||
|
||||
// LocalStorage methods
|
||||
private _loadFromLocalStorage(): SavedSignature[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const signatures = JSON.parse(raw);
|
||||
// Ensure all localStorage signatures have the correct scope
|
||||
return signatures.map((sig: SavedSignature) => ({
|
||||
...sig,
|
||||
scope: 'localStorage' as const,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private _saveToLocalStorage(signature: SavedSignature): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const index = signatures.findIndex(s => s.id === signature.id);
|
||||
|
||||
if (index >= 0) {
|
||||
signatures[index] = signature;
|
||||
} else {
|
||||
signatures.unshift(signature);
|
||||
}
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
|
||||
}
|
||||
|
||||
private _deleteFromLocalStorage(id: string): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const filtered = signatures.filter(s => s.id !== id);
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
private _updateLabelInLocalStorage(id: string, label: string): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const signature = signatures.find(s => s.id === id);
|
||||
if (signature) {
|
||||
signature.label = label;
|
||||
signature.updatedAt = Date.now();
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate signatures from localStorage to backend
|
||||
* In SaaS mode, this is a no-op since we don't support backend storage
|
||||
*/
|
||||
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
|
||||
console.log('[SignatureStorage] Migration not supported in SaaS mode');
|
||||
return { migrated: 0, failed: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up blob URLs to prevent memory leaks
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.blobUrls.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
this.blobUrls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const signatureStorageService = new SignatureStorageService();
|
||||
@@ -0,0 +1,272 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { supabase, isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
supabaseId?: string | null;
|
||||
roleName: string; // Translation key like "adminUserSettings.admin"
|
||||
rolesAsString?: string; // Actual role ID like "ROLE_ADMIN"
|
||||
enabled: boolean;
|
||||
isFirstLogin?: boolean;
|
||||
authenticationType?: string;
|
||||
team?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
// Enriched client-side fields
|
||||
isActive?: boolean;
|
||||
lastRequest?: number; // timestamp in milliseconds
|
||||
}
|
||||
|
||||
export interface AdminSettingsData {
|
||||
users: User[];
|
||||
userSessions: Record<string, boolean>;
|
||||
userLastRequest: Record<string, number>; // username -> timestamp in milliseconds
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
disabledUsers: number;
|
||||
currentUsername?: string;
|
||||
roleDetails?: Record<string, string>;
|
||||
teams?: any[];
|
||||
maxPaidUsers?: number;
|
||||
// License information
|
||||
maxAllowedUsers: number;
|
||||
availableSlots: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
password?: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
authType: 'password' | 'SSO';
|
||||
forceChange?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleRequest {
|
||||
username: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export interface InviteUsersRequest {
|
||||
emails: string; // Comma-separated email addresses
|
||||
role: string;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export interface InviteUsersResponse {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
message?: string;
|
||||
errors?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InviteLinkRequest {
|
||||
email?: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
expiryHours?: number;
|
||||
sendEmail?: boolean;
|
||||
}
|
||||
|
||||
export interface InviteLinkResponse {
|
||||
token: string;
|
||||
inviteUrl: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
expiryHours: number;
|
||||
emailSent?: boolean;
|
||||
emailError?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InviteToken {
|
||||
id: number;
|
||||
email: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Management Service
|
||||
* Provides functions to interact with user management backend APIs
|
||||
*/
|
||||
export const userManagementService = {
|
||||
/**
|
||||
* Get all users with session data (admin only)
|
||||
*/
|
||||
async getUsers(): Promise<AdminSettingsData> {
|
||||
const response = await apiClient.get<AdminSettingsData>('/api/v1/proprietary/ui-data/admin-settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get users without a team
|
||||
*/
|
||||
async getUsersWithoutTeam(): Promise<User[]> {
|
||||
const response = await apiClient.get<User[]>('/api/v1/users/without-team');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user (admin only)
|
||||
*/
|
||||
async createUser(data: CreateUserRequest): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('username', data.username);
|
||||
if (data.password) {
|
||||
formData.append('password', data.password);
|
||||
}
|
||||
formData.append('role', data.role);
|
||||
if (data.teamId) {
|
||||
formData.append('teamId', data.teamId.toString());
|
||||
}
|
||||
formData.append('authType', data.authType);
|
||||
if (data.forceChange !== undefined) {
|
||||
formData.append('forceChange', data.forceChange.toString());
|
||||
}
|
||||
await apiClient.post('/api/v1/user/admin/saveUser', formData, {
|
||||
suppressErrorToast: true, // Component will handle error display
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update user role and/or team (admin only)
|
||||
*/
|
||||
async updateUserRole(data: UpdateUserRoleRequest): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('username', data.username);
|
||||
formData.append('role', data.role);
|
||||
if (data.teamId) {
|
||||
formData.append('teamId', data.teamId.toString());
|
||||
}
|
||||
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable or disable a user (admin only)
|
||||
*/
|
||||
async toggleUserEnabled(username: string, enabled: boolean): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', enabled.toString());
|
||||
await apiClient.post(`/api/v1/user/admin/changeUserEnabled/${username}`, formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a user (admin only)
|
||||
*/
|
||||
async deleteUser(user: User, options?: { notifyUser?: boolean }): Promise<void> {
|
||||
if (isSupabaseConfigured && supabase) {
|
||||
if (!user.email) {
|
||||
throw new Error('Email missing for this user. Please contact support for manual removal.');
|
||||
}
|
||||
|
||||
const { error } = await supabase.functions.invoke('delete-user', {
|
||||
body: {
|
||||
target_email: user.email,
|
||||
notify_user: options?.notifyUser ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Supabase deletion failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Invite users via email (admin only)
|
||||
* Sends comma-separated email addresses, creates accounts with random passwords,
|
||||
* and sends invitation emails
|
||||
*/
|
||||
async inviteUsers(data: InviteUsersRequest): Promise<InviteUsersResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('emails', data.emails);
|
||||
formData.append('role', data.role);
|
||||
if (data.teamId) {
|
||||
formData.append('teamId', data.teamId.toString());
|
||||
}
|
||||
|
||||
const response = await apiClient.post<InviteUsersResponse>(
|
||||
'/api/v1/user/admin/inviteUsers',
|
||||
formData,
|
||||
{
|
||||
suppressErrorToast: true, // Component will handle error display
|
||||
} as any
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an invite link (admin only)
|
||||
*/
|
||||
async generateInviteLink(data: InviteLinkRequest): Promise<InviteLinkResponse> {
|
||||
const formData = new FormData();
|
||||
// Only append email if it's provided and not empty
|
||||
if (data.email && data.email.trim()) {
|
||||
formData.append('email', data.email);
|
||||
}
|
||||
formData.append('role', data.role);
|
||||
if (data.teamId) {
|
||||
formData.append('teamId', data.teamId.toString());
|
||||
}
|
||||
if (data.expiryHours) {
|
||||
formData.append('expiryHours', data.expiryHours.toString());
|
||||
}
|
||||
if (data.sendEmail !== undefined) {
|
||||
formData.append('sendEmail', data.sendEmail.toString());
|
||||
}
|
||||
|
||||
const response = await apiClient.post<InviteLinkResponse>(
|
||||
'/api/v1/invite/generate',
|
||||
formData,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
} as any
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of active invite links (admin only)
|
||||
*/
|
||||
async getInviteLinks(): Promise<InviteToken[]> {
|
||||
const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list');
|
||||
return response.data.invites;
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoke an invite link (admin only)
|
||||
*/
|
||||
async revokeInviteLink(inviteId: number): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clean up expired invite links (admin only)
|
||||
*/
|
||||
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
|
||||
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* User service for handling user-related API calls
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
/**
|
||||
* Synchronizes user upgrade from anonymous to authenticated status with the backend.
|
||||
* This should be called after Supabase has successfully upgraded the user.
|
||||
* Only the current user can upgrade their own account - the backend determines
|
||||
* the user from the security context and derives email from SupabaseUser.
|
||||
*
|
||||
* @param authMethod - The authentication method used (e.g., "email", "google", "github", "apple", "azure")
|
||||
* @returns Promise with the synchronization result
|
||||
*/
|
||||
export const synchronizeUserUpgrade = async (
|
||||
authMethod?: string
|
||||
): Promise<{
|
||||
message: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}> => {
|
||||
const formData = new URLSearchParams();
|
||||
if (authMethod) {
|
||||
formData.append('authMethod', authMethod);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/user-role/promptToAuthUser`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
credentials: 'include', // Include cookies for authentication
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Failed to synchronize user upgrade' }));
|
||||
throw new Error(errorData.error || 'Failed to synchronize user upgrade');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock localStorage for tests
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key]; }),
|
||||
clear: vi.fn(() => { store = {}; }),
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] || null),
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock Supabase for tests
|
||||
vi.mock('@app/auth/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
|
||||
refreshSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
|
||||
onAuthStateChange: vi.fn().mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } })
|
||||
}
|
||||
},
|
||||
debugAuthEvents: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock i18next for tests
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn(),
|
||||
},
|
||||
I18nextProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Mock i18next-http-backend
|
||||
vi.mock('i18next-http-backend', () => ({
|
||||
default: {
|
||||
type: 'backend',
|
||||
init: vi.fn(),
|
||||
read: vi.fn(),
|
||||
save: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock window.URL.createObjectURL and revokeObjectURL for tests
|
||||
global.URL.createObjectURL = vi.fn(() => 'mocked-url');
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
// Mock File and Blob API methods that aren't available in jsdom
|
||||
if (!globalThis.File.prototype.arrayBuffer) {
|
||||
globalThis.File.prototype.arrayBuffer = function() {
|
||||
// Return a simple ArrayBuffer with some mock data
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const view = new Uint8Array(buffer);
|
||||
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
return Promise.resolve(buffer);
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.Blob.prototype.arrayBuffer) {
|
||||
globalThis.Blob.prototype.arrayBuffer = function() {
|
||||
// Return a simple ArrayBuffer with some mock data
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const view = new Uint8Array(buffer);
|
||||
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
return Promise.resolve(buffer);
|
||||
};
|
||||
}
|
||||
|
||||
// Mock crypto.subtle for hashing in tests - force override even if exists
|
||||
const mockHashBuffer = new ArrayBuffer(32);
|
||||
const mockHashView = new Uint8Array(mockHashBuffer);
|
||||
// Fill with predictable mock hash data
|
||||
for (let i = 0; i < 32; i++) {
|
||||
mockHashView[i] = i;
|
||||
}
|
||||
|
||||
// Force override crypto.subtle to avoid Node.js native implementation
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: {
|
||||
subtle: {
|
||||
digest: vi.fn().mockImplementation(async (_algorithm: string, _data: any) => {
|
||||
// Always return the mock hash buffer regardless of input
|
||||
return mockHashBuffer.slice();
|
||||
}),
|
||||
},
|
||||
getRandomValues: vi.fn().mockImplementation((array: any) => {
|
||||
// Mock getRandomValues if needed
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return array;
|
||||
}),
|
||||
} as unknown as Crypto,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock Worker for tests (Web Workers not available in test environment)
|
||||
global.Worker = vi.fn().mockImplementation(() => ({
|
||||
postMessage: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver for Mantine components
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock IntersectionObserver for components that might use it
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock matchMedia for responsive components
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock DOMMatrix for PDF.js tests
|
||||
Object.defineProperty(global, 'DOMMatrix', {
|
||||
value: class MockDOMMatrix {
|
||||
a = 1; b = 0; c = 0; d = 1; e = 0; f = 0;
|
||||
m11 = 1; m12 = 0; m13 = 0; m14 = 0;
|
||||
m21 = 0; m22 = 1; m23 = 0; m24 = 0;
|
||||
m31 = 0; m32 = 0; m33 = 1; m34 = 0;
|
||||
m41 = 0; m42 = 0; m43 = 0; m44 = 1;
|
||||
is2D = true;
|
||||
isIdentity = true;
|
||||
|
||||
toString() { return 'matrix(1, 0, 0, 1, 0, 0)'; }
|
||||
scale() { return this; }
|
||||
translate() { return this; }
|
||||
rotate() { return this; }
|
||||
inverse() { return this; }
|
||||
multiply() { return this; }
|
||||
|
||||
static fromFloat32Array() { return new MockDOMMatrix(); }
|
||||
static fromFloat64Array() { return new MockDOMMatrix(); }
|
||||
static fromMatrix() { return new MockDOMMatrix(); }
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Set global test timeout to prevent hangs
|
||||
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user