feat(FN-094): add comment line for deployment verification
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
- Added a comment line to main.ts for deployment verification purposes
@@ -30,7 +30,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cardog-icons/react": "^1.1.1",
|
||||
"@grafana/faro-web-sdk": "^2.2.4",
|
||||
"@grafana/faro-web-tracing": "^2.2.4",
|
||||
"@remotion/player": "^4.0.422",
|
||||
|
||||
1
apps/web/src/assets/brand-logos/alpine.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><path fill="#fff" d="M0 0h192.756v192.756H0V0z"/><path fill="#060a0b" d="M62.707 87.96l2.49-2.49h-6.28l-7.794 7.795L39.7 104.688l-2.653 2.652H43.327l7.796-7.795L62.707 87.96zM96.648 85.47h-5.899v21.925l18.079.107v-5.143l-12.18-.054V85.47zM76.62 85.47h-6.28l-7.633 7.633-11.584 11.585-2.653 2.652H54.75l7.957-7.957L76.62 85.47zM28.278 88.123l2.652-2.653h-6.279l-2.057 2.111-19.76 19.759h6.28l13.48-13.533 5.684-5.684zM51.123 88.123l2.652-2.653h-6.28L39.7 93.265l-11.422 11.423-2.599 2.652h6.226l7.795-7.795 11.423-11.422zM39.7 88.123l2.653-2.653H36.073l-7.795 7.795-5.684 5.739-8.337 8.336h6.226l2.111-2.111 5.684-5.684L39.7 88.123zM62.707 104.85l-2.49 2.49h7.957l15.753-15.916v15.971h5.793l.054-21.925h-7.741l-19.326 19.38zM125.99 85.578H115.433v-.108h-5.848v21.925l5.848.055v-7.256H125.99c4.33 0 5.738-2.922 5.738-7.253.001-4.331-1.408-7.363-5.738-7.363zm.215 8.121c0 1.353-1.189 1.353-1.189 1.353h-9.475V90.18s2.762 0 5.305.054h4.006c1.354 0 1.354 1.245 1.354 1.245l-.001 2.22zM188.785 105.066c0-.541-.326-.703-.922-.703h-.811v2.219h.486v-.92h.217l.107.217.379.703h.598l-.598-.975c.327 0 .544-.162.544-.541zm-.812.217h-.433v-.541h.324c.164 0 .434.053.434.27s-.163.271-.325.271z"/><path d="M187.863 103.443c-1.082 0-2.057.756-2.057 2.057 0 1.244.975 2.002 2.057 2.002 1.084 0 2.059-.758 2.059-2.002 0-1.301-.975-2.057-2.059-2.057zm0 3.625v-.053c-.811.053-1.461-.596-1.461-1.516 0-.975.65-1.57 1.461-1.57.812 0 1.463.596 1.463 1.57 0 .921-.65 1.569-1.463 1.569zM163.775 90.18v17.322h20.842v-4.926h-15.158v-4.061h13.316v-4.817h-13.316V90.18h15.158v-4.927H168.43s-4.655 1.029-4.655 4.927zM157.008 98.895c-1.623-2.002-7.74-9.364-9.689-11.8 0 0-1.191-1.678-3.465-1.678h-4.33v21.979l5.521.055V93.482l11.584 13.913 5.848.055V85.47h-5.469v13.425zM132.594 107.395l5.9.054V85.47h-5.9v21.925z" fill="#060a0b"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
apps/web/src/assets/brand-logos/citroen.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="2369" viewBox="70.8 77.5 453.6 440.3" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m131.3 517h-36.7c-5.9 0-11.9-2.4-16.4-6.5-4.8-4.3-7.4-10-7.4-16.1s2.6-11.7 7.4-16.1c4.5-4.1 10.5-6.5 16.4-6.5h36.7v8.9h-36.2c-7.9 0-14.1 6.4-14.1 13.6 0 7.3 6.1 13.6 14.1 13.6h36.2zm99-45.1h-61.1v8.9h25.7v36.2h9.7v-36.2h25.7zm-82.6 0h9.7v45h-9.7zm211.6 45.9h-19.7c-12.6 0-23.8-8.9-23.8-22.9 0-14.1 11.5-23.8 24.6-23.8h18c13.1 0 24.6 8.9 24.6 23.8.1 14-11 22.9-23.7 22.9zm-18.8-37.9c-8.2 0-14.5 6.7-14.5 14.9 0 7.7 5.9 14.1 13.6 14.1h19.7c7.7 0 13.6-6.3 13.6-14.1 0-8.2-6.3-14.9-14.5-14.9zm77.5-13.2h-14c-1.9 0-3.4-1.5-3.4-3.4s1.5-3.4 3.4-3.4h14c1.9 0 3.4 1.5 3.4 3.4-.1 1.9-1.6 3.4-3.4 3.4zm25.4 0h-14c-1.9 0-3.4-1.5-3.4-3.4s1.5-3.4 3.4-3.4h14c1.9 0 3.4 1.5 3.4 3.4s-1.6 3.4-3.4 3.4zm7.7 14.1v-8.9h-54.8v45.1h54.8v-8.9h-45.1v-9.2h39.4v-8.9h-39.4v-9.2zm-171.1 18.3h8.7c7.5 0 14.8-3.4 14.8-13.2 0-8.8-7.3-14-14.8-14h-46.8v45h9.7v-36.1h36.6c3 0 5.1 3 5.1 5.5s-2.2 5.5-5.1 5.5h-27.9v3.1l30.9 21.9h14.3zm224.6-27.2h-39.7v45h9.7v-36.1h28.4c6.7 0 12.1 5.4 12.1 12.1v24.1h9.3v-25.2c0-11-8.9-19.9-19.8-19.9zm-111.6-344.8c-25.4-32-59.2-49.6-95.4-49.6s-70 17.6-95.4 49.6c-24.9 31.5-38.7 73.3-38.7 117.6s13.7 86.1 38.7 117.6c25.4 32 59.2 49.6 95.4 49.6s70-17.6 95.4-49.6c24.9-31.5 38.7-73.3 38.7-117.6s-13.8-86.1-38.7-117.6zm-95.4-34.6c64.4 0 117 65.8 118.9 147.6l-118.9-88.9-118.9 88.9c1.9-81.8 54.5-147.6 118.9-147.6zm116.4 184.1c-1.5 9.2-3.7 18.1-6.5 26.7l-109.9-82.1-109.9 82.1c-2.8-8.5-5-17.5-6.5-26.7l116.4-87zm-116.4 120.4c-40.2 0-75.8-25.6-97.3-64.8l97.3-72.8 97.3 72.8c-21.5 39.2-57.1 64.8-97.3 64.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
apps/web/src/assets/brand-logos/cupra.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="206pt" viewBox=".26 .95 312.19 206.3" width="313pt" xmlns="http://www.w3.org/2000/svg"><path d="m64.87.95c29.9 10.4 59.64 21.28 89.61 31.47 1.06.41 2.11.41 3.17 0 29.92-10.24 59.64-21.03 89.49-31.47-10.59 16.88-21.28 33.7-32.1 50.43-5-2.15-10.15-3.94-15.19-6 2.92-5.14 6.06-10.16 9.02-15.28-9.64 4.85-19.45 9.36-29.02 14.35 6.69 2.55 13.39 5.05 20.03 7.74 3.7 1.51 7.67 4.42 7.92 8.76.59 4.82-2.7 8.76-5.03 12.62-14.42 22.56-28.76 45.18-43.25 67.7.08-5.94-.6-11.94.1-17.84 5.7-18.09 11.33-36.2 16.52-54.44 1.02-2.71-2.01-4.16-3.96-5.11-5.5-2.43-10.65-5.65-16.28-7.73-5.71 2.47-11.19 5.46-16.79 8.17-1.73.8-4.17 2.38-3.18 4.64 5.15 18.25 10.84 36.35 16.5 54.45.63 5.91.03 11.91.06 17.86-14.26-22.32-28.49-44.67-42.73-67-2.48-4.11-6.08-8.31-5.46-13.44.33-4.31 4.28-7.17 7.98-8.67 6.58-2.68 13.26-5.13 19.89-7.72-9.55-4.97-19.32-9.52-28.95-14.33 2.95 5.12 6.09 10.13 9 15.28-5.07 2.02-10.2 3.89-15.25 5.96-10.78-16.75-21.45-33.58-32.1-50.4zm63.36 204.24c.1-9.86-.07-19.71.09-29.57 16.22.11 32.45-.03 48.67.06 3.17-.11 6.87 1.48 7.5 4.9.39 3.35.3 6.75.06 10.1-.2 3.7-4.08 5.94-7.5 5.7-13.23.03-26.46-.02-39.7.01.01 2.96.02 5.91.06 8.87-3.06.08-6.12.04-9.18-.07m9.09-22.88c.04 2.3.06 4.6.11 6.91 12.56-.12 25.12.11 37.68-.11 2.28-1.32.73-4.49 1.16-6.61-12.97-.44-25.97-.09-38.95-.19z"/><path d="m1.16 180.17c1.3-2.94 4.67-4.58 7.8-4.51 15.9-.04 31.8.02 47.71-.02.03 2.72.04 5.45.06 8.17-15.58.11-31.18-.14-46.75.12-.29.23-.86.7-1.15.94-.18 3.91.04 7.84-.12 11.76 1.41.35 2.84.71 4.31.65 14.56-.04 29.12.02 43.68-.04.01 2.63.02 5.26.02 7.89-15.25.44-30.52.06-45.77.22-3.74.15-8.38-.57-9.86-4.57-.81-3.54-.25-7.21-.4-10.8.05-3.26-.43-6.62.47-9.81zm63.15 17.84c-.03-7.46-.05-14.92.04-22.37 3.26.01 6.53-.01 9.8.05.11 7.15-.12 14.31.14 21.46 12.1.24 24.21.1 36.31.08.5-7.18.12-14.38.23-21.56 3.3-.02 6.6-.01 9.9-.05.07 7.45.09 14.91.02 22.37.04 4-3.75 7.34-7.66 7.27-11.36.34-22.73-.11-34.1.09-3.28-.01-6.62.19-9.83-.62-2.72-1.02-5-3.71-4.85-6.72zm127.93-22.37c15.93.04 31.86 0 47.79.01 3.44-.07 7.49 1.72 8.35 5.35.24 3.33.23 6.69-.01 10.02-.5 2.8-2.97 4.27-5.23 5.57 2.16 2.74 4.08 5.65 6.02 8.55-3.94-.25-9.92 2.11-12.12-2.35-1.32-2.1-2.57-4.24-3.85-6.36-10.53-.09-21.06-.04-31.59-.03.02 2.9.03 5.8.06 8.71-3.15.28-6.31.29-9.45.08.05-9.85-.04-19.7.03-29.55m9.46 6.34c-.08 2.4-.08 4.81-.07 7.21 12.44-.09 24.89.17 37.32-.12.31-.22.93-.67 1.25-.9-.18-2.02 1.03-5.07-1.39-6.07-12.36-.26-24.74-.01-37.11-.12zm54.48 1.01c-.13-4.51 4.58-7.65 8.76-7.34 15.78-.01 31.56.03 47.34-.02.05 9.85.17 19.71-.12 29.56-2.74.12-5.51.38-8.24.04-1.24-2.59-.19-5.97-.49-8.84-12.68-.01-25.36 0-38.04-.01-.02 2.96 0 5.93.02 8.9-3.07.08-6.13.04-9.19-.09.09-7.4-.19-14.8-.04-22.2m9.25-.59c-.04 2.27-.03 4.54.01 6.82 12.64-.07 25.29-.02 37.94-.03.02-2.29.03-4.57.06-6.86-12.67 0-25.34-.13-38.01.07z" fill="#010101"/></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
1
apps/web/src/assets/brand-logos/dacia.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1365" viewBox="0 0 1875.9290000000005 1024.029" width="2500" xmlns="http://www.w3.org/2000/svg"><g fill="#241f21"><path d="M105.362 0v157.405h582.54v158.727h-582.54v157.405h643.203c14.753 0 24.479-4.093 34.535-14.204l169.345-164.726 169.351 164.726c10.056 10.11 19.782 14.204 34.535 14.204h643.203V316.132H1216.99V157.405h582.544V0H1156.33c-14.753 0-24.479 4.093-34.535 14.203L952.446 178.93 783.1 14.203C773.044 4.093 763.318 0 748.565 0zM0 827.021v65.048l239.407 1.534c5.715 0 8.88 1.311 12.514 4.946l25.46 22.002c1.863 1.862 2.716 3.052 2.716 4.972 0 1.919-.853 3.114-2.716 4.976l-25.46 22.002c-3.635 3.635-6.799 4.946-12.514 4.946L0 958.981v65.048h269.382c9.62 0 14.363-1.573 20.534-7.743l76.975-79.567c2.899-2.899 4.796-7.426 4.796-11.196s-1.897-8.293-4.796-11.192l-76.975-79.572c-6.171-6.17-10.914-7.738-20.534-7.738z" fill-rule="evenodd"/><path d="M607.747 827.021c-4.237 0-6.638.85-8.3 2.511l-188.984 189.76c-1.984 1.994-1.089 4.737 1.995 4.737h91.89l97.46-101.098c2.022-2.022 3.54-2.866 5.939-2.866 2.397 0 3.916.844 5.939 2.866l97.458 101.098h91.891c3.084 0 3.98-2.743 1.995-4.736l-188.984-189.76c-1.662-1.663-4.063-2.512-8.3-2.512z"/><path d="M1217.07 827.021v65.048l-239.407 1.534c-5.715 0-8.879 1.311-12.514 4.946l-25.46 22.002c-1.862 1.862-2.716 3.052-2.716 4.972 0 1.919.854 3.114 2.716 4.976l25.46 22.002c3.635 3.635 6.8 4.946 12.514 4.946l239.407 1.534v65.048H947.688c-9.62 0-14.363-1.573-20.533-7.743l-76.976-79.567c-2.898-2.899-4.796-7.426-4.796-11.196s1.898-8.293 4.796-11.192l76.976-79.572c6.17-6.17 10.913-7.738 20.533-7.738z" fill-rule="evenodd"/><path d="M1329.535 827.021h75.303v197.008h-75.303zM1676.66 827.021c-4.236 0-6.637.85-8.299 2.511l-188.983 189.76c-1.985 1.994-1.09 4.737 1.994 4.737h91.891l97.459-101.098c2.022-2.022 3.541-2.866 5.939-2.866s3.917.844 5.939 2.866l97.458 101.098h91.891c3.084 0 3.98-2.743 1.995-4.736l-188.984-189.76c-1.662-1.663-4.063-2.512-8.3-2.512z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
apps/web/src/assets/brand-logos/man.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 -13 720 719.998"><path fill="none" d="M0-13h720v719.998H0z"/><path d="M468.828 404.51h-37.054v72.36h37.369v-32.647l44.11 32.647h37.348v-72.36h-37.376v32.938l-44.397-32.938zM358.146 213.959c-131.229 0-237.604 106.379-237.604 237.602 0 8.554.453 16.991 1.332 25.312h24.883a214.88 214.88 0 0 1-1.488-25.312c0-117.566 95.31-212.875 212.878-212.875 117.569 0 212.88 95.309 212.88 212.875 0 8.567-.506 17.012-1.487 25.312h24.881a240.465 240.465 0 0 0 1.332-25.312c-.005-131.223-106.382-237.602-237.607-237.602zm19.941 190.551h-39.733l-47.091 72.36h38.569l5.234-8.096h46.311l5.238 8.096h38.567l-47.095-72.36zm-9.029 45.031h-21.672l10.836-16.742 10.836 16.742zM200.069 404.51h-35.055v72.36h37.377v-28.614l22.327 22.324 22.322-22.324v28.614h37.376v-72.36H249.36l-24.643 24.646-24.648-24.646z" fill="#222"/></svg>
|
||||
|
After Width: | Height: | Size: 881 B |
1
apps/web/src/assets/brand-logos/opel.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
apps/web/src/assets/brand-logos/peugeot.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
apps/web/src/assets/brand-logos/renault.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="2500" viewBox=".306 .394 379.652 500" width="1900" xmlns="http://www.w3.org/2000/svg"><path d="m304.375 250.394-133.72 250h-36.628l-133.721-250 133.14-250h37.79l-133.14 250 113.954 214.536 113.953-214.536-66.279-124.997 19.186-35.47zm-56.975-250h-38.372l-132.558 250 85.465 160.467 18.604-35.47-66.279-124.997 113.953-215.117 113.953 215.117-133.14 250h38.374l132.558-250z" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 414 B |
1
apps/web/src/assets/brand-logos/seat.svg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
1
apps/web/src/assets/brand-logos/skoda.svg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
1
apps/web/src/assets/brand-logos/suzuki.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2075" height="2500" viewBox="0 0 153.588 185.084"><path d="M55.487 39.154c1.426-4.656 5.037-2.28 5.037-2.28S69.882 44 95.549 60.333 153.215 54 153.215 54l-76-54c-33.334 35.333-76 48-76 48l.121.047 97.147 68.121c-1.426 4.656-5.037 2.281-5.037 2.281s-9.358-7.127-35.025-23.461C32.754 78.655.755 101.322.755 101.322l76 54c33.334-35.334 76-48 76-48l-.123-.048-97.145-68.12z" fill="#ff0016"/><g fill="#015cd0"><path d="M0 176.761l11.692.062c.201 1.1-.115 1.096.5 1.67.963.896 2.457 1.1 3.711.879 1.027-.18 2.619-.494 2.695-1.769.083-1.384-.875-1.866-2.077-2.024-1.662-.217-3.339-.274-4.996-.539-4.269-.701-6.539-1.015-8.357-2.174-1.834-1.16-1.992-3.686-1.992-5.484 0-1.182.469-2.297 1.407-3.35.938-1.049 2.349-1.875 4.232-2.478 1.884-.601 4.465-.899 7.746-.899 4.024 0 7.095.545 9.206 1.635 2.114 1.09 3.371 3.521 3.772 5.896h-9.941c.074-.141-.195-.777-.238-.936-.381-1.379-1.712-1.781-2.987-2.063-1.045-.231-2.239-.251-3.181.325-.573.352-1.018.723-1.018 1.227 0 .365.236.694.71.987.461.302 1.55.585 3.269.845 4.255.672 7.302 1.348 9.144 2.031 1.84.688 3.179 1.535 4.018 2.548.837 1.013 1.257 2.147 1.257 3.401 0 1.474-.559 2.832-1.677 4.074-1.116 1.242-2.677 2.188-4.682 2.83-2.006.643-4.533.963-7.583.963-5.355 0-9.066-.752-11.127-2.256C1.439 180.66.272 179.083 0 176.761zM47.439 161.087h8.641v16.246c0 3.334-2.071 5.695-6.214 7.09-1.917.636-4.215.66-6.817.66-2.333 0-4.426.043-6.362-.461-4.56-1.822-6.839-4.252-6.839-7.289v-16.246h8.702V175c0 2.533.336 4.418 4.5 4.418 4.248 0 4.333-1.967 4.333-4.5l.056-13.831zM103.938 161.087h8.642v16.246c0 3.334-2.071 5.695-6.214 7.09-1.916.636-4.215.66-6.817.66-2.333 0-4.427.043-6.362-.461-4.561-1.822-6.84-4.252-6.84-7.289v-16.246h8.702V175c0 2.533.337 4.418 4.5 4.418 4.248 0 4.333-1.967 4.333-4.5l.056-13.831zM57.858 161.255h26.766v4.994L71.549 178h13.076v6.541H57.858v-5.623l12.774-11.584h-12.75l-.024-6.079zM114.72 184.5v-23.285h8.579l-.002 8.619 9.253-8.619h10.737L130.966 172.5l12.834 12h-11.584l-8.834-8.332.084 8.332h-8.746zM153.588 161.212V184.5h-8.562v-23.285h8.562v-.003z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -15,9 +15,7 @@ interface DailyChartProps {
|
||||
}
|
||||
|
||||
export function DailyChart({ dailyStats, isLoading }: DailyChartProps) {
|
||||
const maxCount = dailyStats
|
||||
? Math.max(...dailyStats.map((d) => d.count), 1)
|
||||
: 1;
|
||||
const maxCount = dailyStats ? Math.max(...dailyStats.map((d) => d.count), 1) : 1;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -31,21 +29,16 @@ export function DailyChart({ dailyStats, isLoading }: DailyChartProps) {
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : !dailyStats || dailyStats.length === 0 ? (
|
||||
<p className="py-12 text-center text-muted-foreground">
|
||||
Henuz veri bulunmuyor
|
||||
</p>
|
||||
<p className="py-12 text-center text-muted-foreground">Henuz veri bulunmuyor</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1 overflow-x-auto pb-2" style={{ height: 256 }}>
|
||||
{dailyStats.map((day) => {
|
||||
const heightPercent = (day.count / maxCount) * 100;
|
||||
const successPercent =
|
||||
day.count > 0
|
||||
? (day.successCount / day.count) * 100
|
||||
: 0;
|
||||
const dateLabel = new Date(day.date).toLocaleDateString(
|
||||
"tr-TR",
|
||||
{ day: "2-digit", month: "2-digit" },
|
||||
);
|
||||
const successPercent = day.count > 0 ? (day.successCount / day.count) * 100 : 0;
|
||||
const dateLabel = new Date(day.date).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
@@ -75,9 +68,7 @@ export function DailyChart({ dailyStats, isLoading }: DailyChartProps) {
|
||||
/>
|
||||
</div>
|
||||
{/* Date label */}
|
||||
<span className="mt-1 text-[9px] text-muted-foreground">
|
||||
{dateLabel}
|
||||
</span>
|
||||
<span className="mt-1 text-[9px] text-muted-foreground">{dateLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface VariantItem {
|
||||
code: string;
|
||||
@@ -159,11 +159,7 @@ export function FordVariantSelector({ vehicleId, onSelect }: FordVariantSelector
|
||||
|
||||
{/* Proceed button — only enabled when all required dimensions are selected */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleProceed}
|
||||
disabled={!canProceed}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Button onClick={handleProceed} disabled={!canProceed} className="w-full sm:w-auto">
|
||||
{t("catalog.fordVariant.proceed")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
355
apps/web/src/components/catalog/model-list-columns.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
import { cn } from "@sase/ui";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Car, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
interface CatalogVehicle {
|
||||
id: string;
|
||||
brandName: string;
|
||||
model: string;
|
||||
year: string | null;
|
||||
engine: string | null;
|
||||
bodyType: string | null;
|
||||
transmission: string | null;
|
||||
architecture: string | null;
|
||||
catalogPath: string | null;
|
||||
}
|
||||
|
||||
type ColumnItem =
|
||||
| { kind: "model"; id: string; label: string; sublabel?: string; vehicle: CatalogVehicle }
|
||||
| { kind: "restriction"; code: string; name: string; path: string; isFinal: boolean }
|
||||
| { kind: "category"; id: string; name: string; isLeaf: boolean; children?: any[] };
|
||||
|
||||
interface Column {
|
||||
type: "models" | "restrictions" | "categories";
|
||||
items: ColumnItem[];
|
||||
selectedId?: string;
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export function ModelListColumns({
|
||||
models,
|
||||
brandName,
|
||||
}: {
|
||||
models: CatalogVehicle[];
|
||||
brandName: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [loadingCol, setLoadingCol] = useState<number | null>(null);
|
||||
|
||||
// Track selected vehicle for category navigation and back button
|
||||
const selectedVehicleRef = useRef<string | null>(null);
|
||||
const selectedModelLabelRef = useRef<string | null>(null);
|
||||
const [inCategoryMode, setInCategoryMode] = useState(false);
|
||||
|
||||
const modelItems: ColumnItem[] = models.map((m) => ({
|
||||
kind: "model" as const,
|
||||
id: m.id,
|
||||
label: m.model,
|
||||
sublabel: [m.year, m.engine].filter(Boolean).join(" · ") || undefined,
|
||||
vehicle: m,
|
||||
}));
|
||||
|
||||
const [columns, setColumns] = useState<Column[]>([{ type: "models", items: modelItems }]);
|
||||
|
||||
// Reset when models change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: modelItems is derived from models; gating on models prevents an extra effect when only modelItems identity changes
|
||||
useEffect(() => {
|
||||
setColumns([{ type: "models", items: modelItems }]);
|
||||
selectedVehicleRef.current = null;
|
||||
selectedModelLabelRef.current = null;
|
||||
setInCategoryMode(false);
|
||||
}, [models]);
|
||||
|
||||
const handleBackToModels = useCallback(() => {
|
||||
setColumns([{ type: "models", items: modelItems }]);
|
||||
setInCategoryMode(false);
|
||||
}, [modelItems]);
|
||||
|
||||
// Auto-scroll right when new column added
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: depending on columns.length is intentional — we only scroll when a column is added/removed, not on inner-item updates
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
|
||||
}
|
||||
}, [columns.length]);
|
||||
|
||||
const truncateColumns = useCallback((fromIndex: number) => {
|
||||
setColumns((prev) => prev.slice(0, fromIndex + 1));
|
||||
}, []);
|
||||
|
||||
const setSelectedInColumn = useCallback((colIdx: number, itemId: string) => {
|
||||
setColumns((prev) =>
|
||||
prev.map((col, i) => (i === colIdx ? { ...col, selectedId: itemId } : col)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/* ── Handlers ── */
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
async (item: ColumnItem & { kind: "model" }, colIdx: number) => {
|
||||
const vehicle = item.vehicle;
|
||||
truncateColumns(colIdx);
|
||||
setSelectedInColumn(colIdx, item.id);
|
||||
selectedVehicleRef.current = vehicle.id;
|
||||
selectedModelLabelRef.current = item.label;
|
||||
|
||||
const arch = vehicle.architecture;
|
||||
const isLegacyVariant = ["LEGACY_PSA", "LEGACY_FORD", "LEGACY_VOLVO"].includes(arch ?? "");
|
||||
|
||||
if (isLegacyVariant) {
|
||||
// Navigate away — legacy brands need full-page variant selectors
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId",
|
||||
params: { brandName, modelId: vehicle.id },
|
||||
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const needsP5Restrictions =
|
||||
arch === "P5_MODERN" &&
|
||||
!!vehicle.catalogPath &&
|
||||
!vehicle.catalogPath.includes("/mainGroup");
|
||||
|
||||
setLoadingCol(colIdx);
|
||||
try {
|
||||
if (needsP5Restrictions) {
|
||||
// Fetch first restriction level
|
||||
const data = await api.get<{ options: any[]; isFinal: boolean }>(
|
||||
`/catalog/vehicles/${vehicle.id}/p5-restrictions`,
|
||||
);
|
||||
const items: ColumnItem[] = (data.options ?? []).map((o: any) => ({
|
||||
kind: "restriction" as const,
|
||||
code: o.code,
|
||||
name: o.name,
|
||||
path: o.path,
|
||||
isFinal: data.isFinal,
|
||||
}));
|
||||
setColumns((prev) => [...prev.slice(0, colIdx + 1), { type: "restrictions", items }]);
|
||||
} else {
|
||||
// Fetch categories directly
|
||||
await fetchCategories(vehicle.id, colIdx, undefined);
|
||||
}
|
||||
} catch {
|
||||
// On error, do nothing
|
||||
} finally {
|
||||
setLoadingCol(null);
|
||||
}
|
||||
},
|
||||
[brandName, navigate, truncateColumns, setSelectedInColumn],
|
||||
);
|
||||
|
||||
const handleRestrictionSelect = useCallback(
|
||||
async (item: ColumnItem & { kind: "restriction" }, colIdx: number) => {
|
||||
truncateColumns(colIdx);
|
||||
setSelectedInColumn(colIdx, item.code);
|
||||
|
||||
const vehicleId = selectedVehicleRef.current;
|
||||
if (!vehicleId) return;
|
||||
|
||||
setLoadingCol(colIdx);
|
||||
try {
|
||||
if (item.isFinal) {
|
||||
// Restrictions complete — fetch categories with mgp
|
||||
await fetchCategories(vehicleId, colIdx, item.path);
|
||||
} else {
|
||||
// More restriction levels
|
||||
const data = await api.get<{ options: any[]; isFinal: boolean }>(
|
||||
`/catalog/vehicles/${vehicleId}/p5-restrictions?path=${encodeURIComponent(item.path)}`,
|
||||
);
|
||||
const items: ColumnItem[] = (data.options ?? []).map((o: any) => ({
|
||||
kind: "restriction" as const,
|
||||
code: o.code,
|
||||
name: o.name,
|
||||
path: o.path,
|
||||
isFinal: data.isFinal,
|
||||
}));
|
||||
setColumns((prev) => [...prev.slice(0, colIdx + 1), { type: "restrictions", items }]);
|
||||
}
|
||||
} catch {
|
||||
// On error, do nothing
|
||||
} finally {
|
||||
setLoadingCol(null);
|
||||
}
|
||||
},
|
||||
[truncateColumns, setSelectedInColumn],
|
||||
);
|
||||
|
||||
const fetchCategories = useCallback(
|
||||
async (vehicleId: string, _afterColIdx: number, mgp: string | undefined) => {
|
||||
const mgpParam = mgp ? `?mgp=${encodeURIComponent(mgp)}` : "";
|
||||
const categories = await api.get<any[]>(
|
||||
`/catalog/vehicles/${vehicleId}/categories${mgpParam}`,
|
||||
);
|
||||
const items: ColumnItem[] = (categories ?? []).map((c: any) => ({
|
||||
kind: "category" as const,
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isLeaf: c.children !== undefined && c.children.length === 0,
|
||||
children: c.children,
|
||||
}));
|
||||
// Reset columns — categories start fresh from leftmost column
|
||||
setColumns([{ type: "categories", items }]);
|
||||
setInCategoryMode(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCategorySelect = useCallback(
|
||||
async (item: ColumnItem & { kind: "category" }, colIdx: number) => {
|
||||
truncateColumns(colIdx);
|
||||
setSelectedInColumn(colIdx, item.id);
|
||||
|
||||
const vehicleId = selectedVehicleRef.current;
|
||||
if (!vehicleId) return;
|
||||
|
||||
if (item.isLeaf) {
|
||||
// Navigate to schema page
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
||||
params: { brandName, modelId: vehicleId, categoryId: item.id },
|
||||
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If children already known from initial data
|
||||
if (item.children && item.children.length > 0) {
|
||||
const items: ColumnItem[] = item.children.map((c: any) => ({
|
||||
kind: "category" as const,
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isLeaf: c.children !== undefined && c.children.length === 0,
|
||||
children: c.children,
|
||||
}));
|
||||
setColumns((prev) => [...prev.slice(0, colIdx + 1), { type: "categories", items }]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy fetch children
|
||||
setLoadingCol(colIdx);
|
||||
try {
|
||||
const children = await api.get<any[]>(`/categories/${item.id}/children`);
|
||||
if (!children || children.length === 0) {
|
||||
// Actually a leaf — navigate
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
||||
params: { brandName, modelId: vehicleId, categoryId: item.id },
|
||||
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const items: ColumnItem[] = children.map((c: any) => ({
|
||||
kind: "category" as const,
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isLeaf: c.children !== undefined && c.children.length === 0,
|
||||
children: c.children,
|
||||
}));
|
||||
setColumns((prev) => [...prev.slice(0, colIdx + 1), { type: "categories", items }]);
|
||||
} catch {
|
||||
// On error, do nothing
|
||||
} finally {
|
||||
setLoadingCol(null);
|
||||
}
|
||||
},
|
||||
[brandName, navigate, truncateColumns, setSelectedInColumn],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: ColumnItem, colIdx: number) => {
|
||||
if (item.kind === "model") handleModelSelect(item, colIdx);
|
||||
else if (item.kind === "restriction") handleRestrictionSelect(item, colIdx);
|
||||
else if (item.kind === "category") handleCategorySelect(item, colIdx);
|
||||
},
|
||||
[handleModelSelect, handleRestrictionSelect, handleCategorySelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{inCategoryMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToModels}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
<span>{selectedModelLabelRef.current ?? "Modellere Dön"}</span>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex border rounded-lg overflow-x-auto"
|
||||
style={{ minHeight: 320 }}
|
||||
>
|
||||
{columns.map((col, colIdx) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: columns are append/pop only — index is the stable identity here
|
||||
key={colIdx}
|
||||
className={cn(
|
||||
"w-[220px] shrink-0 overflow-y-auto",
|
||||
colIdx < columns.length - 1 && "border-r",
|
||||
)}
|
||||
style={{ maxHeight: 480 }}
|
||||
>
|
||||
{col.items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-xs text-muted-foreground">
|
||||
Sonuç yok
|
||||
</div>
|
||||
) : (
|
||||
col.items.map((item) => {
|
||||
const itemId = item.kind === "restriction" ? item.code : item.id;
|
||||
const isSelected = col.selectedId === itemId;
|
||||
const isLoading = loadingCol === colIdx && isSelected;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={itemId}
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item, colIdx)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-accent text-accent-foreground font-medium",
|
||||
)}
|
||||
>
|
||||
<ItemIcon item={item} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate">{item.kind === "model" ? item.label : item.name}</p>
|
||||
{item.kind === "model" && item.sublabel && (
|
||||
<p className="text-xs text-muted-foreground truncate">{item.sublabel}</p>
|
||||
)}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
) : item.kind === "category" && item.isLeaf ? null : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemIcon({ item }: { item: ColumnItem }) {
|
||||
if (item.kind === "model") {
|
||||
return <Car className="size-4 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
if (item.kind === "category") {
|
||||
const Icon = getCategoryIcon(item.name);
|
||||
return <Icon className="size-4 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
46
apps/web/src/components/catalog/model-list-tree.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Car, ChevronRight } from "lucide-react";
|
||||
|
||||
interface CatalogVehicle {
|
||||
id: string;
|
||||
brandName: string;
|
||||
model: string;
|
||||
year: string | null;
|
||||
engine: string | null;
|
||||
bodyType: string | null;
|
||||
transmission: string | null;
|
||||
}
|
||||
|
||||
export function ModelListTree({
|
||||
models,
|
||||
brandName,
|
||||
}: {
|
||||
models: CatalogVehicle[];
|
||||
brandName: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{models.map((model) => (
|
||||
<Link
|
||||
key={model.id}
|
||||
to="/dashboard/catalog/$brandName/$modelId"
|
||||
params={{ brandName, modelId: model.id }}
|
||||
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
|
||||
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<Car className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{model.model}</p>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-0 text-xs text-muted-foreground">
|
||||
{model.year && <span>{model.year}</span>}
|
||||
{model.engine && <span>{model.engine}</span>}
|
||||
{model.bodyType && <span>{model.bodyType}</span>}
|
||||
{model.transmission && <span>{model.transmission}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
apps/web/src/components/catalog/p5-restriction-selector.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface RestrictionOption {
|
||||
code: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface P5RestrictionsResponse {
|
||||
options: RestrictionOption[];
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
interface P5RestrictionSelectorProps {
|
||||
vehicleId: string;
|
||||
onComplete: (mainGroupsPath: string) => void;
|
||||
}
|
||||
|
||||
export function P5RestrictionSelector({ vehicleId, onComplete }: P5RestrictionSelectorProps) {
|
||||
const [steps, setSteps] = useState<
|
||||
Array<{ label: string; selectedCode: string; selectedPath: string }>
|
||||
>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data, isLoading } = useQuery<P5RestrictionsResponse>({
|
||||
queryKey: ["p5-restrictions", vehicleId, currentPath ?? "initial"],
|
||||
queryFn: () => {
|
||||
const pathParam = currentPath ? `?path=${encodeURIComponent(currentPath)}` : "";
|
||||
return api.get<P5RestrictionsResponse>(
|
||||
`/catalog/vehicles/${vehicleId}/p5-restrictions${pathParam}`,
|
||||
);
|
||||
},
|
||||
enabled: !!vehicleId,
|
||||
});
|
||||
|
||||
const options = data?.options ?? [];
|
||||
|
||||
const handleSelect = (option: RestrictionOption) => {
|
||||
if (data?.isFinal) {
|
||||
// This is the last selection step — option.path leads to mainGroups
|
||||
onComplete(option.path);
|
||||
} else {
|
||||
// More levels needed — advance to next restriction
|
||||
setSteps((prev) => [
|
||||
...prev,
|
||||
{ label: option.name, selectedCode: option.code, selectedPath: option.path },
|
||||
]);
|
||||
setCurrentPath(option.path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (steps.length === 0) return;
|
||||
const newSteps = steps.slice(0, -1);
|
||||
setSteps(newSteps);
|
||||
setCurrentPath(newSteps.length > 0 ? newSteps[newSteps.length - 1].selectedPath : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Araç Konfigürasyonu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{steps.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Geri
|
||||
</button>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
{steps.map((step, i) => (
|
||||
<span key={step.selectedCode} className="text-muted-foreground">
|
||||
{i > 0 && " / "}
|
||||
<span className="font-medium text-foreground">{step.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Seçenekler yükleniyor...
|
||||
</div>
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Seçenek bulunamadı</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
{option.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface VariantItem {
|
||||
code: string;
|
||||
@@ -32,7 +32,7 @@ export function PsaVariantSelector({ vehicleId, onSelect }: PsaVariantSelectorPr
|
||||
queryKey: ["psa-engines", vehicleId, selectedBody],
|
||||
queryFn: () =>
|
||||
api.get<VariantItem[]>(
|
||||
`/catalog/vehicles/${vehicleId}/psa-engines?body=${encodeURIComponent(selectedBody!)}`,
|
||||
`/catalog/vehicles/${vehicleId}/psa-engines?body=${encodeURIComponent(selectedBody ?? "")}`,
|
||||
),
|
||||
enabled: !!vehicleId && !!selectedBody,
|
||||
});
|
||||
@@ -41,7 +41,7 @@ export function PsaVariantSelector({ vehicleId, onSelect }: PsaVariantSelectorPr
|
||||
queryKey: ["psa-gearboxes", vehicleId, selectedBody, selectedEngine],
|
||||
queryFn: () =>
|
||||
api.get<VariantItem[]>(
|
||||
`/catalog/vehicles/${vehicleId}/psa-gearboxes?body=${encodeURIComponent(selectedBody!)}&engine=${encodeURIComponent(selectedEngine!)}`,
|
||||
`/catalog/vehicles/${vehicleId}/psa-gearboxes?body=${encodeURIComponent(selectedBody ?? "")}&engine=${encodeURIComponent(selectedEngine ?? "")}`,
|
||||
),
|
||||
enabled: !!vehicleId && !!selectedBody && !!selectedEngine,
|
||||
});
|
||||
@@ -58,7 +58,8 @@ export function PsaVariantSelector({ vehicleId, onSelect }: PsaVariantSelectorPr
|
||||
|
||||
const handleEngineSelect = (code: string | "_all_") => {
|
||||
if (code === "_all_") {
|
||||
onSelect(selectedBody!, "_all_", "_all_");
|
||||
if (!selectedBody) return;
|
||||
onSelect(selectedBody, "_all_", "_all_");
|
||||
return;
|
||||
}
|
||||
setSelectedEngine(code);
|
||||
@@ -67,7 +68,8 @@ export function PsaVariantSelector({ vehicleId, onSelect }: PsaVariantSelectorPr
|
||||
|
||||
const handleGearboxSelect = (code: string | "_all_") => {
|
||||
if (code === "_all_") {
|
||||
onSelect(selectedBody!, selectedEngine!, "_all_");
|
||||
if (!selectedBody || !selectedEngine) return;
|
||||
onSelect(selectedBody, selectedEngine, "_all_");
|
||||
return;
|
||||
}
|
||||
setSelectedGearbox(code);
|
||||
|
||||
256
apps/web/src/components/categories/category-columns.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
import { cn } from "@sase/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: Category[];
|
||||
partCount?: number;
|
||||
schemaImageUrl?: string | null;
|
||||
parentId?: string | null;
|
||||
unavailable?: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface CategoryColumnsProps {
|
||||
categories: Category[];
|
||||
vehicleId: string;
|
||||
catalogMode?: boolean;
|
||||
brandName?: string;
|
||||
variantSearch?: { body?: string; engine?: string; gearbox?: string };
|
||||
}
|
||||
|
||||
export function CategoryColumns({
|
||||
categories,
|
||||
vehicleId,
|
||||
catalogMode,
|
||||
brandName,
|
||||
variantSearch,
|
||||
}: CategoryColumnsProps) {
|
||||
// columns[0] = root categories, columns[1] = children of selected[0], etc.
|
||||
const [columns, setColumns] = useState<Category[][]>([categories]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Reset when root categories change
|
||||
useEffect(() => {
|
||||
setColumns([categories]);
|
||||
setSelectedIds([]);
|
||||
}, [categories]);
|
||||
|
||||
// Auto-scroll right when new column added
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: depend only on columns.length so we don't scroll on inner-item updates
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
|
||||
}
|
||||
}, [columns.length]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (category: Category, columnIndex: number) => {
|
||||
// Update selected path up to this column, clear deeper selections
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.slice(0, columnIndex);
|
||||
next[columnIndex] = category.id;
|
||||
return next;
|
||||
});
|
||||
|
||||
// If already have children in initial data, use them
|
||||
const knownChildren = category.children;
|
||||
if (knownChildren && knownChildren.length > 0) {
|
||||
setColumns((prev) => [...prev.slice(0, columnIndex + 1), knownChildren]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy fetch
|
||||
setLoadingId(category.id);
|
||||
try {
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: ["category-children", category.id],
|
||||
queryFn: () => api.get<Category[]>(`/categories/${category.id}/children`),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const children = data || [];
|
||||
if (children.length === 0) {
|
||||
// True leaf — navigate to schema page
|
||||
navigate({
|
||||
to: catalogMode
|
||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: catalogMode
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id },
|
||||
search: (catalogMode && variantSearch ? variantSearch : undefined) as any,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setColumns((prev) => [...prev.slice(0, columnIndex + 1), children]);
|
||||
} catch {
|
||||
setColumns((prev) => [...prev.slice(0, columnIndex + 1), []]);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, navigate, catalogMode, brandName, vehicleId, variantSearch],
|
||||
);
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
return <p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex overflow-x-auto border rounded-md"
|
||||
style={{ minHeight: 320 }}
|
||||
>
|
||||
{columns.map((col, colIdx) => (
|
||||
<ColumnPanel
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: columns are append/pop only — index is the stable identity here
|
||||
key={colIdx}
|
||||
categories={col}
|
||||
selectedId={selectedIds[colIdx]}
|
||||
columnIndex={colIdx}
|
||||
loadingId={loadingId}
|
||||
vehicleId={vehicleId}
|
||||
onSelect={handleSelect}
|
||||
isLast={colIdx === columns.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnPanel({
|
||||
categories,
|
||||
selectedId,
|
||||
columnIndex,
|
||||
loadingId,
|
||||
vehicleId,
|
||||
onSelect,
|
||||
isLast,
|
||||
}: {
|
||||
categories: Category[];
|
||||
selectedId?: string;
|
||||
columnIndex: number;
|
||||
loadingId: string | null;
|
||||
vehicleId: string;
|
||||
onSelect: (cat: Category, colIdx: number) => void;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
|
||||
const prefetchedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Prefetch schema images for leaf categories in this column
|
||||
useEffect(() => {
|
||||
prefetchedRef.current.clear();
|
||||
setImageOverrides(new Map());
|
||||
|
||||
const leafs = categories.filter(
|
||||
(c) =>
|
||||
c.children !== undefined &&
|
||||
c.children.length === 0 &&
|
||||
!c.schemaImageUrl &&
|
||||
c.source !== "parts-catalogs",
|
||||
);
|
||||
if (leafs.length === 0) return;
|
||||
|
||||
const parentId = categories[0]?.parentId;
|
||||
let cancelled = false;
|
||||
const BATCH_SIZE = 2;
|
||||
|
||||
(async () => {
|
||||
for (let i = 0; i < leafs.length; i += BATCH_SIZE) {
|
||||
if (cancelled) break;
|
||||
const batch = leafs.slice(i, i + BATCH_SIZE);
|
||||
await Promise.allSettled(
|
||||
batch.map((c) => api.get(`/vehicles/${vehicleId}/categories/${c.id}`)),
|
||||
);
|
||||
for (const c of batch) prefetchedRef.current.add(c.id);
|
||||
if (!cancelled && parentId) {
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
|
||||
if (!cancelled && refreshed?.length) {
|
||||
setImageOverrides((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const r of refreshed) {
|
||||
if (r.schemaImageUrl) next.set(r.id, r.schemaImageUrl);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [categories, vehicleId]);
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[220px] shrink-0 flex items-center justify-center text-xs text-muted-foreground",
|
||||
!isLast && "border-r",
|
||||
)}
|
||||
>
|
||||
Sonuç yok
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-[220px] shrink-0 overflow-y-auto", !isLast && "border-r")}
|
||||
style={{ maxHeight: 420 }}
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const isSelected = selectedId === category.id;
|
||||
const isLoading = loadingId === category.id;
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-accent text-accent-foreground font-medium",
|
||||
category.unavailable && "opacity-40",
|
||||
)}
|
||||
onClick={() => onSelect(category, columnIndex)}
|
||||
>
|
||||
{schemaImageUrl ? (
|
||||
<img
|
||||
src={schemaImageUrl}
|
||||
alt={category.name}
|
||||
className="h-6 w-6 shrink-0 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{category.name}</span>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -25,7 +25,14 @@ interface CategoryGridProps {
|
||||
variantSearch?: { body?: string; engine?: string; gearbox?: string };
|
||||
}
|
||||
|
||||
export function CategoryGrid({ categories, vehicleId, catalogMode, brandName, parentId, variantSearch }: CategoryGridProps) {
|
||||
export function CategoryGrid({
|
||||
categories,
|
||||
vehicleId,
|
||||
catalogMode,
|
||||
brandName,
|
||||
parentId,
|
||||
variantSearch,
|
||||
}: CategoryGridProps) {
|
||||
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
|
||||
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
|
||||
const prefetchedRef = useRef<Set<string>>(new Set());
|
||||
@@ -66,9 +73,7 @@ export function CategoryGrid({ categories, vehicleId, catalogMode, brandName, pa
|
||||
|
||||
if (!didCancel && parentId) {
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(
|
||||
`/categories/${parentId}/children`,
|
||||
);
|
||||
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
|
||||
if (!didCancel && refreshed?.length) {
|
||||
setImageOverrides((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -90,30 +95,29 @@ export function CategoryGrid({ categories, vehicleId, catalogMode, brandName, pa
|
||||
}, [categories, vehicleId]);
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Kategori bulunamadi.
|
||||
</p>
|
||||
);
|
||||
return <p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((category) => {
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
const isLeaf =
|
||||
category.children !== undefined && category.children.length === 0;
|
||||
const isLeaf = category.children !== undefined && category.children.length === 0;
|
||||
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={category.id}
|
||||
to={catalogMode
|
||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
: "/dashboard/vehicles/$id/categories/$categoryId"}
|
||||
params={catalogMode
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id }}
|
||||
to={
|
||||
catalogMode
|
||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
: "/dashboard/vehicles/$id/categories/$categoryId"
|
||||
}
|
||||
params={
|
||||
catalogMode
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id }
|
||||
}
|
||||
search={catalogMode && variantSearch ? variantSearch : undefined}
|
||||
className={category.unavailable ? "opacity-40" : undefined}
|
||||
>
|
||||
@@ -171,9 +175,7 @@ function CategoryCard({
|
||||
<div className="p-3 flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm line-clamp-2">{name}</span>
|
||||
{partCount != null && partCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{partCount} parça
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">{partCount} parça</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, ChevronDown, Loader2 } from "lucide-react";
|
||||
import { cn } from "@sase/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
import { cn } from "@sase/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -51,9 +51,22 @@ export function CategoryTree({
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryNode({ category, vehicleId, level, parentPrefetching, catalogMode, brandName, variantSearch }: {
|
||||
category: Category; vehicleId: string; level: number; parentPrefetching: boolean;
|
||||
catalogMode?: boolean; brandName?: string; variantSearch?: { body?: string; engine?: string; gearbox?: string };
|
||||
function CategoryNode({
|
||||
category,
|
||||
vehicleId,
|
||||
level,
|
||||
parentPrefetching,
|
||||
catalogMode,
|
||||
brandName,
|
||||
variantSearch,
|
||||
}: {
|
||||
category: Category;
|
||||
vehicleId: string;
|
||||
level: number;
|
||||
parentPrefetching: boolean;
|
||||
catalogMode?: boolean;
|
||||
brandName?: string;
|
||||
variantSearch?: { body?: string; engine?: string; gearbox?: string };
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -67,8 +80,14 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching, catalogMo
|
||||
const isLeaf = fetched && children.length === 0;
|
||||
|
||||
const handleExpand = useCallback(async () => {
|
||||
if (expanded) { setExpanded(false); return; }
|
||||
if (fetched) { setExpanded(true); return; }
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
return;
|
||||
}
|
||||
if (fetched) {
|
||||
setExpanded(true);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await queryClient.fetchQuery({
|
||||
@@ -88,10 +107,15 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching, catalogMo
|
||||
}, [expanded, fetched, category.id, queryClient]);
|
||||
|
||||
// Prefetch schema images for leaf children in batches of 2 when expanded
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: prefetch should fire only on expand toggle, not when children mutate during prefetch
|
||||
useEffect(() => {
|
||||
if (!expanded || prefetchedRef.current) return;
|
||||
const leafs = children.filter(
|
||||
(c) => c.children !== undefined && c.children.length === 0 && !c.schemaImageUrl && c.source !== "parts-catalogs",
|
||||
(c) =>
|
||||
c.children !== undefined &&
|
||||
c.children.length === 0 &&
|
||||
!c.schemaImageUrl &&
|
||||
c.source !== "parts-catalogs",
|
||||
);
|
||||
if (leafs.length === 0) return;
|
||||
prefetchedRef.current = true;
|
||||
@@ -113,18 +137,21 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching, catalogMo
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
|
||||
if (!cancelled && refreshed?.length) {
|
||||
setChildren((prev) => prev.map((c) => {
|
||||
const u = refreshed.find((r) => r.id === c.id);
|
||||
return u?.schemaImageUrl ? { ...c, schemaImageUrl: u.schemaImageUrl } : c;
|
||||
}));
|
||||
setChildren((prev) =>
|
||||
prev.map((c) => {
|
||||
const u = refreshed.find((r) => r.id === c.id);
|
||||
return u?.schemaImageUrl ? { ...c, schemaImageUrl: u.schemaImageUrl } : c;
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!cancelled) setPrefetching(false);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [expanded, vehicleId]);
|
||||
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
@@ -146,27 +173,47 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching, catalogMo
|
||||
) : isLeaf ? (
|
||||
<span className="h-5 w-5" />
|
||||
) : (
|
||||
<button type="button" onClick={handleExpand}
|
||||
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted">
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<SchemaIcon Icon={Icon} schemaImageUrl={category.schemaImageUrl} name={category.name} shimmer={isShimmering} />
|
||||
<SchemaIcon
|
||||
Icon={Icon}
|
||||
schemaImageUrl={category.schemaImageUrl}
|
||||
name={category.name}
|
||||
shimmer={isShimmering}
|
||||
/>
|
||||
{isLeaf ? (
|
||||
<Link
|
||||
to={catalogMode
|
||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
: "/dashboard/vehicles/$id/categories/$categoryId"}
|
||||
params={catalogMode
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id }}
|
||||
to={
|
||||
catalogMode
|
||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
: "/dashboard/vehicles/$id/categories/$categoryId"
|
||||
}
|
||||
params={
|
||||
catalogMode
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id }
|
||||
}
|
||||
search={catalogMode && variantSearch ? variantSearch : undefined}
|
||||
className="flex-1 truncate hover:underline">
|
||||
className="flex-1 truncate hover:underline"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" onClick={handleExpand}
|
||||
className="flex-1 truncate text-left hover:underline">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className="flex-1 truncate text-left hover:underline"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
)}
|
||||
@@ -177,9 +224,16 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching, catalogMo
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<CategoryNode key={child.id} category={child} vehicleId={vehicleId}
|
||||
level={level + 1} parentPrefetching={prefetching}
|
||||
catalogMode={catalogMode} brandName={brandName} variantSearch={variantSearch} />
|
||||
<CategoryNode
|
||||
key={child.id}
|
||||
category={child}
|
||||
vehicleId={vehicleId}
|
||||
level={level + 1}
|
||||
parentPrefetching={prefetching}
|
||||
catalogMode={catalogMode}
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -187,7 +241,12 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching, catalogMo
|
||||
);
|
||||
}
|
||||
|
||||
function SchemaIcon({ Icon, schemaImageUrl, name, shimmer }: {
|
||||
function SchemaIcon({
|
||||
Icon,
|
||||
schemaImageUrl,
|
||||
name,
|
||||
shimmer,
|
||||
}: {
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
schemaImageUrl?: string | null;
|
||||
name: string;
|
||||
@@ -208,8 +267,11 @@ function SchemaIcon({ Icon, schemaImageUrl, name, shimmer }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0"
|
||||
onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
|
||||
<div
|
||||
className="relative flex-shrink-0"
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-primary cursor-pointer" />
|
||||
{show && (
|
||||
<div className="absolute left-0 bottom-full mb-2 z-50 w-56 rounded-lg border bg-card shadow-lg overflow-hidden pointer-events-none">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
@@ -342,11 +342,19 @@ export function PaymentContent({ planKey, period, brandIds }: PaymentContentProp
|
||||
onValueChange={(v) => setPaymentMethod(v as "iyzico" | "eft")}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="iyzico" data-faro-user-action-name="payment-tab-card" className="flex-1">
|
||||
<TabsTrigger
|
||||
value="iyzico"
|
||||
data-faro-user-action-name="payment-tab-card"
|
||||
className="flex-1"
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
{t("payment.creditCard")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="eft" data-faro-user-action-name="payment-tab-eft" className="flex-1">
|
||||
<TabsTrigger
|
||||
value="eft"
|
||||
data-faro-user-action-name="payment-tab-eft"
|
||||
className="flex-1"
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{t("payment.eftTransfer")}
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import type { Hotspot } from "@/hooks/use-parts";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function useIsDark() {
|
||||
return useSyncExternalStore(
|
||||
@@ -41,12 +41,16 @@ function HotspotShape({
|
||||
? "#ef4444"
|
||||
: isHighlighted
|
||||
? "#60a5fa"
|
||||
: isDark ? "#a5b4fc" : "#6b7280";
|
||||
: isDark
|
||||
? "#a5b4fc"
|
||||
: "#6b7280";
|
||||
const fillColor = isSelected
|
||||
? "#ef4444"
|
||||
: isHighlighted
|
||||
? "#60a5fa"
|
||||
: isDark ? "#818cf8" : "#9ca3af";
|
||||
: isDark
|
||||
? "#818cf8"
|
||||
: "#9ca3af";
|
||||
const strokeWidth = isSelected || isHighlighted ? 2.5 : isDark ? 2 : 1.5;
|
||||
|
||||
const commonProps = {
|
||||
@@ -85,17 +89,15 @@ function HotspotShape({
|
||||
return null;
|
||||
}
|
||||
|
||||
export function HotspotOverlay({
|
||||
hotspots,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
}: HotspotOverlayProps) {
|
||||
export function HotspotOverlay({ hotspots, imageWidth, imageHeight }: HotspotOverlayProps) {
|
||||
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
|
||||
useSchemaStore();
|
||||
const isDark = useIsDark();
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${imageWidth} ${imageHeight}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
@@ -119,7 +121,11 @@ export function HotspotOverlay({
|
||||
: hotspot.coordinates[1] - 4;
|
||||
|
||||
return (
|
||||
<g key={hotspot.id} style={{ pointerEvents: "auto" }} data-faro-user-action-name="hotspot-click">
|
||||
<g
|
||||
key={hotspot.id}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
data-faro-user-action-name="hotspot-click"
|
||||
>
|
||||
<HotspotShape
|
||||
hotspot={hotspot}
|
||||
isHighlighted={isHighlighted}
|
||||
@@ -128,9 +134,7 @@ export function HotspotOverlay({
|
||||
onMouseEnter={() => setHighlightedGroup(hotspot.group)}
|
||||
onMouseLeave={() => setHighlightedGroup(null)}
|
||||
onClick={() =>
|
||||
setSelectedGroup(
|
||||
selectedGroup === hotspot.group ? null : hotspot.group,
|
||||
)
|
||||
setSelectedGroup(selectedGroup === hotspot.group ? null : hotspot.group)
|
||||
}
|
||||
/>
|
||||
{(isHighlighted || isSelected) && hotspot.label && (
|
||||
@@ -140,7 +144,13 @@ export function HotspotOverlay({
|
||||
y={labelY}
|
||||
textAnchor="middle"
|
||||
className="pointer-events-none select-none text-xs font-medium"
|
||||
style={{ fontSize: 12, stroke: isDark ? "#000" : "#fff", strokeWidth: 3, strokeLinejoin: "round", fill: isDark ? "#000" : "#fff" }}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
stroke: isDark ? "#000" : "#fff",
|
||||
strokeWidth: 3,
|
||||
strokeLinejoin: "round",
|
||||
fill: isDark ? "#000" : "#fff",
|
||||
}}
|
||||
>
|
||||
{hotspot.label}
|
||||
</text>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { cn } from "@sase/ui";
|
||||
import type { Part } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import type { Part } from "@/hooks/use-parts";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { cn } from "@sase/ui";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface PartsPanelProps {
|
||||
parts: Part[];
|
||||
@@ -18,6 +18,8 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const hasPrices = parts.some((p) => p.price != null);
|
||||
|
||||
// Map group → IDs of available (non-unavailable) parts
|
||||
const availableByGroup = useMemo(() => {
|
||||
const map = new Map<number, string[]>();
|
||||
@@ -31,28 +33,31 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
return map;
|
||||
}, [parts]);
|
||||
|
||||
const copyOemCode = useCallback((e: React.MouseEvent, partId: string, code: string) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedId(partId);
|
||||
setTimeout(() => setCopiedId(null), 1500);
|
||||
const copyOemCode = useCallback(
|
||||
(e: React.MouseEvent, partId: string, code: string) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedId(partId);
|
||||
setTimeout(() => setCopiedId(null), 1500);
|
||||
|
||||
api
|
||||
.post("/analytics/oem-copy", {
|
||||
oemCode: code,
|
||||
partId,
|
||||
vehicleId,
|
||||
categoryId,
|
||||
})
|
||||
.catch(() => {});
|
||||
api
|
||||
.post("/analytics/oem-copy", {
|
||||
oemCode: code,
|
||||
partId,
|
||||
vehicleId,
|
||||
categoryId,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
capture("oem_code_copied", {
|
||||
oem_code: code,
|
||||
part_id: partId,
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
});
|
||||
}, [vehicleId, categoryId]);
|
||||
capture("oem_code_copied", {
|
||||
oem_code: code,
|
||||
part_id: partId,
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
});
|
||||
},
|
||||
[vehicleId, categoryId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroup != null) {
|
||||
@@ -67,9 +72,7 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Parcalar</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{parts.length} parca listeleniyor
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{parts.length} parca listeleniyor</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
@@ -81,6 +84,7 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
<th className="px-3 py-2">OEM Kodu</th>
|
||||
<th className="px-3 py-2 w-14 text-center">Adet</th>
|
||||
<th className="px-3 py-2">Pozisyon</th>
|
||||
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -109,22 +113,21 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
className={cn(
|
||||
"cursor-pointer border-b border-border/50 transition-colors duration-150",
|
||||
part.unavailable && "opacity-40",
|
||||
isSelected &&
|
||||
"bg-primary/10 ring-1 ring-inset ring-primary/20",
|
||||
isSelected && "bg-primary/10 ring-1 ring-inset ring-primary/20",
|
||||
isHighlighted && !isSelected && "bg-accent",
|
||||
!isSelected && !isHighlighted && "hover:bg-accent/50",
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedGroup(group)}
|
||||
onMouseLeave={() => setHighlightedGroup(null)}
|
||||
onClick={() =>
|
||||
setSelectedGroup(
|
||||
selectedGroup === group ? null : group,
|
||||
)
|
||||
}
|
||||
onClick={() => setSelectedGroup(selectedGroup === group ? null : group)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedGroup(selectedGroup === group ? null : group);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{part.hotspotIndex}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium">{part.name}</span>
|
||||
{(part.remark || part.modelCodes) && (
|
||||
@@ -152,9 +155,17 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{part.quantity}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{part.position}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
|
||||
{hasPrices && (
|
||||
<td className="px-3 py-2 text-right text-xs">
|
||||
{part.price != null
|
||||
? new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: part.currency ?? "EUR",
|
||||
}).format(part.price)
|
||||
: "—"}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Button } from "@sase/ui";
|
||||
import { ZoomIn, ZoomOut, RotateCcw, Maximize, Minimize } from "lucide-react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Maximize, Minimize, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
export function SchemaToolbar() {
|
||||
const { zoom, isFullscreen, setZoom, resetView, toggleFullscreen } =
|
||||
useSchemaStore();
|
||||
const { zoom, isFullscreen, setZoom, resetView, toggleFullscreen } = useSchemaStore();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-sm backdrop-blur-sm">
|
||||
@@ -53,11 +52,7 @@ export function SchemaToolbar() {
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "Tam ekrandan çık" : "Tam ekran"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize className="h-4 w-4" />
|
||||
)}
|
||||
{isFullscreen ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import type { Hotspot, Part, SchemaPic } from "@/hooks/use-parts";
|
||||
import { useSchemaInteraction } from "@/hooks/use-schema-interaction";
|
||||
import { SchemaToolbar } from "./schema-toolbar";
|
||||
import { HotspotOverlay } from "./hotspot-overlay";
|
||||
import { PartsPanel } from "./parts-panel";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { cn } from "@sase/ui";
|
||||
import type { Part, Hotspot, SchemaPic } from "@/hooks/use-parts";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { HotspotOverlay } from "./hotspot-overlay";
|
||||
import { PartsPanel } from "./parts-panel";
|
||||
import { SchemaToolbar } from "./schema-toolbar";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
interface SchemaViewerProps {
|
||||
schemaPic: SchemaPic | null;
|
||||
hotspots: Hotspot[];
|
||||
@@ -76,8 +77,8 @@ export function SchemaViewer({
|
||||
</div>
|
||||
<div className="w-full space-y-3 p-4 md:w-[40%]">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,9 +124,9 @@ export function SchemaViewer({
|
||||
<div className="relative">
|
||||
<img
|
||||
src={schemaPic.url}
|
||||
alt={schemaPic.label || "Sema goruntusu"}
|
||||
{...(schemaPic.width > 0 && { width: schemaPic.width })}
|
||||
{...(schemaPic.height > 0 && { height: schemaPic.height })}
|
||||
alt={schemaPic.label || "Şema görüntüsü"}
|
||||
width={schemaPic.width > 0 ? schemaPic.width : undefined}
|
||||
height={schemaPic.height > 0 ? schemaPic.height : undefined}
|
||||
className="max-h-full max-w-full select-none object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
@@ -21,8 +23,6 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Copy, Gift, Link2, Share2, Shield, Trash2, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { capture } from "@/lib/posthog";
|
||||
|
||||
export function SettingsContent() {
|
||||
const { t } = useTranslation();
|
||||
@@ -201,6 +201,19 @@ export function SettingsContent() {
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{user?.createdAt && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("settings.profile.memberSince")}</Label>
|
||||
<Input
|
||||
value={new Date(user.createdAt).toLocaleDateString("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" disabled={savingProfile}>
|
||||
{savingProfile ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
@@ -111,17 +112,12 @@ export function BrandSelector({
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center p-4">
|
||||
<div className="relative">
|
||||
{brand.logoUrl ? (
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={brand.name}
|
||||
className="mb-2 h-12 w-12 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-muted text-lg font-bold text-muted-foreground">
|
||||
{brand.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<CarBrandLogo
|
||||
brandName={brand.name}
|
||||
logoUrl={brand.logoUrl}
|
||||
size={48}
|
||||
className="mb-2"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
|
||||
190
apps/web/src/components/ui/car-brand-logo.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
AlfaRomeoLogo,
|
||||
AlfaRomeoLogoDark,
|
||||
AudiLogo,
|
||||
AudiLogoDark,
|
||||
BMWLogo,
|
||||
BMWLogoDark,
|
||||
BentleyLogo,
|
||||
BentleyLogoDark,
|
||||
FiatLogo,
|
||||
FiatLogoDark,
|
||||
FordLogo,
|
||||
FordLogoDark,
|
||||
GenesisLogo,
|
||||
GenesisLogoDark,
|
||||
HondaLogo,
|
||||
HondaLogoDark,
|
||||
HyundaiLogo,
|
||||
HyundaiLogoDark,
|
||||
InfinitiLogo,
|
||||
InfinitiLogoDark,
|
||||
JaguarLogo,
|
||||
JaguarLogoDark,
|
||||
KiaLogo,
|
||||
KiaLogoDark,
|
||||
LandroverLogo,
|
||||
LandroverLogoDark,
|
||||
LexusLogo,
|
||||
LexusLogoDark,
|
||||
MBLogo,
|
||||
MBLogoDark,
|
||||
MazdaLogo,
|
||||
MazdaLogoDark,
|
||||
MiniLogo,
|
||||
MiniLogoDark,
|
||||
MitsubishiLogo,
|
||||
MitsubishiLogoDark,
|
||||
NissanLogo,
|
||||
NissanLogoDark,
|
||||
PolestarLogo,
|
||||
PolestarLogoDark,
|
||||
PorscheLogo,
|
||||
PorscheLogoDark,
|
||||
SubaruLogo,
|
||||
SubaruLogoDark,
|
||||
ToyotaLogo,
|
||||
ToyotaLogoDark,
|
||||
VolkswagenLogo,
|
||||
VolkswagenLogoDark,
|
||||
VolvoLogo,
|
||||
VolvoLogoDark,
|
||||
} from "@cardog-icons/react";
|
||||
import { cn } from "@sase/ui";
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
// Static SVG asset imports (brands not in @cardog-icons/react)
|
||||
import alpineUrl from "@/assets/brand-logos/alpine.svg";
|
||||
import citroenUrl from "@/assets/brand-logos/citroen.svg";
|
||||
import cupraUrl from "@/assets/brand-logos/cupra.svg";
|
||||
import daciaUrl from "@/assets/brand-logos/dacia.svg";
|
||||
import manUrl from "@/assets/brand-logos/man.svg";
|
||||
import opelUrl from "@/assets/brand-logos/opel.svg";
|
||||
import peugeotUrl from "@/assets/brand-logos/peugeot.svg";
|
||||
import renaultUrl from "@/assets/brand-logos/renault.svg";
|
||||
import seatUrl from "@/assets/brand-logos/seat.svg";
|
||||
import skodaUrl from "@/assets/brand-logos/skoda.svg";
|
||||
import suzukiUrl from "@/assets/brand-logos/suzuki.svg";
|
||||
|
||||
type SvgComponent = React.ForwardRefExoticComponent<
|
||||
Omit<SVGProps<SVGSVGElement>, "ref"> & React.RefAttributes<SVGSVGElement>
|
||||
>;
|
||||
|
||||
interface BrandEntry {
|
||||
light: SvgComponent;
|
||||
dark: SvgComponent;
|
||||
}
|
||||
|
||||
interface StaticEntry {
|
||||
url: string;
|
||||
invertDark: boolean;
|
||||
}
|
||||
|
||||
const BRAND_MAP: Record<string, BrandEntry> = {
|
||||
volkswagen: { light: VolkswagenLogo, dark: VolkswagenLogoDark },
|
||||
vw: { light: VolkswagenLogo, dark: VolkswagenLogoDark },
|
||||
audi: { light: AudiLogo, dark: AudiLogoDark },
|
||||
porsche: { light: PorscheLogo, dark: PorscheLogoDark },
|
||||
bentley: { light: BentleyLogo, dark: BentleyLogoDark },
|
||||
bmw: { light: BMWLogo, dark: BMWLogoDark },
|
||||
mini: { light: MiniLogo, dark: MiniLogoDark },
|
||||
mercedes: { light: MBLogo, dark: MBLogoDark },
|
||||
"mercedes-benz": { light: MBLogo, dark: MBLogoDark },
|
||||
toyota: { light: ToyotaLogo, dark: ToyotaLogoDark },
|
||||
lexus: { light: LexusLogo, dark: LexusLogoDark },
|
||||
ford: { light: FordLogo, dark: FordLogoDark },
|
||||
hyundai: { light: HyundaiLogo, dark: HyundaiLogoDark },
|
||||
kia: { light: KiaLogo, dark: KiaLogoDark },
|
||||
genesis: { light: GenesisLogo, dark: GenesisLogoDark },
|
||||
nissan: { light: NissanLogo, dark: NissanLogoDark },
|
||||
infiniti: { light: InfinitiLogo, dark: InfinitiLogoDark },
|
||||
volvo: { light: VolvoLogo, dark: VolvoLogoDark },
|
||||
polestar: { light: PolestarLogo, dark: PolestarLogoDark },
|
||||
fiat: { light: FiatLogo, dark: FiatLogoDark },
|
||||
"alfa romeo": { light: AlfaRomeoLogo, dark: AlfaRomeoLogoDark },
|
||||
"alfa-romeo": { light: AlfaRomeoLogo, dark: AlfaRomeoLogoDark },
|
||||
alfaromeo: { light: AlfaRomeoLogo, dark: AlfaRomeoLogoDark },
|
||||
mitsubishi: { light: MitsubishiLogo, dark: MitsubishiLogoDark },
|
||||
jaguar: { light: JaguarLogo, dark: JaguarLogoDark },
|
||||
"land rover": { light: LandroverLogo, dark: LandroverLogoDark },
|
||||
"land-rover": { light: LandroverLogo, dark: LandroverLogoDark },
|
||||
landrover: { light: LandroverLogo, dark: LandroverLogoDark },
|
||||
mazda: { light: MazdaLogo, dark: MazdaLogoDark },
|
||||
subaru: { light: SubaruLogo, dark: SubaruLogoDark },
|
||||
honda: { light: HondaLogo, dark: HondaLogoDark },
|
||||
};
|
||||
|
||||
const STATIC_LOGO_MAP: Record<string, StaticEntry> = {
|
||||
renault: { url: renaultUrl, invertDark: true },
|
||||
alpine: { url: alpineUrl, invertDark: true },
|
||||
cupra: { url: cupraUrl, invertDark: true },
|
||||
opel: { url: opelUrl, invertDark: true },
|
||||
dacia: { url: daciaUrl, invertDark: true },
|
||||
peugeot: { url: peugeotUrl, invertDark: true },
|
||||
citroen: { url: citroenUrl, invertDark: true },
|
||||
citroën: { url: citroenUrl, invertDark: true },
|
||||
man: { url: manUrl, invertDark: true },
|
||||
skoda: { url: skodaUrl, invertDark: false },
|
||||
škoda: { url: skodaUrl, invertDark: false },
|
||||
seat: { url: seatUrl, invertDark: false },
|
||||
suzuki: { url: suzukiUrl, invertDark: false },
|
||||
};
|
||||
|
||||
interface CarBrandLogoProps {
|
||||
brandName: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
||||
export function CarBrandLogo({ brandName, size = 40, className, logoUrl }: CarBrandLogoProps) {
|
||||
const key = brandName.toLowerCase();
|
||||
|
||||
const cardog = BRAND_MAP[key];
|
||||
if (cardog) {
|
||||
const { light: LightLogo, dark: DarkLogo } = cardog;
|
||||
return (
|
||||
<>
|
||||
<LightLogo width={size} height={size} className={cn("dark:hidden", className)} />
|
||||
<DarkLogo width={size} height={size} className={cn("hidden dark:block", className)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const staticEntry = STATIC_LOGO_MAP[key];
|
||||
if (staticEntry) {
|
||||
return (
|
||||
<img
|
||||
src={staticEntry.url}
|
||||
alt={brandName}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("object-contain", staticEntry.invertDark && "dark:invert", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (logoUrl) {
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={brandName}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("object-contain", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: size, height: size }}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg bg-muted text-sm font-bold text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{brandName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
interface VehicleCardProps {
|
||||
id: string;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Button,
|
||||
Badge,
|
||||
Separator,
|
||||
} from "@sase/ui";
|
||||
import { Car, Loader2, ChevronRight } from "lucide-react";
|
||||
import { Car, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface PcatCandidate {
|
||||
id: string;
|
||||
@@ -32,7 +32,9 @@ function getDifferingKeys(candidates: PcatCandidate[]): Set<string> {
|
||||
const diffKeys = new Set<string>();
|
||||
const allKeys = new Set(candidates.flatMap((c) => (c.parameters ?? []).map((p) => p.key)));
|
||||
for (const key of allKeys) {
|
||||
const values = new Set(candidates.map((c) => c.parameters?.find((p) => p.key === key)?.value ?? null));
|
||||
const values = new Set(
|
||||
candidates.map((c) => c.parameters?.find((p) => p.key === key)?.value ?? null),
|
||||
);
|
||||
if (values.size > 1) diffKeys.add(key);
|
||||
}
|
||||
return diffKeys;
|
||||
@@ -53,12 +55,10 @@ export function VehicleSelectModal({
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-[family-name:var(--font-display)]">
|
||||
Araç Seçimi
|
||||
</DialogTitle>
|
||||
<DialogTitle className="font-[family-name:var(--font-display)]">Araç Seçimi</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-mono text-xs">{vin}</span> için birden fazla
|
||||
araç bulundu. Lütfen aracınızı seçin.
|
||||
<span className="font-mono text-xs">{vin}</span> için birden fazla araç bulundu. Lütfen
|
||||
aracınızı seçin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -86,9 +86,7 @@ export function VehicleSelectModal({
|
||||
>
|
||||
<div
|
||||
className={`flex size-10 shrink-0 items-center justify-center rounded-xl ${
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
isSelected ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Car className="size-5" />
|
||||
@@ -96,9 +94,7 @@ export function VehicleSelectModal({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{car.name}</p>
|
||||
{car.description && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{car.description}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{car.description}</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{sortedParams.map((p) => {
|
||||
@@ -138,9 +134,7 @@ export function VehicleSelectModal({
|
||||
onClick={() => selectedId && onSelect(selectedId)}
|
||||
className="rounded-xl"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
Seç ve Devam Et
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
|
||||
function isValidVin(vin: string): boolean {
|
||||
@@ -39,11 +39,7 @@ export function VinInput({ onSubmit, loading, error }: VinInputProps) {
|
||||
className="font-mono text-lg tracking-wider"
|
||||
/>
|
||||
<Button type="submit" disabled={loading || vin.length !== 17 || isInvalid}>
|
||||
{loading ? (
|
||||
<span className="animate-spin">...</span>
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
{loading ? <span className="animate-spin">...</span> : <Search className="h-4 w-4" />}
|
||||
Ara
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,45 +1,95 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../packages/ui/src";
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0a0a0a;
|
||||
--color-muted: #f5f5f5;
|
||||
--color-muted-foreground: #737373;
|
||||
--color-border: #e5e5e5;
|
||||
--color-input: #e5e5e5;
|
||||
--color-ring: #0a0a0a;
|
||||
--color-primary: #0a0a0a;
|
||||
--color-primary-foreground: #fafafa;
|
||||
--color-secondary: #f5f5f5;
|
||||
--color-secondary-foreground: #171717;
|
||||
--color-accent: #f5f5f5;
|
||||
--color-accent-foreground: #171717;
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #fafafa;
|
||||
--color-surface: #f5f5f5;
|
||||
--color-surface-foreground: #171717;
|
||||
--color-surface-alt: #eaeaea;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #0a0a0a;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #0a0a0a;
|
||||
/* Light theme — warm-tinted neutrals (not pure white/black) */
|
||||
--color-background: oklch(99.2% 0.003 80);
|
||||
--color-foreground: oklch(15% 0.01 250);
|
||||
--color-muted: oklch(96.5% 0.004 80);
|
||||
--color-muted-foreground: oklch(50% 0.012 250);
|
||||
--color-border: oklch(91% 0.005 250);
|
||||
--color-input: oklch(91% 0.005 250);
|
||||
--color-ring: oklch(15% 0.01 250);
|
||||
--color-primary: oklch(15% 0.01 250);
|
||||
--color-primary-foreground: oklch(99.2% 0.003 80);
|
||||
--color-secondary: oklch(96.5% 0.004 80);
|
||||
--color-secondary-foreground: oklch(20% 0.01 250);
|
||||
--color-accent: oklch(96.5% 0.004 80);
|
||||
--color-accent-foreground: oklch(20% 0.01 250);
|
||||
--color-destructive: oklch(58% 0.18 28);
|
||||
--color-destructive-foreground: oklch(99.2% 0.003 80);
|
||||
--color-surface: oklch(96.5% 0.004 80);
|
||||
--color-surface-foreground: oklch(20% 0.01 250);
|
||||
--color-surface-alt: oklch(94% 0.005 80);
|
||||
--color-card: oklch(99.2% 0.003 80);
|
||||
--color-card-foreground: oklch(15% 0.01 250);
|
||||
--color-popover: oklch(99.2% 0.003 80);
|
||||
--color-popover-foreground: oklch(15% 0.01 250);
|
||||
|
||||
/* Brand accent — single semantic token; replaces ad-hoc emerald usage */
|
||||
--color-brand: oklch(56% 0.13 158);
|
||||
--color-brand-foreground: oklch(99.2% 0.003 80);
|
||||
--color-brand-muted: oklch(94% 0.04 158);
|
||||
--color-brand-soft: oklch(56% 0.13 158 / 0.12);
|
||||
|
||||
/* Tinted shadows — carry the cool-neutral hue rather than pure black */
|
||||
--shadow-sm: 0 1px 2px oklch(15% 0.01 250 / 0.06);
|
||||
--shadow-md: 0 4px 14px oklch(15% 0.01 250 / 0.08);
|
||||
--shadow-lg: 0 16px 40px oklch(15% 0.01 250 / 0.12);
|
||||
--shadow-brand: 0 8px 28px oklch(56% 0.13 158 / 0.18);
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.25rem;
|
||||
|
||||
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", monospace;
|
||||
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased font-sans;
|
||||
font-feature-settings: "cv11", "ss01", "ss03";
|
||||
}
|
||||
/* Fix autofill contrast in dark mode — Chrome/Safari force a light bg on autofilled inputs */
|
||||
/* Headlines: tighter tracking, balanced wraps (no orphans) */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
text-wrap: balance;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
h4,
|
||||
h5,
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* Numbers in data contexts use tabular alignment */
|
||||
.tabular,
|
||||
[data-tabular],
|
||||
input[type="number"],
|
||||
.font-mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Skip-to-content link for keyboard users */
|
||||
.skip-link {
|
||||
@apply sr-only;
|
||||
}
|
||||
.skip-link:focus {
|
||||
@apply not-sr-only fixed left-4 top-4 z-50 rounded-md bg-foreground px-4 py-2 text-background shadow-lg;
|
||||
}
|
||||
/* Fix autofill contrast — Chrome/Safari force a light bg on autofilled inputs */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
@@ -52,67 +102,123 @@
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-left {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scroll-left { animation: scroll-left 30s linear infinite; }
|
||||
.animate-float { animation: float 3s ease-in-out infinite; }
|
||||
.carousel-track:hover .animate-scroll-left { animation-play-state: paused; }
|
||||
.scrollbar-none::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scroll-left {
|
||||
animation: scroll-left 30s linear infinite;
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out;
|
||||
}
|
||||
.carousel-track:hover .animate-scroll-left {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.animate-fade-in-up { animation: fade-in-up 0.3s ease-out; }
|
||||
|
||||
/* Sileo toast: deeper state colors for light mode */
|
||||
:root {
|
||||
--sileo-state-success: oklch(0.52 0.24 142);
|
||||
--sileo-state-success: oklch(0.56 0.13 158);
|
||||
--sileo-state-error: oklch(0.48 0.26 25);
|
||||
--sileo-state-warning: oklch(0.58 0.2 70);
|
||||
--sileo-state-info: oklch(0.50 0.2 237);
|
||||
--sileo-state-loading: oklch(0.40 0 0);
|
||||
--sileo-state-info: oklch(0.5 0.2 237);
|
||||
--sileo-state-loading: oklch(0.4 0 0);
|
||||
}
|
||||
|
||||
[data-sileo-description] {
|
||||
color: #333;
|
||||
color: oklch(25% 0.01 250);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
--color-muted: #262626;
|
||||
--color-muted-foreground: #a3a3a3;
|
||||
--color-border: #262626;
|
||||
--color-input: #262626;
|
||||
--color-ring: #d4d4d4;
|
||||
--color-primary: #fafafa;
|
||||
--color-primary-foreground: #171717;
|
||||
--color-secondary: #262626;
|
||||
--color-secondary-foreground: #fafafa;
|
||||
--color-accent: #262626;
|
||||
--color-accent-foreground: #fafafa;
|
||||
--color-destructive: #dc2626;
|
||||
--color-destructive-foreground: #fafafa;
|
||||
--color-surface: #1a1a1a;
|
||||
--color-surface-foreground: #fafafa;
|
||||
--color-surface-alt: #0f0f0f;
|
||||
--color-card: #0a0a0a;
|
||||
--color-card-foreground: #fafafa;
|
||||
--color-popover: #0a0a0a;
|
||||
--color-popover-foreground: #fafafa;
|
||||
/* Dark theme — off-black with cool tint, never pure black */
|
||||
--color-background: oklch(13% 0.008 250);
|
||||
--color-foreground: oklch(97% 0.004 80);
|
||||
--color-muted: oklch(20% 0.008 250);
|
||||
--color-muted-foreground: oklch(65% 0.012 250);
|
||||
--color-border: oklch(22% 0.008 250);
|
||||
--color-input: oklch(22% 0.008 250);
|
||||
--color-ring: oklch(80% 0.005 250);
|
||||
--color-primary: oklch(97% 0.004 80);
|
||||
--color-primary-foreground: oklch(15% 0.01 250);
|
||||
--color-secondary: oklch(20% 0.008 250);
|
||||
--color-secondary-foreground: oklch(97% 0.004 80);
|
||||
--color-accent: oklch(20% 0.008 250);
|
||||
--color-accent-foreground: oklch(97% 0.004 80);
|
||||
--color-destructive: oklch(54% 0.2 28);
|
||||
--color-destructive-foreground: oklch(97% 0.004 80);
|
||||
--color-surface: oklch(17% 0.008 250);
|
||||
--color-surface-foreground: oklch(97% 0.004 80);
|
||||
--color-surface-alt: oklch(15% 0.008 250);
|
||||
--color-card: oklch(13% 0.008 250);
|
||||
--color-card-foreground: oklch(97% 0.004 80);
|
||||
--color-popover: oklch(13% 0.008 250);
|
||||
--color-popover-foreground: oklch(97% 0.004 80);
|
||||
|
||||
--color-brand: oklch(68% 0.16 158);
|
||||
--color-brand-foreground: oklch(13% 0.008 250);
|
||||
--color-brand-muted: oklch(28% 0.06 158);
|
||||
--color-brand-soft: oklch(68% 0.16 158 / 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.4);
|
||||
--shadow-md: 0 4px 14px oklch(0% 0 0 / 0.5);
|
||||
--shadow-lg: 0 16px 40px oklch(0% 0 0 / 0.6);
|
||||
--shadow-brand: 0 8px 32px oklch(68% 0.16 158 / 0.22);
|
||||
|
||||
/* Sileo toast: dark pill/body, light text, subtler shadow */
|
||||
--sileo-state-loading: oklch(0.7 0 0);
|
||||
@@ -120,13 +226,13 @@
|
||||
|
||||
.dark [data-sileo-pill],
|
||||
.dark [data-sileo-body] {
|
||||
fill: #1c1c1e !important;
|
||||
fill: oklch(17% 0.008 250) !important;
|
||||
}
|
||||
|
||||
.dark [data-sileo-description] {
|
||||
color: #d4d4d4;
|
||||
color: oklch(85% 0.005 80);
|
||||
}
|
||||
|
||||
.dark [data-sileo-toast] {
|
||||
filter: drop-shadow(0 0 12px rgba(0, 0, 0, 0.4));
|
||||
filter: drop-shadow(0 0 12px oklch(0% 0 0 / 0.4));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSession, signIn, signUp, signOut } from "@/lib/auth-client";
|
||||
import { signIn, signOut, signUp, useSession } from "@/lib/auth-client";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -8,6 +8,7 @@ export function useAuth() {
|
||||
|
||||
useEffect(() => {
|
||||
if (session.data?.user) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Better Auth user shape narrows to store User at runtime
|
||||
setUser(session.data.user as any);
|
||||
} else if (!session.isPending) {
|
||||
setUser(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface Part {
|
||||
id: string;
|
||||
@@ -12,7 +12,8 @@ export interface Part {
|
||||
remark?: string;
|
||||
modelCodes?: string;
|
||||
presel?: boolean;
|
||||
price?: number;
|
||||
price?: number | null;
|
||||
currency?: string | null;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -58,11 +59,7 @@ export interface CategorySchema {
|
||||
export function useCategoryParts(vehicleId: string, categoryId: string) {
|
||||
return useQuery<CategorySchema>({
|
||||
queryKey: ["category-parts", vehicleId, categoryId],
|
||||
queryFn: () =>
|
||||
api.get<CategorySchema>(
|
||||
`/vehicles/${vehicleId}/categories/${categoryId}`,
|
||||
),
|
||||
queryFn: () => api.get<CategorySchema>(`/vehicles/${vehicleId}/categories/${categoryId}`),
|
||||
enabled: !!vehicleId && !!categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
function getDistance(t1: React.Touch, t2: React.Touch): number {
|
||||
const dx = t1.clientX - t2.clientX;
|
||||
@@ -24,14 +24,11 @@ export function useSchemaInteraction() {
|
||||
[zoom, setZoom],
|
||||
);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
isDragging.current = true;
|
||||
lastMousePos.current = { x: e.clientX, y: e.clientY };
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
isDragging.current = true;
|
||||
lastMousePos.current = { x: e.clientX, y: e.clientY };
|
||||
}, []);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -48,25 +45,22 @@ export function useSchemaInteraction() {
|
||||
isDragging.current = false;
|
||||
}, []);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const dist = getDistance(e.touches[0], e.touches[1]);
|
||||
lastTouchDistance.current = dist;
|
||||
lastTouchCenter.current = {
|
||||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||
};
|
||||
} else if (e.touches.length === 1) {
|
||||
isDragging.current = true;
|
||||
lastMousePos.current = {
|
||||
x: e.touches[0].clientX,
|
||||
y: e.touches[0].clientY,
|
||||
};
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const dist = getDistance(e.touches[0], e.touches[1]);
|
||||
lastTouchDistance.current = dist;
|
||||
lastTouchCenter.current = {
|
||||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||
};
|
||||
} else if (e.touches.length === 1) {
|
||||
isDragging.current = true;
|
||||
lastMousePos.current = {
|
||||
x: e.touches[0].clientX,
|
||||
y: e.touches[0].clientY,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {
|
||||
Cog,
|
||||
Settings,
|
||||
CircleDot,
|
||||
Shield,
|
||||
Zap,
|
||||
Droplets,
|
||||
Wind,
|
||||
Car,
|
||||
Footprints,
|
||||
Wrench,
|
||||
Package,
|
||||
FlaskConical,
|
||||
CircleDot,
|
||||
Cog,
|
||||
Droplets,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Footprints,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Settings,
|
||||
Shield,
|
||||
Wind,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
const iconRules: [RegExp, LucideIcon][] = [
|
||||
|
||||
@@ -459,7 +459,11 @@ export const EMEX_GROUP_HIERARCHY: Record<string, string[]> = {
|
||||
"10908": ["Fuel Mixture Formation"],
|
||||
"10909": ["Axle Mounting/ Steering/ Wheels"],
|
||||
"10910": ["Axle Mounting/ Steering/ Wheels"],
|
||||
"10911": ["Fuel Mixture Formation", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"10911": [
|
||||
"Fuel Mixture Formation",
|
||||
"Exhaust Emission Control",
|
||||
"Exhaust Gas Recirculation (EGR)",
|
||||
],
|
||||
"10912": ["Engine"],
|
||||
"10918": ["Engine", "Engine Air Supply"],
|
||||
"10919": ["Locking System"],
|
||||
@@ -742,7 +746,11 @@ export const EMEX_GROUP_HIERARCHY: Record<string, string[]> = {
|
||||
"13031": ["Transmission", "Automatic Transmission"],
|
||||
"13064": ["Brake System", "Drum Brake"],
|
||||
"13065": ["Brake System", "Drum Brake"],
|
||||
"13092": ["Fuel Mixture Formation", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"13092": [
|
||||
"Fuel Mixture Formation",
|
||||
"Exhaust Emission Control",
|
||||
"Exhaust Gas Recirculation (EGR)",
|
||||
],
|
||||
"13093": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"13098": ["Interior Equipment"],
|
||||
"13104": ["Interior Equipment"],
|
||||
@@ -793,9 +801,21 @@ export const EMEX_GROUP_HIERARCHY: Record<string, string[]> = {
|
||||
"13336": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"13337": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"13338": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"13339": ["Fuel Mixture Formation", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"13340": ["Fuel Mixture Formation", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"13341": ["Fuel Mixture Formation", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
|
||||
"13339": [
|
||||
"Fuel Mixture Formation",
|
||||
"Exhaust Emission Control",
|
||||
"Exhaust Gas Recirculation (EGR)",
|
||||
],
|
||||
"13340": [
|
||||
"Fuel Mixture Formation",
|
||||
"Exhaust Emission Control",
|
||||
"Exhaust Gas Recirculation (EGR)",
|
||||
],
|
||||
"13341": [
|
||||
"Fuel Mixture Formation",
|
||||
"Exhaust Emission Control",
|
||||
"Exhaust Gas Recirculation (EGR)",
|
||||
],
|
||||
"13370": ["Engine", "Crankshaft Drive", "Piston Assembly"],
|
||||
"13372": ["Interior Equipment"],
|
||||
"13374": ["Accessories"],
|
||||
|
||||
@@ -43,17 +43,11 @@ export function getFaro() {
|
||||
}
|
||||
|
||||
/** Start a programmatic user action (auto-completes 100ms after last linked event) */
|
||||
export function startAction(
|
||||
name: string,
|
||||
attributes?: Record<string, string>,
|
||||
) {
|
||||
export function startAction(name: string, attributes?: Record<string, string>) {
|
||||
faro?.api.startUserAction(name, attributes);
|
||||
}
|
||||
|
||||
/** Push a Faro event (for non-action tracking like page-level events) */
|
||||
export function pushEvent(
|
||||
name: string,
|
||||
attributes?: Record<string, string>,
|
||||
) {
|
||||
export function pushEvent(name: string, attributes?: Record<string, string>) {
|
||||
faro?.api.pushEvent(name, attributes);
|
||||
}
|
||||
|
||||
97
apps/web/src/lib/keys.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Stable React `key` arrays for fixed-length skeleton/decorative lists.
|
||||
*
|
||||
* Lets us replace `Array.from({ length: N }).map((_, i) => <… key={i} />)`
|
||||
* (which Biome flags via lint/suspicious/noArrayIndexKey) with
|
||||
* `KEYS_N.map((k) => <… key={k} />)` — the keys are module-level constants
|
||||
* so they don't change identity across renders.
|
||||
*
|
||||
* If you need a length not listed here, add a new export — they cost nothing.
|
||||
*/
|
||||
export const KEYS_4 = ["k0", "k1", "k2", "k3"];
|
||||
export const KEYS_5 = ["k0", "k1", "k2", "k3", "k4"];
|
||||
export const KEYS_6 = ["k0", "k1", "k2", "k3", "k4", "k5"];
|
||||
export const KEYS_8 = ["k0", "k1", "k2", "k3", "k4", "k5", "k6", "k7"];
|
||||
export const KEYS_9 = ["k0", "k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8"];
|
||||
export const KEYS_10 = ["k0", "k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8", "k9"];
|
||||
export const KEYS_16 = [
|
||||
"k0",
|
||||
"k1",
|
||||
"k2",
|
||||
"k3",
|
||||
"k4",
|
||||
"k5",
|
||||
"k6",
|
||||
"k7",
|
||||
"k8",
|
||||
"k9",
|
||||
"k10",
|
||||
"k11",
|
||||
"k12",
|
||||
"k13",
|
||||
"k14",
|
||||
"k15",
|
||||
];
|
||||
export const KEYS_17 = [
|
||||
"k0",
|
||||
"k1",
|
||||
"k2",
|
||||
"k3",
|
||||
"k4",
|
||||
"k5",
|
||||
"k6",
|
||||
"k7",
|
||||
"k8",
|
||||
"k9",
|
||||
"k10",
|
||||
"k11",
|
||||
"k12",
|
||||
"k13",
|
||||
"k14",
|
||||
"k15",
|
||||
"k16",
|
||||
];
|
||||
export const KEYS_25 = [
|
||||
"k0",
|
||||
"k1",
|
||||
"k2",
|
||||
"k3",
|
||||
"k4",
|
||||
"k5",
|
||||
"k6",
|
||||
"k7",
|
||||
"k8",
|
||||
"k9",
|
||||
"k10",
|
||||
"k11",
|
||||
"k12",
|
||||
"k13",
|
||||
"k14",
|
||||
"k15",
|
||||
"k16",
|
||||
"k17",
|
||||
"k18",
|
||||
"k19",
|
||||
"k20",
|
||||
"k21",
|
||||
"k22",
|
||||
"k23",
|
||||
"k24",
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a stable key list of arbitrary length. Use this when the count is
|
||||
* dynamic (e.g. derived from props/state). The result is memoized per length.
|
||||
*/
|
||||
const dynamicCache = new Map<number, string[]>();
|
||||
export function dynamicKeys(length: number): string[] {
|
||||
let cached = dynamicCache.get(length);
|
||||
if (!cached) {
|
||||
cached = Array.from({ length }, (_, i) => `k${i}`);
|
||||
dynamicCache.set(length, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Backwards-compat alias used by a few earlier call sites. */
|
||||
export const BRAND_SKELETON_KEYS = KEYS_8;
|
||||
@@ -23,7 +23,8 @@ export function initPostHog(): void {
|
||||
_initialized = true;
|
||||
load().then((ph) => {
|
||||
ph.init(key, {
|
||||
api_host: "https://eu.i.posthog.com",
|
||||
api_host: "https://t.sase.tr",
|
||||
defaults: "2026-01-30",
|
||||
person_profiles: "identified_only",
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
@@ -60,9 +61,7 @@ export function capture(event: string, properties?: Record<string, unknown>): vo
|
||||
}
|
||||
|
||||
export function capturePageView(path: string): void {
|
||||
load().then((ph) =>
|
||||
ph.capture("$pageview", { $current_url: window.location.origin + path }),
|
||||
);
|
||||
load().then((ph) => ph.capture("$pageview", { $current_url: window.location.origin + path }));
|
||||
}
|
||||
|
||||
export function setPeopleProperties(properties: Record<string, unknown>): void {
|
||||
|
||||
@@ -14,12 +14,8 @@ interface ToastOptions {
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, opts?: ToastOptions) =>
|
||||
sileo.success({ title, ...opts }),
|
||||
error: (title: string, opts?: ToastOptions) =>
|
||||
sileo.error({ title, ...opts }),
|
||||
info: (title: string, opts?: ToastOptions) =>
|
||||
sileo.info({ title, ...opts }),
|
||||
warning: (title: string, opts?: ToastOptions) =>
|
||||
sileo.warning({ title, ...opts }),
|
||||
success: (title: string, opts?: ToastOptions) => sileo.success({ title, ...opts }),
|
||||
error: (title: string, opts?: ToastOptions) => sileo.error({ title, ...opts }),
|
||||
info: (title: string, opts?: ToastOptions) => sileo.info({ title, ...opts }),
|
||||
warning: (title: string, opts?: ToastOptions) => sileo.warning({ title, ...opts }),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const STORAGE_KEY = "userSettings";
|
||||
|
||||
interface UserSettings {
|
||||
categoryViewMode?: "grid" | "tree";
|
||||
categoryViewMode?: "grid" | "tree" | "columns";
|
||||
modelViewMode?: "grid" | "tree" | "columns";
|
||||
brandViewMode?: "grid" | "tree" | "columns";
|
||||
sidebarCollapsed?: boolean;
|
||||
theme?: "light" | "dark" | "system";
|
||||
}
|
||||
@@ -20,10 +22,7 @@ export function getUserSettings(): UserSettings {
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserSetting<K extends keyof UserSettings>(
|
||||
key: K,
|
||||
value: UserSettings[K],
|
||||
) {
|
||||
export function setUserSetting<K extends keyof UserSettings>(key: K, value: UserSettings[K]) {
|
||||
const settings = getUserSettings();
|
||||
settings[key] = value;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { initFaro } from "./lib/faro";
|
||||
import { initPostHog } from "./lib/posthog";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { initFaro } from "./lib/faro";
|
||||
import { initPostHog } from "./lib/posthog";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import "./globals.css";
|
||||
|
||||
|
||||
@@ -177,10 +177,10 @@
|
||||
"expired": "Expired"
|
||||
},
|
||||
"popular": "Popular",
|
||||
"trialTitle": "7-Day Full Package Trial",
|
||||
"trialDescription": "Free access to all brands for 7 days. No credit card required.",
|
||||
"trialTitle": "30-Day Full Package Trial",
|
||||
"trialDescription": "Free access to all brands for 30 days. No credit card required.",
|
||||
"startTrial": "Start Free Trial",
|
||||
"trialStarted": "Your 7-day Full Package trial has started!",
|
||||
"trialStarted": "Your 30-day Full Package trial has started!",
|
||||
"onboarding": {
|
||||
"provisioning": "Setting up your free trial",
|
||||
"step1": "Verifying account",
|
||||
@@ -188,7 +188,7 @@
|
||||
"step3": "Activating Full Package",
|
||||
"step4": "Completed!",
|
||||
"completed": "You can test all catalogs without limits!",
|
||||
"trialDuration": "7-Day Trial",
|
||||
"trialDuration": "30-Day Trial",
|
||||
"startSearching": "Start Searching",
|
||||
"error": "An error occurred while starting your trial.",
|
||||
"retry": "Try Again"
|
||||
@@ -270,6 +270,7 @@
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"phonePlaceholder": "+90 5xx xxx xx xx",
|
||||
"memberSince": "Member Since",
|
||||
"updated": "Profile updated.",
|
||||
"updateFailed": "Failed to update profile."
|
||||
},
|
||||
|
||||
@@ -177,10 +177,10 @@
|
||||
"expired": "Süresi Doldu"
|
||||
},
|
||||
"popular": "Popüler",
|
||||
"trialTitle": "7 Gün Full Paket Denemesi",
|
||||
"trialDescription": "Tüm markalara 7 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
|
||||
"trialTitle": "30 Gün Full Paket Denemesi",
|
||||
"trialDescription": "Tüm markalara 30 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
|
||||
"startTrial": "Ücretsiz Denemeyi Başlat",
|
||||
"trialStarted": "7 günlük Full Paket denemeniz başlatıldı!",
|
||||
"trialStarted": "30 günlük Full Paket denemeniz başlatıldı!",
|
||||
"onboarding": {
|
||||
"provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor",
|
||||
"step1": "Hesap doğrulanıyor",
|
||||
@@ -188,7 +188,7 @@
|
||||
"step3": "Full Paket aktif ediliyor",
|
||||
"step4": "Tamamlandı!",
|
||||
"completed": "Tüm katalogları sınırsız test edebilirsiniz!",
|
||||
"trialDuration": "7 Gün Deneme",
|
||||
"trialDuration": "30 Gün Deneme",
|
||||
"startSearching": "Şase Aramaya Başla",
|
||||
"error": "Deneme başlatılırken bir hata oluştu.",
|
||||
"retry": "Tekrar Dene"
|
||||
@@ -270,6 +270,7 @@
|
||||
"email": "E-posta",
|
||||
"phone": "Telefon",
|
||||
"phonePlaceholder": "+90 5xx xxx xx xx",
|
||||
"memberSince": "Kayıt Tarihi",
|
||||
"updated": "Profil güncellendi.",
|
||||
"updateFailed": "Profil güncellenirken hata oluştu."
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Sequence,
|
||||
interpolate,
|
||||
spring,
|
||||
Sequence,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
function getColors(isDark: boolean) {
|
||||
@@ -21,13 +21,7 @@ function getColors(isDark: boolean) {
|
||||
}
|
||||
|
||||
const VIN_TEXT = "WVWZZZ1JZ3W...";
|
||||
const SIDEBAR_ITEMS = [
|
||||
"Dashboard",
|
||||
"Şase Arama",
|
||||
"Katalog",
|
||||
"Şemalar",
|
||||
"Ayarlar",
|
||||
];
|
||||
const SIDEBAR_ITEMS = ["Dashboard", "Şase Arama", "Katalog", "Şemalar", "Ayarlar"];
|
||||
const CATEGORIES = ["Motor", "Şasi", "Elektrik", "Karoseri", "Fren"];
|
||||
const PARTS = [
|
||||
{ code: "1J0 820 803F", name: "Klima Kompresörü" },
|
||||
@@ -52,10 +46,7 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
|
||||
// Typewriter effect starts at frame 30
|
||||
const typeStart = 30;
|
||||
const charsToShow = Math.min(
|
||||
Math.max(0, Math.floor((frame - typeStart) / 3)),
|
||||
VIN_TEXT.length,
|
||||
);
|
||||
const charsToShow = Math.min(Math.max(0, Math.floor((frame - typeStart) / 3)), VIN_TEXT.length);
|
||||
const typedText = VIN_TEXT.slice(0, charsToShow);
|
||||
|
||||
return (
|
||||
@@ -102,6 +93,8 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -200,6 +193,8 @@ const VehicleInfoScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -212,11 +207,7 @@ const VehicleInfoScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<span
|
||||
style={{ fontFamily: "monospace", fontSize: 11, color: c.fg }}
|
||||
>
|
||||
{VIN_TEXT}
|
||||
</span>
|
||||
<span style={{ fontFamily: "monospace", fontSize: 11, color: c.fg }}>{VIN_TEXT}</span>
|
||||
</div>
|
||||
|
||||
{/* Vehicle card */}
|
||||
@@ -353,14 +344,9 @@ const CategoryScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
color:
|
||||
highlightProgress > 0.5 ? c.fg : c.mutedFg,
|
||||
color: highlightProgress > 0.5 ? c.fg : c.mutedFg,
|
||||
backgroundColor:
|
||||
highlightProgress > 0.5
|
||||
? isDark
|
||||
? "#064e3b"
|
||||
: "#d1fae5"
|
||||
: c.muted,
|
||||
highlightProgress > 0.5 ? (isDark ? "#064e3b" : "#d1fae5") : c.muted,
|
||||
border: `1px solid ${highlightProgress > 0.5 ? c.emerald : c.border}`,
|
||||
opacity: itemOpacity,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
@@ -384,10 +370,10 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
const c = getColors(isDark);
|
||||
|
||||
const HOTSPOTS = [
|
||||
{ x: "30%", y: "35%" },
|
||||
{ x: "60%", y: "25%" },
|
||||
{ x: "45%", y: "60%" },
|
||||
{ x: "70%", y: "55%" },
|
||||
{ id: "hs-1", x: "30%", y: "35%" },
|
||||
{ id: "hs-2", x: "60%", y: "25%" },
|
||||
{ id: "hs-3", x: "45%", y: "60%" },
|
||||
{ id: "hs-4", x: "70%", y: "55%" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -486,7 +472,7 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
key={spot.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: spot.x,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Sequence,
|
||||
interpolate,
|
||||
spring,
|
||||
Sequence,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
function getColors(isDark: boolean) {
|
||||
@@ -50,9 +50,7 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
const badgeScale = interpolate(badgeSpring, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{ backgroundColor: c.bg, flexDirection: "column", padding: 0 }}
|
||||
>
|
||||
<AbsoluteFill style={{ backgroundColor: c.bg, flexDirection: "column", padding: 0 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
@@ -87,6 +85,8 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -112,6 +112,8 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
{/* Cart icon */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -284,9 +286,7 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{ backgroundColor: c.bg, flexDirection: "column", padding: 0 }}
|
||||
>
|
||||
<AbsoluteFill style={{ backgroundColor: c.bg, flexDirection: "column", padding: 0 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
@@ -333,6 +333,8 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -395,6 +397,8 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
{/* Cart icon */}
|
||||
<div style={{ position: "relative", flexShrink: 0 }}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -518,10 +522,7 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
const c = getColors(isDark);
|
||||
|
||||
// Typewriter (1 char / 3 frames)
|
||||
const charsToShow = Math.min(
|
||||
Math.max(0, Math.floor(frame / 3)),
|
||||
VIN_DISPLAY.length,
|
||||
);
|
||||
const charsToShow = Math.min(Math.max(0, Math.floor(frame / 3)), VIN_DISPLAY.length);
|
||||
const typedVin = VIN_DISPLAY.slice(0, charsToShow);
|
||||
|
||||
// Filter starts at ~frame 45
|
||||
@@ -533,9 +534,7 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
const incompatibleIndices = [2, 4, 5];
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{ backgroundColor: c.bg, flexDirection: "column", padding: 0 }}
|
||||
>
|
||||
<AbsoluteFill style={{ backgroundColor: c.bg, flexDirection: "column", padding: 0 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
@@ -582,6 +581,8 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -653,6 +654,8 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
{/* Cart */}
|
||||
<div style={{ position: "relative", flexShrink: 0 }}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -721,13 +724,8 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
>
|
||||
{PRODUCTS.map((product, i) => {
|
||||
const isIncompatible = incompatibleIndices.includes(i);
|
||||
const cardOpacity = isIncompatible
|
||||
? interpolate(filterProgress, [0, 1], [1, 0.15])
|
||||
: 1;
|
||||
const borderColor =
|
||||
!isIncompatible && filterProgress > 0.5
|
||||
? c.emerald
|
||||
: c.border;
|
||||
const cardOpacity = isIncompatible ? interpolate(filterProgress, [0, 1], [1, 0.15]) : 1;
|
||||
const borderColor = !isIncompatible && filterProgress > 0.5 ? c.emerald : c.border;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -826,9 +824,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
|
||||
// Click animation at frame 30
|
||||
const clickScale =
|
||||
frame >= 30 && frame <= 38
|
||||
? interpolate(frame, [30, 34, 38], [1, 0.95, 1])
|
||||
: 1;
|
||||
frame >= 30 && frame <= 38 ? interpolate(frame, [30, 34, 38], [1, 0.95, 1]) : 1;
|
||||
|
||||
// Notification slides down at frame 40
|
||||
const notifSpring = spring({
|
||||
@@ -857,9 +853,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
const incompatibleIndices = [2, 4, 5];
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{ backgroundColor: c.bg, flexDirection: "column", padding: 0 }}
|
||||
>
|
||||
<AbsoluteFill style={{ backgroundColor: c.bg, flexDirection: "column", padding: 0 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
@@ -925,6 +919,8 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
{/* Cart icon with animated badge */}
|
||||
<div style={{ position: "relative", flexShrink: 0 }}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -954,9 +950,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transform: showCartBadge
|
||||
? `scale(${cartScale})`
|
||||
: "scale(1)",
|
||||
transform: showCartBadge ? `scale(${cartScale})` : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{showCartBadge ? "1" : "0"}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
spring,
|
||||
} from "remotion";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
function getColors(isDark: boolean) {
|
||||
return {
|
||||
@@ -87,7 +81,7 @@ export const OnboardingProgress: React.FC<{
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
key={label}
|
||||
style={{
|
||||
position: "absolute",
|
||||
opacity: labelOpacity,
|
||||
@@ -158,7 +152,7 @@ export const OnboardingProgress: React.FC<{
|
||||
const circleSize = 28;
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
<div key={step.label}>
|
||||
{/* Outer circle */}
|
||||
<div
|
||||
style={{
|
||||
@@ -188,6 +182,8 @@ export const OnboardingProgress: React.FC<{
|
||||
/>
|
||||
{/* Checkmark */}
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
viewBox="0 0 24 24"
|
||||
width={14}
|
||||
height={14}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Sequence,
|
||||
interpolate,
|
||||
spring,
|
||||
Sequence,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
const PARTS = ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9"];
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createRootRouteWithContext, Outlet, useLocation } from "@tanstack/react-router";
|
||||
import { Toaster } from "@/lib/toast";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { getUserSettings } from "@/lib/user-settings";
|
||||
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
|
||||
import { Toaster } from "@/lib/toast";
|
||||
import { getUserSettings } from "@/lib/user-settings";
|
||||
import { Button } from "@sase/ui";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Link, Outlet, createRootRouteWithContext, useLocation } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Home, Search } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
@@ -12,13 +14,75 @@ interface RouterContext {
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
notFoundComponent: NotFoundComponent,
|
||||
});
|
||||
|
||||
function NotFoundComponent() {
|
||||
return (
|
||||
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 py-12">
|
||||
{/* Ambient brand glow */}
|
||||
<div className="pointer-events-none absolute -left-32 top-1/4 h-[500px] w-[500px] rounded-full bg-brand/8 blur-[140px]" />
|
||||
<div className="pointer-events-none absolute -right-32 bottom-1/4 h-[400px] w-[400px] rounded-full bg-brand/5 blur-[120px]" />
|
||||
|
||||
<div className="relative max-w-xl text-center">
|
||||
<p className="font-mono text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
404 — sayfa bulunamadı
|
||||
</p>
|
||||
<h1 className="mt-6 font-[family-name:var(--font-display)] text-6xl font-bold tracking-tight sm:text-7xl">
|
||||
Yanlış parça,
|
||||
<br />
|
||||
<span className="text-muted-foreground">yanlış adres.</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-md text-base text-muted-foreground">
|
||||
Aradığın sayfa silinmiş ya da hiç olmamış olabilir. Aşağıdan ana sayfaya dönebilir veya
|
||||
doğrudan şase aramaya gidebilirsin.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Link to="/">
|
||||
<Button variant="outline" className="rounded-full">
|
||||
<ArrowLeft className="size-4" />
|
||||
Ana sayfaya dön
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/dashboard/search">
|
||||
<Button variant="brand" className="rounded-full">
|
||||
<Search className="size-4" />
|
||||
Şase aramaya git
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
|
||||
>
|
||||
<Home className="size-3.5" />
|
||||
Anasayfa
|
||||
</Link>
|
||||
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
|
||||
<Link to="/pricing" className="transition-colors hover:text-foreground">
|
||||
Fiyatlandırma
|
||||
</Link>
|
||||
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
|
||||
<Link to="/demo" className="transition-colors hover:text-foreground">
|
||||
Demo
|
||||
</Link>
|
||||
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
|
||||
<Link to="/contact" className="transition-colors hover:text-foreground">
|
||||
İletişim
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function applyTheme(theme: "light" | "dark" | "system") {
|
||||
const isDark =
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
}
|
||||
|
||||
@@ -43,7 +107,7 @@ function RootComponent() {
|
||||
} else {
|
||||
resetUser();
|
||||
}
|
||||
}, [user?.id]);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = getUserSettings().theme ?? "dark";
|
||||
@@ -59,6 +123,9 @@ function RootComponent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href="#main-content" className="skip-link">
|
||||
İçeriğe atla
|
||||
</a>
|
||||
<Outlet />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
|
||||
115
apps/web/src/routes/__tests__/index.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Regression tests for landing page VIN decode analytics events.
|
||||
*
|
||||
* These tests verify that the correct PostHog events are captured on
|
||||
* successful VIN decode and on error conditions.
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock the PostHog capture function
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capture: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the API client used for VIN decode
|
||||
vi.mock("@/lib/api-client", () => ({
|
||||
api: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(message: string, public code?: string, public status?: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the auth store to simulate a logged‑in user
|
||||
vi.mock("@/stores/auth.store", () => {
|
||||
const actual = vi.importActual("@/stores/auth.store");
|
||||
return {
|
||||
...actual,
|
||||
useAuthStore: {
|
||||
getState: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock navigation to avoid real router side‑effects
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual<any>("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { HomePage } from "@/routes/index"; // Exported component
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { api, ApiError } from "@/lib/api-client";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(useAuthStore.getState as any).mockReturnValue({
|
||||
user: { id: "user-1" },
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("captures vin_decode_success on successful decode", async () => {
|
||||
(api.post as any).mockResolvedValue({ id: "test-vehicle-id" });
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<HomePage />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/Örnek:/i);
|
||||
fireEvent.change(input, { target: { value: "WVWZZZ1JZ3W597935" } });
|
||||
const button = screen.getByRole("button", { name: /Ara/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capture).toHaveBeenCalledWith(
|
||||
"vin_decode_success",
|
||||
expect.objectContaining({
|
||||
vin: "WVWZZZ1JZ3W597935",
|
||||
source: "landing",
|
||||
vehicle_id: "test-vehicle-id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("captures vin_decode_error on API error", async () => {
|
||||
const error = new ApiError("Invalid VIN", "INVALID", 400);
|
||||
(api.post as any).mockRejectedValue(error);
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<HomePage />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/Örnek:/i);
|
||||
fireEvent.change(input, { target: { value: "INVALIDVIN1234567" } });
|
||||
const button = screen.getByRole("button", { name: /Ara/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(capture).toHaveBeenCalledWith(
|
||||
"vin_decode_error",
|
||||
expect.objectContaining({
|
||||
vin: "INVALIDVIN1234567",
|
||||
source: "landing",
|
||||
error: "Invalid VIN",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
|
||||
import { Link, Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { Database, ShieldCheck, Zap } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/_auth")({
|
||||
@@ -9,7 +9,7 @@ function AuthLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Panel — Form */}
|
||||
<div className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
|
||||
<main id="main-content" className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<Outlet />
|
||||
@@ -23,31 +23,53 @@ function AuthLayout() {
|
||||
Sase.tr
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Panel — Promo (always dark, hidden on mobile) */}
|
||||
<div className="hidden border-l border-white/5 bg-[#09090b] text-white lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
|
||||
<div className="flex flex-1 flex-col justify-center space-y-8">
|
||||
{/* Right Panel — Promo (always dark via .dark scope, hidden on mobile) */}
|
||||
<div className="dark relative hidden overflow-hidden border-l border-border bg-background text-foreground lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
|
||||
{/* Brand glow ambient */}
|
||||
<div className="pointer-events-none absolute -left-32 top-0 h-[500px] w-[500px] rounded-full bg-brand/10 blur-[140px]" />
|
||||
<div className="pointer-events-none absolute -right-24 bottom-0 h-[400px] w-[400px] rounded-full bg-brand/8 blur-[120px]" />
|
||||
{/* Subtle grid */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-[0.04]">
|
||||
<svg width="100%" height="100%" aria-hidden="true">
|
||||
<defs>
|
||||
<pattern id="auth-grid" width="48" height="48" patternUnits="userSpaceOnUse">
|
||||
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#auth-grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 flex-col justify-center space-y-8">
|
||||
{/* Heading */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Doğru Parçayı İlk Seferde Bulun
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-surface/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur-sm">
|
||||
<span className="size-1.5 rounded-full bg-brand" />
|
||||
Sase.tr
|
||||
</span>
|
||||
<h2 className="font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight">
|
||||
Doğru parçayı
|
||||
<br />
|
||||
<span className="text-foreground/60">ilk seferde bulun.</span>
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed text-neutral-400">
|
||||
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM
|
||||
kodları. Şase numarasını girin, doğru parçayı saniyeler içinde
|
||||
bulun.
|
||||
<p className="text-base leading-relaxed text-muted-foreground">
|
||||
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM kodları. Şase
|
||||
numarasını girin, doğru parçayı saniyeler içinde bulun.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
|
||||
<Zap className="size-3" />
|
||||
1.2sn Sorgu
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="icon"
|
||||
className="size-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -62,47 +84,44 @@ function AuthLayout() {
|
||||
</svg>
|
||||
27 Marka
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
|
||||
<Database className="size-3" />
|
||||
243K+ OEM Parça
|
||||
<span className="tabular">243K+ OEM Parça</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
|
||||
<ShieldCheck className="size-3" />
|
||||
%99.9 Uptime
|
||||
<span className="tabular">%99.9 Uptime</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Brand row */}
|
||||
<p className="text-sm text-neutral-500">
|
||||
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda ·
|
||||
Hyundai · Ford · Opel · Skoda
|
||||
<p className="text-sm text-muted-foreground">
|
||||
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda · Hyundai · Ford ·
|
||||
Opel · Skoda
|
||||
</p>
|
||||
|
||||
{/* Testimonial */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<p className="text-sm leading-relaxed text-neutral-300">
|
||||
“Sase.tr'ye geçtiğimizden beri yanlış parça
|
||||
siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde
|
||||
zaman tasarrufu sağlıyoruz.”
|
||||
<div className="rounded-2xl border border-border bg-surface/40 p-6 backdrop-blur-sm">
|
||||
<p className="text-sm leading-relaxed text-foreground/85">
|
||||
“Sase.tr'ye geçtiğimizden beri yanlış parça siparişlerimiz neredeyse sıfıra
|
||||
indi. Aylık 40 saatin üzerinde zaman tasarrufu sağlıyoruz.”
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-white/10 text-sm font-medium">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
MK
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Mehmet K.</p>
|
||||
<p className="text-xs text-neutral-400">
|
||||
Yedek Parça İşletme Sahibi
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Yedek Parça İşletme Sahibi</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom trial badge */}
|
||||
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
|
||||
<ShieldCheck className="size-4" />
|
||||
7 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
<div className="relative flex items-center gap-2 pt-6 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="size-4 text-brand" />
|
||||
30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_auth/forgot-password")({
|
||||
component: ForgotPasswordPage,
|
||||
@@ -44,12 +44,9 @@ function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
E-posta Gönderildi
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">E-posta Gönderildi</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Şifre sıfırlama bağlantısı {email} adresine gönderildi. Lütfen
|
||||
e-postanızı kontrol edin.
|
||||
Şifre sıfırlama bağlantısı {email} adresine gönderildi. Lütfen e-postanızı kontrol edin.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/login">
|
||||
@@ -65,9 +62,7 @@ function ForgotPasswordPage() {
|
||||
<div className="space-y-8">
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Şifremi Unuttum
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Şifremi Unuttum</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
E-posta adresinize şifre sıfırlama bağlantısı göndereceğiz
|
||||
</p>
|
||||
@@ -92,10 +87,7 @@ function ForgotPasswordPage() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-muted-foreground hover:underline"
|
||||
>
|
||||
<Link to="/login" className="text-muted-foreground hover:underline">
|
||||
Giriş Sayfasına Dön
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_auth/login")({
|
||||
component: LoginPage,
|
||||
@@ -50,9 +50,7 @@ function LoginPage() {
|
||||
<div className="space-y-8">
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Hesabınıza Giriş Yapın
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Hesabınıza Giriş Yapın</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Şase çözme ve parça kataloğuna erişmek için giriş yapın
|
||||
</p>
|
||||
@@ -69,10 +67,22 @@ function LoginPage() {
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Google ile Giriş Yap
|
||||
</Button>
|
||||
@@ -83,9 +93,7 @@ function LoginPage() {
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
veya
|
||||
</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">veya</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,16 +124,10 @@ function LoginPage() {
|
||||
{/* Remember me + Forgot password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<input type="checkbox" className="size-4 rounded border-input accent-primary" />
|
||||
Beni hatırla
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
<Link to="/forgot-password" className="text-sm text-muted-foreground hover:underline">
|
||||
Şifremi Unuttum
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { signIn, signUp } from "@/lib/auth-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_auth/register")({
|
||||
component: RegisterPage,
|
||||
@@ -59,17 +59,13 @@ function RegisterPage() {
|
||||
<div className="space-y-8">
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Kayıt Ol
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Yeni bir Sase.tr hesabı oluşturun
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Kayıt Ol</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Yeni bir Sase.tr hesabı oluşturun</p>
|
||||
|
||||
{/* Trial messaging */}
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
7 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-brand/20 bg-brand/10 px-3 py-2 text-sm text-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0 text-brand" />
|
||||
30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,10 +80,22 @@ function RegisterPage() {
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Google ile Kayıt Ol
|
||||
</Button>
|
||||
@@ -98,9 +106,7 @@ function RegisterPage() {
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
veya
|
||||
</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">veya</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_auth/reset-password")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -31,9 +31,7 @@ function ResetPasswordPage() {
|
||||
toast.success("Şifreniz başarıyla güncellendi.");
|
||||
navigate({ to: "/login" });
|
||||
} catch {
|
||||
toast.error(
|
||||
"Şifre sıfırlama başarısız. Bağlantı süresi dolmuş olabilir.",
|
||||
);
|
||||
toast.error("Şifre sıfırlama başarısız. Bağlantı süresi dolmuş olabilir.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -43,12 +41,8 @@ function ResetPasswordPage() {
|
||||
<div className="space-y-8">
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Şifre Sıfırla
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Yeni şifrenizi belirleyin
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Şifre Sıfırla</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Yeni şifrenizi belirleyin</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/about")({
|
||||
component: AboutPage,
|
||||
@@ -37,47 +37,44 @@ function AboutPage() {
|
||||
|
||||
<div className="mt-8 space-y-6 text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen
|
||||
dijital bir platformdur. Şase numarası (VIN) ile araç tanımlama,
|
||||
orijinal parça kataloğuna erişim ve interaktif şema görüntüleme
|
||||
hizmetlerini tek bir çatı altında sunar.
|
||||
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen dijital bir platformdur.
|
||||
Şase numarası (VIN) ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif
|
||||
şema görüntüleme hizmetlerini tek bir çatı altında sunar.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2>
|
||||
<p>
|
||||
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk
|
||||
seferde ulaşmayı sağlamak ve sektördeki bilgi asimetrisini ortadan
|
||||
kaldırmak. Oto yedek parçacılar, servisler ve bireysel kullanıcılar
|
||||
için güvenilir bir referans noktası olmayı hedefliyoruz.
|
||||
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk seferde ulaşmayı sağlamak ve
|
||||
sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar, servisler ve
|
||||
bireysel kullanıcılar için güvenilir bir referans noktası olmayı hedefliyoruz.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Şase Çözme:</strong> VIN numarası ile aracın marka,
|
||||
model, yıl, motor tipi ve donanım bilgilerine anında erişim.
|
||||
<strong>Şase Çözme:</strong> VIN numarası ile aracın marka, model, yıl, motor tipi ve
|
||||
donanım bilgilerine anında erişim.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Orijinal Parça Kataloğu:</strong> OEM bazlı parça
|
||||
numaraları, açıklamalar ve çapraz referanslar.
|
||||
<strong>Orijinal Parça Kataloğu:</strong> OEM bazlı parça numaraları, açıklamalar ve
|
||||
çapraz referanslar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>İnteraktif Şema:</strong> Araç şemaları üzerinden
|
||||
görsel parça seçimi ve detay görüntüleme.
|
||||
<strong>İnteraktif Şema:</strong> Araç şemaları üzerinden görsel parça seçimi ve detay
|
||||
görüntüleme.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Çoklu Marka Desteği:</strong> Volkswagen, Audi, BMW,
|
||||
Mercedes-Benz, Ford ve daha fazlası.
|
||||
<strong>Çoklu Marka Desteği:</strong> Volkswagen, Audi, BMW, Mercedes-Benz, Ford ve
|
||||
daha fazlası.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Neden Sase.tr?</h2>
|
||||
<p>
|
||||
Geleneksel yöntemlerle saatler süren parça arama işlemini
|
||||
dakikalara indiriyoruz. Güncel ve doğrulanmış verilerle yanlış
|
||||
parça siparişinin önüne geçiyoruz. Kullanıcı dostu arayüzümüz
|
||||
sayesinde teknik bilgi seviyesinden bağımsız olarak herkes
|
||||
kolayca kullanabilir.
|
||||
Geleneksel yöntemlerle saatler süren parça arama işlemini dakikalara indiriyoruz. Güncel
|
||||
ve doğrulanmış verilerle yanlış parça siparişinin önüne geçiyoruz. Kullanıcı dostu
|
||||
arayüzümüz sayesinde teknik bilgi seviyesinden bağımsız olarak herkes kolayca
|
||||
kullanabilir.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">İletişim</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@sase/ui";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/blog")({
|
||||
component: BlogPage,
|
||||
@@ -78,9 +78,7 @@ function BlogPage() {
|
||||
<CardTitle className="text-lg">{post.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{post.description}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{post.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link, createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
// ─── BLOG POST DATA ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,33 +23,40 @@ const POSTS: Record<string, BlogPost> = {
|
||||
content: (
|
||||
<div className="space-y-6 leading-relaxed text-muted-foreground">
|
||||
<p>
|
||||
VIN (Vehicle Identification Number), yani Araç Tanımlama Numarası, her motorlu taşıta üretim
|
||||
aşamasında atanan 17 karakterlik benzersiz bir koddur. Türkiye'de "şase numarası" olarak da
|
||||
bilinen bu kod, aracın üretiminden imhasına kadar tüm yaşam döngüsünü takip etmeye yarar.
|
||||
VIN (Vehicle Identification Number), yani Araç Tanımlama Numarası, her motorlu taşıta
|
||||
üretim aşamasında atanan 17 karakterlik benzersiz bir koddur. Türkiye'de "şase numarası"
|
||||
olarak da bilinen bu kod, aracın üretiminden imhasına kadar tüm yaşam döngüsünü takip
|
||||
etmeye yarar.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">VIN Nereden Okunur?</h2>
|
||||
<p>
|
||||
VIN numarasına birçok yerden ulaşabilirsiniz: ön camın sol alt köşesindeki plaka, sürücü
|
||||
kapısının iç kısmındaki etiket, motor bölmesi veya araç ruhsatı ve fatura bunların
|
||||
başında gelir. Bazı araçlarda bagaj kapısı iç kısmında da bulunur.
|
||||
kapısının iç kısmındaki etiket, motor bölmesi veya araç ruhsatı ve fatura bunların başında
|
||||
gelir. Bazı araçlarda bagaj kapısı iç kısmında da bulunur.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">17 Karakterin Anlamı</h2>
|
||||
<p>VIN üç ana bölüme ayrılır:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong className="text-foreground">WMI (1-3. karakterler) — Dünya Üretici Kodu:</strong>{" "}
|
||||
<strong className="text-foreground">
|
||||
WMI (1-3. karakterler) — Dünya Üretici Kodu:
|
||||
</strong>{" "}
|
||||
Aracın hangi ülkede ve hangi fabrikada üretildiğini gösterir. Örneğin "WVW" Volkswagen
|
||||
Almanya, "ZFA" Fiat İtalya demektir.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">VDS (4-9. karakterler) — Araç Tanımlayıcı Bölüm:</strong>{" "}
|
||||
<strong className="text-foreground">
|
||||
VDS (4-9. karakterler) — Araç Tanımlayıcı Bölüm:
|
||||
</strong>{" "}
|
||||
Model, kasa tipi, motor hacmi, yakıt türü ve güvenlik donanımları hakkında bilgi içerir.
|
||||
9. karakter her zaman kontrol karakteridir ve matematiksel bir doğrulama amacı taşır.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">VIS (10-17. karakterler) — Araç Tanımlama Bölümü:</strong>{" "}
|
||||
<strong className="text-foreground">
|
||||
VIS (10-17. karakterler) — Araç Tanımlama Bölümü:
|
||||
</strong>{" "}
|
||||
Model yılı (10. karakter), üretim fabrikası (11. karakter) ve üretim sıra numarası
|
||||
(12-17. karakterler) bilgilerini içerir.
|
||||
</li>
|
||||
@@ -57,9 +64,9 @@ const POSTS: Record<string, BlogPost> = {
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Örnek: WVWZZZ1JZ3W597935</h2>
|
||||
<p>
|
||||
Bu VIN'i inceleyelim: <strong className="font-mono text-foreground">WVW</strong> — Volkswagen
|
||||
Almanya, <strong className="font-mono text-foreground">ZZZ</strong> — pazar tanımlayıcı,{" "}
|
||||
<strong className="font-mono text-foreground">1J</strong> — Golf modeli,{" "}
|
||||
Bu VIN'i inceleyelim: <strong className="font-mono text-foreground">WVW</strong> —
|
||||
Volkswagen Almanya, <strong className="font-mono text-foreground">ZZZ</strong> — pazar
|
||||
tanımlayıcı, <strong className="font-mono text-foreground">1J</strong> — Golf modeli,{" "}
|
||||
<strong className="font-mono text-foreground">Z</strong> — motor tipi,{" "}
|
||||
<strong className="font-mono text-foreground">3</strong> — model yılı 2003,{" "}
|
||||
<strong className="font-mono text-foreground">W</strong> — Wolfsburg fabrikası,{" "}
|
||||
@@ -109,10 +116,10 @@ const POSTS: Record<string, BlogPost> = {
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">OEM Parça Nedir?</h2>
|
||||
<p>
|
||||
OEM (Original Equipment Manufacturer), aracın üretiminde kullanılan ya da araç üreticisinin
|
||||
onayladığı orijinal parçalardır. Bu parçalar araç fabrikasında kullanılan parçalarla aynı
|
||||
spesifikasyonlara sahiptir ve genellikle aynı tedarikçilerden gelir. Üzerinde araç markasının
|
||||
logosu bulunabilir ya da yalnızca parça numarasıyla satılabilir.
|
||||
OEM (Original Equipment Manufacturer), aracın üretiminde kullanılan ya da araç
|
||||
üreticisinin onayladığı orijinal parçalardır. Bu parçalar araç fabrikasında kullanılan
|
||||
parçalarla aynı spesifikasyonlara sahiptir ve genellikle aynı tedarikçilerden gelir.
|
||||
Üzerinde araç markasının logosu bulunabilir ya da yalnızca parça numarasıyla satılabilir.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Aftermarket (Muadil) Parça Nedir?</h2>
|
||||
@@ -154,7 +161,9 @@ const POSTS: Record<string, BlogPost> = {
|
||||
<li>Güvenlik sistemlerini olumsuz etkileyebilir</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Hangi Durumlarda Orijinal, Hangisinde Muadil?</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
Hangi Durumlarda Orijinal, Hangisinde Muadil?
|
||||
</h2>
|
||||
<p>
|
||||
Fren sistemi, hava yastığı, direksiyon ve motor parçaları gibi güvenlik kritik
|
||||
bileşenlerde kesinlikle OEM tercih edilmelidir. Kaporta, iç döşeme veya aksesuar
|
||||
@@ -190,12 +199,12 @@ const POSTS: Record<string, BlogPost> = {
|
||||
<h2 className="text-xl font-semibold text-foreground">Geleneksel Yöntemlerin Sorunları</h2>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong className="text-foreground">Zaman kaybı:</strong> Tek bir parça için birden fazla
|
||||
katalog taramak ortalama 15-20 dakika sürebilir.
|
||||
<strong className="text-foreground">Zaman kaybı:</strong> Tek bir parça için birden
|
||||
fazla katalog taramak ortalama 15-20 dakika sürebilir.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Hata oranı:</strong> Elle yapılan arama ve karşılaştırma
|
||||
işlemlerinde yanlış parça sipariş riski yüksektir.
|
||||
<strong className="text-foreground">Hata oranı:</strong> Elle yapılan arama ve
|
||||
karşılaştırma işlemlerinde yanlış parça sipariş riski yüksektir.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Güncellik sorunu:</strong> Basılı kataloglar yeni
|
||||
@@ -208,9 +217,7 @@ const POSTS: Record<string, BlogPost> = {
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Dijital Dönüşümün Faydaları</h2>
|
||||
<p>
|
||||
Dijital platformlar, yedek parça arama sürecini kökten değiştirmektedir:
|
||||
</p>
|
||||
<p>Dijital platformlar, yedek parça arama sürecini kökten değiştirmektedir:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>VIN bazlı anlık araç tanımlama — saniyeler içinde doğru araç tespiti</li>
|
||||
<li>Çoklu katalog çapraz sorgulama — tek arayüzde birden fazla kaynak</li>
|
||||
@@ -220,9 +227,7 @@ const POSTS: Record<string, BlogPost> = {
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Sektörde Sayısal Dönüşüm</h2>
|
||||
<p>
|
||||
Dijital platforma geçiş yapan işletmelerin deneyimlerine göre:
|
||||
</p>
|
||||
<p>Dijital platforma geçiş yapan işletmelerin deneyimlerine göre:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>Parça arama süresi ortalama %85 kısalmaktadır</li>
|
||||
<li>Yanlış parça iade oranları %60-80 düşmektedir</li>
|
||||
@@ -230,20 +235,22 @@ const POSTS: Record<string, BlogPost> = {
|
||||
<li>Personel kapasitesi daha katma değerli işlere yönlendirilebilmektedir</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Küçük ve Orta Ölçekli İşletmeler İçin Fırsatlar</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
Küçük ve Orta Ölçekli İşletmeler İçin Fırsatlar
|
||||
</h2>
|
||||
<p>
|
||||
Dijital dönüşüm artık yalnızca büyük zincirlerin ayrıcalığı değildir. Aylık sabit maliyetli
|
||||
abonelik modelleri sayesinde küçük oto yedek parçacılar ve servisler de kurumsal araçlara
|
||||
erişebilmektedir. Bu durum, rekabet eşitliğini kısmen sağlamaktadır.
|
||||
Dijital dönüşüm artık yalnızca büyük zincirlerin ayrıcalığı değildir. Aylık sabit
|
||||
maliyetli abonelik modelleri sayesinde küçük oto yedek parçacılar ve servisler de kurumsal
|
||||
araçlara erişebilmektedir. Bu durum, rekabet eşitliğini kısmen sağlamaktadır.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Sase.tr'nin Rolü</h2>
|
||||
<p>
|
||||
Sase.tr, Türkiye'nin yedek parça sektörüne özgü geliştirilen bu dijital dönüşümün
|
||||
öncüsüdür. VIN/şase numarası sorgulama, çoklu katalog entegrasyonu ve interaktif şema
|
||||
görüntüleme özellikleriyle geleneksel iş yapış biçimlerini dönüştürmektedir.
|
||||
Platform, 27+ marka ve 243.000'den fazla OEM parça numarasıyla sektörün en kapsamlı
|
||||
dijital kataloğunu sunmaktadır.
|
||||
görüntüleme özellikleriyle geleneksel iş yapış biçimlerini dönüştürmektedir. Platform, 27+
|
||||
marka ve 243.000'den fazla OEM parça numarasıyla sektörün en kapsamlı dijital kataloğunu
|
||||
sunmaktadır.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
@@ -258,8 +265,8 @@ const POSTS: Record<string, BlogPost> = {
|
||||
content: (
|
||||
<div className="space-y-6 leading-relaxed text-muted-foreground">
|
||||
<p>
|
||||
Yanlış parça sipariş etmek hem zaman hem para kaybettirdiği gibi, müşteri memnuniyetini
|
||||
de olumsuz etkiler. Sase.tr, bu sorunu kökten çözmek için tasarlanmıştır. Bu rehberde
|
||||
Yanlış parça sipariş etmek hem zaman hem para kaybettirdiği gibi, müşteri memnuniyetini de
|
||||
olumsuz etkiler. Sase.tr, bu sorunu kökten çözmek için tasarlanmıştır. Bu rehberde
|
||||
platformu nasıl en verimli şekilde kullanacağınızı adım adım anlatıyoruz.
|
||||
</p>
|
||||
|
||||
@@ -276,32 +283,34 @@ const POSTS: Record<string, BlogPost> = {
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Adım 2: Kategori Seçin</h2>
|
||||
<p>
|
||||
Araç tanımlandıktan sonra karşınıza o araca özgü parça kategorileri çıkar. Motor,
|
||||
Şasi & Süspansiyon, Elektrik, Karoseri, Klima & Isıtma gibi ana kategorilerden
|
||||
ihtiyacınız olan bölümü seçin.
|
||||
Araç tanımlandıktan sonra karşınıza o araca özgü parça kategorileri çıkar. Motor, Şasi &
|
||||
Süspansiyon, Elektrik, Karoseri, Klima & Isıtma gibi ana kategorilerden ihtiyacınız olan
|
||||
bölümü seçin.
|
||||
</p>
|
||||
<p>
|
||||
Her ana kategorinin altında detaylı alt kategoriler bulunur. Örneğin "Motor" altında
|
||||
Silindir Kapağı, Krank Mili, Piston, Yağ Pompası gibi bölümler yer alır.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Adım 3: İnteraktif Şemayı Kullanın</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
Adım 3: İnteraktif Şemayı Kullanın
|
||||
</h2>
|
||||
<p>
|
||||
Kategori seçildikten sonra o bölgеnin teknik çizimi — interaktif şema — ekranda açılır.
|
||||
Şema üzerindeki parçalara tıklayarak OEM kodunu, açıklamasını ve gerekli miktarını
|
||||
görebilirsiniz.
|
||||
</p>
|
||||
<p>
|
||||
Şemalar zoom ve pan desteğiyle büyütülebilir, yatay veya dikey kaydırılabilir.
|
||||
Karmaşık motor veya şasi bölgelerinde parça konumunu görsel olarak tespit etmek
|
||||
iade oranlarını ciddi ölçüde düşürmektedir.
|
||||
Şemalar zoom ve pan desteğiyle büyütülebilir, yatay veya dikey kaydırılabilir. Karmaşık
|
||||
motor veya şasi bölgelerinde parça konumunu görsel olarak tespit etmek iade oranlarını
|
||||
ciddi ölçüde düşürmektedir.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Adım 4: OEM Kodunu Kopyalayın</h2>
|
||||
<p>
|
||||
Doğru parçayı bulduktan sonra OEM kodunu panoya kopyalayın. Bu kodu tedarikçinize,
|
||||
servis faturanıza veya online sipariş formunuza yapıştırarak yanlış parça riskini
|
||||
tamamen ortadan kaldırın.
|
||||
Doğru parçayı bulduktan sonra OEM kodunu panoya kopyalayın. Bu kodu tedarikçinize, servis
|
||||
faturanıza veya online sipariş formunuza yapıştırarak yanlış parça riskini tamamen ortadan
|
||||
kaldırın.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">İpuçları</h2>
|
||||
@@ -315,19 +324,17 @@ const POSTS: Record<string, BlogPost> = {
|
||||
karşılaştırma özelliğini kullanın.
|
||||
</li>
|
||||
<li>
|
||||
Parça bulamadığınızda kategori ağacında bir seviye yukarı çıkarak daha geniş
|
||||
bir arama yapabilirsiniz.
|
||||
</li>
|
||||
<li>
|
||||
27+ markanın tamamına erişmek için Full Paket aboneliği en avantajlı seçenektir.
|
||||
Parça bulamadığınızda kategori ağacında bir seviye yukarı çıkarak daha geniş bir arama
|
||||
yapabilirsiniz.
|
||||
</li>
|
||||
<li>27+ markanın tamamına erişmek için Full Paket aboneliği en avantajlı seçenektir.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
|
||||
<p>
|
||||
Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik
|
||||
ücretini karşılar. 7 günlük ücretsiz deneme süresiyle platformu bugün deneyin —
|
||||
kredi kartı gerektirmez.
|
||||
ücretini karşılar. 30 günlük ücretsiz deneme süresiyle platformu bugün deneyin — kredi
|
||||
kartı gerektirmez.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/contact")({
|
||||
component: ContactPage,
|
||||
@@ -35,8 +35,7 @@ function ContactPage() {
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">İletişim</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Sorularınız, önerileriniz veya iş birliği talepleriniz için bize
|
||||
ulaşın.
|
||||
Sorularınız, önerileriniz veya iş birliği talepleriniz için bize ulaşın.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
@@ -45,10 +44,7 @@ function ContactPage() {
|
||||
<CardTitle className="text-lg">E-posta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a
|
||||
href="mailto:info@sase.tr"
|
||||
className="text-primary underline"
|
||||
>
|
||||
<a href="mailto:info@sase.tr" className="text-primary underline">
|
||||
info@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
@@ -62,10 +58,7 @@ function ContactPage() {
|
||||
<CardTitle className="text-lg">Destek</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a
|
||||
href="mailto:destek@sase.tr"
|
||||
className="text-primary underline"
|
||||
>
|
||||
<a href="mailto:destek@sase.tr" className="text-primary underline">
|
||||
destek@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
@@ -79,9 +72,7 @@ function ContactPage() {
|
||||
<CardTitle className="text-lg">Adres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
İstanbul, Türkiye
|
||||
</p>
|
||||
<p className="text-muted-foreground">İstanbul, Türkiye</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Çalışma saatleri: Pazartesi – Cuma, 09:00 – 18:00
|
||||
</p>
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import { createFileRoute, Outlet, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { KEYS_5 } from "@/lib/keys";
|
||||
import { capture, resetUser } from "@/lib/posthog";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Separator, Skeleton } from "@sase/ui";
|
||||
import { Link, Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
Search,
|
||||
History,
|
||||
CreditCard,
|
||||
Receipt,
|
||||
Settings,
|
||||
Shield,
|
||||
Users,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Copy,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Share2,
|
||||
Menu,
|
||||
X,
|
||||
FlaskConical,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
Moon,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
LayoutDashboard,
|
||||
Bell,
|
||||
ArrowRight,
|
||||
Mail,
|
||||
BookOpen,
|
||||
Receipt,
|
||||
Search,
|
||||
Settings,
|
||||
Share2,
|
||||
Shield,
|
||||
Sun,
|
||||
Moon,
|
||||
Copy,
|
||||
Library,
|
||||
FlaskConical,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { capture, resetUser } from "@/lib/posthog";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: DashboardLayout,
|
||||
@@ -47,7 +48,12 @@ const mainMenuItems = [
|
||||
] as const;
|
||||
|
||||
const accountItems = [
|
||||
{ to: "/dashboard/subscription", label: "nav.subscription", translatable: true, icon: CreditCard },
|
||||
{
|
||||
to: "/dashboard/subscription",
|
||||
label: "nav.subscription",
|
||||
translatable: true,
|
||||
icon: CreditCard,
|
||||
},
|
||||
{ to: "/dashboard/billing", label: "nav.billing", translatable: true, icon: Receipt },
|
||||
{ to: "/dashboard/settings", label: "nav.settings", translatable: true, icon: Settings },
|
||||
] as const;
|
||||
@@ -105,12 +111,17 @@ function NavLink({
|
||||
<Link
|
||||
to={to}
|
||||
title={collapsed ? label : undefined}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
|
||||
className={`group relative flex items-center rounded-lg text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground [&.active]:bg-accent [&.active]:text-foreground [&.active]:font-semibold ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
|
||||
activeProps={{ className: "active" }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{/* Left accent bar — only visible when active */}
|
||||
<span
|
||||
className={`absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-r-full bg-brand opacity-0 transition-opacity duration-200 group-[.active]:opacity-100 ${collapsed ? "hidden" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground transition-colors duration-200 group-hover:text-foreground group-[.active]:text-brand" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
@@ -123,9 +134,7 @@ function DashboardLayout() {
|
||||
const { user, isLoading, signOut, isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => getUserSettings().sidebarCollapsed ?? false,
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(() => getUserSettings().sidebarCollapsed ?? false);
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const theme = getUserSettings().theme ?? "dark";
|
||||
if (theme === "system") {
|
||||
@@ -158,8 +167,8 @@ function DashboardLayout() {
|
||||
<div className="flex min-h-screen">
|
||||
<div className="hidden w-64 border-r border-border bg-background p-4 lg:block">
|
||||
<Skeleton className="mb-8 h-8 w-32" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={`nav-skel-${i}`} className="mb-3 h-10 w-full" />
|
||||
{KEYS_5.map((__k) => (
|
||||
<Skeleton key={__k} className="mb-3 h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 p-6">
|
||||
@@ -281,9 +290,7 @@ function DashboardLayout() {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className={`flex-1 space-y-0.5 overflow-y-auto ${collapsed ? "p-2" : "px-3 py-2"}`}
|
||||
>
|
||||
<nav className={`flex-1 space-y-0.5 overflow-y-auto ${collapsed ? "p-2" : "px-3 py-2"}`}>
|
||||
<SidebarNav />
|
||||
</nav>
|
||||
|
||||
@@ -292,7 +299,7 @@ function DashboardLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
title={collapsed ? user.name ?? "Çıkış" : undefined}
|
||||
title={collapsed ? (user.name ?? "Çıkış") : undefined}
|
||||
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5"}`}
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
@@ -302,9 +309,7 @@ function DashboardLayout() {
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<LogOut className="size-4 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
@@ -334,9 +339,7 @@ function DashboardLayout() {
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-semibold">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sase.tr'ye hoş geldiniz 👋
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Sase.tr'ye hoş geldiniz 👋</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -378,15 +381,14 @@ function DashboardLayout() {
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
|
||||
<main id="main-content" className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-6 py-3">
|
||||
<p className="text-center text-xs text-muted-foreground/60">
|
||||
© {new Date().getFullYear()} Sase.tr | Gizlilik Politikası,
|
||||
Kullanım Koşulları
|
||||
© {new Date().getFullYear()} Sase.tr | Gizlilik Politikası, Kullanım Koşulları
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,11 +406,7 @@ function DashboardLayout() {
|
||||
<aside className="absolute left-0 top-0 flex h-full w-64 flex-col bg-background shadow-lg">
|
||||
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||
<span className="text-xl font-bold">Sase.tr</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -430,9 +428,7 @@ function DashboardLayout() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<LogOut className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Activity, CheckCircle, ChevronLeft, ChevronRight, Search, X, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { KEYS_10 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/admin/analytics")({
|
||||
component: AdminAnalyticsPage,
|
||||
});
|
||||
@@ -75,9 +68,7 @@ function AdminAnalyticsPage() {
|
||||
params.set("page", String(page));
|
||||
params.set("limit", String(limit));
|
||||
if (debouncedUserId) params.set("userId", debouncedUserId);
|
||||
return api.get<QueryLogResponse>(
|
||||
`/admin/query-logs?${params.toString()}`,
|
||||
);
|
||||
return api.get<QueryLogResponse>(`/admin/query-logs?${params.toString()}`);
|
||||
},
|
||||
enabled: user?.role === "admin",
|
||||
});
|
||||
@@ -136,8 +127,8 @@ function AdminAnalyticsPage() {
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={`log-skeleton-${i}`} className="h-12 w-full" />
|
||||
{KEYS_10.map((__k) => (
|
||||
<Skeleton key={__k} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
@@ -176,19 +167,13 @@ function AdminAnalyticsPage() {
|
||||
>
|
||||
<div className="truncate">
|
||||
<p className="truncate font-medium">{log.userName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{log.userEmail}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{log.userEmail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
{log.vin}
|
||||
</code>
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">{log.vin}</code>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<span className="text-sm">
|
||||
{log.brandName || "-"}
|
||||
</span>
|
||||
<span className="text-sm">{log.brandName || "-"}</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{log.success ? (
|
||||
@@ -214,9 +199,7 @@ function AdminAnalyticsPage() {
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(log.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{formatDate(log.createdAt)}</div>
|
||||
<div>
|
||||
{log.errorMessage && (
|
||||
<span
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
@@ -8,16 +7,11 @@ import { Badge } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X,
|
||||
Copy,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronLeft, ChevronRight, Copy, Search, TrendingUp, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { KEYS_10 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/admin/copy-logs")({
|
||||
component: AdminCopyLogsPage,
|
||||
});
|
||||
@@ -78,9 +72,7 @@ function AdminCopyLogsPage() {
|
||||
params.set("page", String(page));
|
||||
params.set("limit", String(limit));
|
||||
if (debouncedUserId) params.set("userId", debouncedUserId);
|
||||
return api.get<CopyLogResponse>(
|
||||
`/admin/copy-logs?${params.toString()}`,
|
||||
);
|
||||
return api.get<CopyLogResponse>(`/admin/copy-logs?${params.toString()}`);
|
||||
},
|
||||
enabled: user?.role === "admin" && tab === "logs",
|
||||
});
|
||||
@@ -161,8 +153,8 @@ function AdminCopyLogsPage() {
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={`copy-skel-${i}`} className="h-12 w-full" />
|
||||
{KEYS_10.map((__k) => (
|
||||
<Skeleton key={__k} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
@@ -195,9 +187,7 @@ function AdminCopyLogsPage() {
|
||||
>
|
||||
<div className="truncate">
|
||||
<p className="truncate font-medium">{log.userName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{log.userEmail}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{log.userEmail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
|
||||
@@ -258,61 +248,52 @@ function AdminCopyLogsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "top" && (
|
||||
<>
|
||||
{topLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={`top-skel-${i}`} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !topCodes || topCodes.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<TrendingUp className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-lg font-medium">Veri bulunamadi</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Son 30 gunde kopyalanan OEM kodu yok
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="overflow-x-auto p-0">
|
||||
<div className="min-w-[500px]">
|
||||
<div className="grid grid-cols-4 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>#</span>
|
||||
<span>OEM Kodu</span>
|
||||
<span className="text-center">Kopyalanma</span>
|
||||
<span className="text-center">Benzersiz Kullanici</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{topCodes.map((item, idx) => (
|
||||
<div
|
||||
key={item.oemCode}
|
||||
className="grid grid-cols-4 items-center gap-4 px-6 py-3 text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground">{idx + 1}</span>
|
||||
<div>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
|
||||
{item.oemCode}
|
||||
</code>
|
||||
</div>
|
||||
<div className="text-center font-medium">
|
||||
{item.copyCount}
|
||||
</div>
|
||||
<div className="text-center text-muted-foreground">
|
||||
{item.uniqueUsers}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tab === "top" &&
|
||||
(topLoading ? (
|
||||
<div className="space-y-3">
|
||||
{KEYS_10.map((__k) => (
|
||||
<Skeleton key={__k} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !topCodes || topCodes.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<TrendingUp className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-lg font-medium">Veri bulunamadi</p>
|
||||
<p className="text-sm text-muted-foreground">Son 30 gunde kopyalanan OEM kodu yok</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="overflow-x-auto p-0">
|
||||
<div className="min-w-[500px]">
|
||||
<div className="grid grid-cols-4 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>#</span>
|
||||
<span>OEM Kodu</span>
|
||||
<span className="text-center">Kopyalanma</span>
|
||||
<span className="text-center">Benzersiz Kullanici</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="divide-y">
|
||||
{topCodes.map((item, idx) => (
|
||||
<div
|
||||
key={item.oemCode}
|
||||
className="grid grid-cols-4 items-center gap-4 px-6 py-3 text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground">{idx + 1}</span>
|
||||
<div>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
|
||||
{item.oemCode}
|
||||
</code>
|
||||
</div>
|
||||
<div className="text-center font-medium">{item.copyCount}</div>
|
||||
<div className="text-center text-muted-foreground">{item.uniqueUsers}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { KEYS_6 } from "@/lib/keys";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
Users,
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
Search,
|
||||
UserPlus,
|
||||
Clock,
|
||||
UserCog,
|
||||
Receipt,
|
||||
Activity,
|
||||
Clock,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Receipt,
|
||||
Search,
|
||||
TrendingUp,
|
||||
UserCog,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const DailyChart = lazy(() =>
|
||||
@@ -75,8 +76,8 @@ function AdminDashboardPage() {
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={`skeleton-${i}`} className="h-32" />
|
||||
{KEYS_6.map((__k) => (
|
||||
<Skeleton key={__k} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,8 +189,8 @@ function AdminDashboardPage() {
|
||||
{/* Stat Cards */}
|
||||
{statsLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={`stat-skeleton-${i}`} className="h-32" />
|
||||
{KEYS_6.map((__k) => (
|
||||
<Skeleton key={__k} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -205,9 +206,7 @@ function AdminDashboardPage() {
|
||||
<Icon className={`h-6 w-6 ${card.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{card.label}</p>
|
||||
<p className="text-2xl font-bold">{card.value}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
Receipt,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, CheckCircle, ExternalLink, Receipt, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { KEYS_5 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/admin/payments")({
|
||||
component: AdminPaymentsPage,
|
||||
});
|
||||
@@ -57,8 +52,7 @@ function AdminPaymentsPage() {
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (paymentId: string) =>
|
||||
api.patch(`/payments/eft/${paymentId}/approve`, {}),
|
||||
mutationFn: (paymentId: string) => api.patch(`/payments/eft/${paymentId}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
|
||||
@@ -67,8 +61,7 @@ function AdminPaymentsPage() {
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (paymentId: string) =>
|
||||
api.patch(`/payments/eft/${paymentId}/reject`, {}),
|
||||
mutationFn: (paymentId: string) => api.patch(`/payments/eft/${paymentId}/reject`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
|
||||
@@ -109,27 +102,21 @@ function AdminPaymentsPage() {
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">EFT Odeme Onaylari</h2>
|
||||
<Badge variant="secondary">
|
||||
{payments?.length ?? 0} bekleyen
|
||||
</Badge>
|
||||
<Badge variant="secondary">{payments?.length ?? 0} bekleyen</Badge>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={`payment-skeleton-${i}`} className="h-32 w-full" />
|
||||
{KEYS_5.map((__k) => (
|
||||
<Skeleton key={__k} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !payments || payments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
<p className="text-lg font-medium">
|
||||
Bekleyen odeme bulunmuyor
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tum EFT odemeleri islenmis durumda
|
||||
</p>
|
||||
<p className="text-lg font-medium">Bekleyen odeme bulunmuyor</p>
|
||||
<p className="text-sm text-muted-foreground">Tum EFT odemeleri islenmis durumda</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -141,9 +128,7 @@ function AdminPaymentsPage() {
|
||||
{/* User Info */}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{payment.userName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{payment.userEmail}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{payment.userEmail}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatDate(payment.createdAt)}</span>
|
||||
<span>|</span>
|
||||
@@ -153,9 +138,7 @@ function AdminPaymentsPage() {
|
||||
|
||||
{/* Amount & Receipt */}
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency(payment.amount)}
|
||||
</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(payment.amount)}</p>
|
||||
{payment.eftReceiptUrl ? (
|
||||
<a
|
||||
href={payment.eftReceiptUrl}
|
||||
@@ -194,15 +177,8 @@ function AdminPaymentsPage() {
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={
|
||||
confirmAction.type === "approve"
|
||||
? "default"
|
||||
: "destructive"
|
||||
}
|
||||
disabled={
|
||||
approveMutation.isPending ||
|
||||
rejectMutation.isPending
|
||||
}
|
||||
variant={confirmAction.type === "approve" ? "default" : "destructive"}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirmAction.type === "approve") {
|
||||
approveMutation.mutate(payment.id);
|
||||
@@ -211,8 +187,7 @@ function AdminPaymentsPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{approveMutation.isPending ||
|
||||
rejectMutation.isPending
|
||||
{approveMutation.isPending || rejectMutation.isPending
|
||||
? "Isleniyor..."
|
||||
: "Evet, onayla"}
|
||||
</Button>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { KEYS_6 } from "@/lib/keys";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Gift,
|
||||
Search,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -180,8 +181,8 @@ function AdminReferralsPage() {
|
||||
{/* Referrers List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={`ref-skeleton-${i}`} className="h-20 w-full" />
|
||||
{KEYS_6.map((__k) => (
|
||||
<Skeleton key={__k} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
@@ -207,9 +208,7 @@ function AdminReferralsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-6 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() =>
|
||||
setExpandedReferrer(isExpanded ? null : referrer.referrerId)
|
||||
}
|
||||
onClick={() => setExpandedReferrer(isExpanded ? null : referrer.referrerId)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary font-bold">
|
||||
@@ -217,9 +216,7 @@ function AdminReferralsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{referrer.referrerName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{referrer.referrerEmail}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{referrer.referrerEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -252,9 +249,7 @@ function AdminReferralsPage() {
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{ref.referredName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ref.referredEmail}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{ref.referredEmail}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ref.rewardApplied && (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
@@ -16,17 +17,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@sase/ui";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
ArrowLeft,
|
||||
X,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Eye, Search, UserPlus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/admin/users")({
|
||||
@@ -185,11 +178,7 @@ function AdminUsersPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedUserId(null)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedUserId(null)}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Geri
|
||||
</Button>
|
||||
@@ -245,15 +234,11 @@ function AdminUsersPage() {
|
||||
{/* Subscriptions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Abonelikler ({userDetail.subscriptions.length})
|
||||
</CardTitle>
|
||||
<CardTitle>Abonelikler ({userDetail.subscriptions.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{userDetail.subscriptions.length === 0 ? (
|
||||
<p className="py-4 text-center text-muted-foreground">
|
||||
Abonelik bulunmuyor
|
||||
</p>
|
||||
<p className="py-4 text-center text-muted-foreground">Abonelik bulunmuyor</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{userDetail.subscriptions.map((sub) => (
|
||||
@@ -279,13 +264,11 @@ function AdminUsersPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sub.startDate ? formatDate(sub.startDate) : "-"}{" "}
|
||||
- {sub.endDate ? formatDate(sub.endDate) : "-"}
|
||||
{sub.startDate ? formatDate(sub.startDate) : "-"} -{" "}
|
||||
{sub.endDate ? formatDate(sub.endDate) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(sub.createdAt)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(sub.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -296,15 +279,11 @@ function AdminUsersPage() {
|
||||
{/* Payments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Odemeler ({userDetail.payments.length})
|
||||
</CardTitle>
|
||||
<CardTitle>Odemeler ({userDetail.payments.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{userDetail.payments.length === 0 ? (
|
||||
<p className="py-4 text-center text-muted-foreground">
|
||||
Odeme bulunmuyor
|
||||
</p>
|
||||
<p className="py-4 text-center text-muted-foreground">Odeme bulunmuyor</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{userDetail.payments.map((payment) => (
|
||||
@@ -324,14 +303,10 @@ function AdminUsersPage() {
|
||||
>
|
||||
{payment.status}
|
||||
</Badge>
|
||||
<span className="text-sm">
|
||||
{payment.method.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm">{payment.method.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">
|
||||
{formatCurrency(payment.amount)}
|
||||
</p>
|
||||
<p className="font-medium">{formatCurrency(payment.amount)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(payment.createdAt)}
|
||||
</p>
|
||||
@@ -369,9 +344,7 @@ function AdminUsersPage() {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Yeni Kullanici Olustur</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sisteme yeni bir kullanici ekleyin.
|
||||
</DialogDescription>
|
||||
<DialogDescription>Sisteme yeni bir kullanici ekleyin.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@@ -426,10 +399,7 @@ function AdminUsersPage() {
|
||||
</select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createUserMutation.isPending}
|
||||
>
|
||||
<Button type="submit" disabled={createUserMutation.isPending}>
|
||||
{createUserMutation.isPending ? "Olusturuluyor..." : "Olustur"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -462,8 +432,8 @@ function AdminUsersPage() {
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`row-skeleton-${i}`} className="h-14 w-full" />
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
@@ -491,7 +461,16 @@ function AdminUsersPage() {
|
||||
<div
|
||||
key={u.id}
|
||||
className="grid cursor-pointer items-center gap-4 px-6 py-4 transition-colors hover:bg-muted/50 lg:grid-cols-7"
|
||||
// biome-ignore lint/a11y/useSemanticElements: needs grid layout that <button> cannot host
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedUserId(u.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedUserId(u.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{u.name}</p>
|
||||
@@ -500,9 +479,7 @@ function AdminUsersPage() {
|
||||
<p className="text-sm text-muted-foreground">{u.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant={roleVariants[u.role] || "secondary"}>
|
||||
{u.role}
|
||||
</Badge>
|
||||
<Badge variant={roleVariants[u.role] || "secondary"}>{u.role}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant={subStatusVariants[u.subscriptionStatus] || "outline"}>
|
||||
@@ -510,9 +487,7 @@ function AdminUsersPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(u.createdAt)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{formatDate(u.createdAt)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
@@ -7,6 +6,7 @@ import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Separator } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Download, Filter } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui";
|
||||
import { Clock, Library, Lock } from "lucide-react";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Skeleton, cn } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Columns2, LayoutGrid, Library, List, Lock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { KEYS_10 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/catalog/")({
|
||||
component: CatalogBrandsPage,
|
||||
});
|
||||
@@ -18,156 +22,124 @@ interface CatalogBrand {
|
||||
hasAccess: boolean;
|
||||
}
|
||||
|
||||
interface EmexBrand {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
code: string | null;
|
||||
brandName: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface PcatCatalog {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
imgUrl: string | null;
|
||||
modelsCount: number;
|
||||
carsCount: number;
|
||||
}
|
||||
|
||||
function CatalogBrandsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState("pl24");
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().brandViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("brandViewMode", mode);
|
||||
};
|
||||
|
||||
const { data: brands, isLoading } = useQuery({
|
||||
queryKey: ["catalog-brands"],
|
||||
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
|
||||
});
|
||||
|
||||
const { data: pcatCatalogs, isLoading: pcatLoading } = useQuery({
|
||||
queryKey: ["pcat-catalogs"],
|
||||
queryFn: () => api.get<PcatCatalog[]>("/catalog/pcat/catalogs"),
|
||||
enabled: tab === "pcat",
|
||||
});
|
||||
|
||||
const { data: emexBrands, isLoading: emexLoading } = useQuery({
|
||||
queryKey: ["emex-brands"],
|
||||
queryFn: () => api.get<EmexBrand[]>("/catalog/emex/brands"),
|
||||
enabled: tab === "emex",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Görünüm modu"
|
||||
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
|
||||
>
|
||||
{[
|
||||
{ mode: "grid" as const, Icon: LayoutGrid, label: "Izgara" },
|
||||
{ mode: "tree" as const, Icon: List, label: "Liste" },
|
||||
{ mode: "columns" as const, Icon: Columns2, label: "Sütun" },
|
||||
].map(({ mode, Icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === mode}
|
||||
onClick={() => changeViewMode(mode)}
|
||||
className={cn(
|
||||
"inline-flex size-7 items-center justify-center rounded-md transition-all duration-200",
|
||||
viewMode === mode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="sasetr">{t("catalog.tabSasetr")}</TabsTrigger>
|
||||
<TabsTrigger value="pl24">{t("catalog.tabPl24")}</TabsTrigger>
|
||||
<TabsTrigger value="pcat">{t("catalog.tabPcat")}</TabsTrigger>
|
||||
<TabsTrigger value="emex">{t("catalog.tabEmex")}</TabsTrigger>
|
||||
<TabsTrigger value="tecdoc">{t("catalog.tabTecdoc")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="sasetr" className="mt-4">
|
||||
<ComingSoonPlaceholder />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pl24" className="mt-4">
|
||||
{isLoading ? (
|
||||
<BrandGridSkeleton />
|
||||
) : !brands || brands.length === 0 ? (
|
||||
<EmptyBrands message={t("catalog.noBrands")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{brands.map((brand) => (
|
||||
<PL24BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pcat" className="mt-4">
|
||||
{pcatLoading ? (
|
||||
<BrandGridSkeleton />
|
||||
) : !pcatCatalogs || pcatCatalogs.length === 0 ? (
|
||||
<EmptyBrands message={t("catalog.noBrands")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{pcatCatalogs.map((cat) => (
|
||||
<PcatCatalogCard key={cat.id} catalog={cat} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="emex" className="mt-4">
|
||||
{emexLoading ? (
|
||||
<BrandGridSkeleton />
|
||||
) : !emexBrands || emexBrands.length === 0 ? (
|
||||
<EmptyBrands message={t("catalog.noBrands")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{emexBrands.map((brand) => (
|
||||
<EmexBrandCard key={brand.catalogId} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="tecdoc" className="mt-4">
|
||||
<ComingSoonPlaceholder />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{KEYS_10.map((__k) => (
|
||||
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : !brands || brands.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{brands.map((brand) => (
|
||||
<BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "tree" ? (
|
||||
<BrandListTree brands={brands} />
|
||||
) : (
|
||||
<BrandListColumns brands={brands} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComingSoonPlaceholder() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Clock className="mb-4 size-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">{t("catalog.comingSoon")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* ── Grid card (existing) ── */
|
||||
|
||||
function BrandGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={`brand-skel-${i}`} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBrands({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!brand.hasAccess) {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center rounded-xl border border-border/50 bg-muted/30 p-4 text-center opacity-60 select-none">
|
||||
<Lock className="mb-2 size-5 text-muted-foreground" />
|
||||
<p className="text-sm font-semibold text-foreground">{brand.brandName}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("catalog.locked")}</p>
|
||||
<div className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center select-none">
|
||||
{/* Diagonal stripe overlay for locked feel */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 8px)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative mb-2">
|
||||
<CarBrandLogo
|
||||
brandName={brand.brandName}
|
||||
logoUrl={brand.logoUrl}
|
||||
size={40}
|
||||
className="grayscale opacity-70"
|
||||
/>
|
||||
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-foreground">
|
||||
<Lock className="size-2.5 text-background" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="relative text-sm font-semibold text-foreground/70">{brand.brandName}</p>
|
||||
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
|
||||
{t("catalog.locked")}
|
||||
</p>
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
className="relative mt-2 inline-flex items-center gap-1 rounded-full text-xs font-medium text-brand transition-colors hover:text-brand/80"
|
||||
>
|
||||
{t("catalog.upgradeCta")}
|
||||
<ChevronRight className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -178,61 +150,122 @@ function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
||||
search={{ catalog: undefined }}
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]"
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={brand.brandName}
|
||||
className="mb-2 h-10 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<Library className="size-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<CarBrandLogo
|
||||
brandName={brand.brandName}
|
||||
logoUrl={brand.logoUrl}
|
||||
size={40}
|
||||
className="mb-2 transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
<p className="text-sm font-semibold">{brand.brandName}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PcatCatalogCard({ catalog }: { catalog: PcatCatalog }) {
|
||||
const imgSrc = catalog.imgUrl?.startsWith("//") ? `https:${catalog.imgUrl}` : catalog.imgUrl;
|
||||
/* ── Tree (flat list) ── */
|
||||
|
||||
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/pcat/$catalogId"
|
||||
params={{ catalogId: catalog.id }}
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
{imgSrc ? (
|
||||
<img src={imgSrc} alt={catalog.name} className="mb-2 h-10 w-auto object-contain" />
|
||||
) : (
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<Library className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-semibold">{catalog.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{catalog.modelsCount} model
|
||||
</p>
|
||||
</Link>
|
||||
<div className="divide-y rounded-lg border">
|
||||
{brands.map((brand) => {
|
||||
if (!brand.hasAccess) {
|
||||
return (
|
||||
<div key={brand.brandName} className="flex items-center gap-3 px-4 py-3 opacity-50">
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
||||
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
||||
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={brand.brandName}
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
||||
search={{ catalog: undefined }}
|
||||
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
||||
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmexBrandCard({ brand }: { brand: EmexBrand }) {
|
||||
/* ── Columns (left: brand list, right: detail + CTA) ── */
|
||||
|
||||
function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/emex/$catalogCode"
|
||||
params={{ catalogCode: brand.catalogId }}
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-orange-500/10">
|
||||
<Library className="size-5 text-orange-500" />
|
||||
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
|
||||
{/* Left panel */}
|
||||
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
|
||||
{brands.map((brand) => (
|
||||
<button
|
||||
key={brand.brandName}
|
||||
type="button"
|
||||
onClick={() => setSelectedName(brand.brandName)}
|
||||
disabled={!brand.hasAccess}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
|
||||
brand.hasAccess ? "hover:bg-accent" : "opacity-50 cursor-not-allowed",
|
||||
selectedName === brand.brandName && "bg-accent font-medium",
|
||||
)}
|
||||
>
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={20} />
|
||||
<span className="flex-1 truncate">{brand.brandName}</span>
|
||||
{brand.hasAccess ? (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Lock className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm font-semibold">{brand.brandName}</p>
|
||||
{brand.description && brand.description !== brand.brandName && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{brand.description}</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
{selected ? (
|
||||
<div className="space-y-4">
|
||||
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
|
||||
<p className="text-lg font-semibold">{selected.brandName}</p>
|
||||
{selected.hasAccess ? (
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
params: { brandName: encodeURIComponent(selected.brandName) },
|
||||
search: { catalog: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
Modellere Git
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Soldan bir marka seçin</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ModelListColumns } from "@/components/catalog/model-list-columns";
|
||||
import { ModelListTree } from "@/components/catalog/model-list-tree";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { KEYS_4, KEYS_9 } from "@/lib/keys";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Car,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -27,6 +41,7 @@ interface CatalogVehicle {
|
||||
bodyType: string | null;
|
||||
transmission: string | null;
|
||||
architecture: string | null;
|
||||
catalogPath: string | null;
|
||||
}
|
||||
|
||||
function CatalogModelsPage() {
|
||||
@@ -37,6 +52,15 @@ function CatalogModelsPage() {
|
||||
|
||||
const decodedBrandName = decodeURIComponent(brandName);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().modelViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("modelViewMode", mode);
|
||||
};
|
||||
|
||||
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
|
||||
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
|
||||
queryKey: ["catalog-catalogs", decodedBrandName],
|
||||
@@ -88,11 +112,7 @@ function CatalogModelsPage() {
|
||||
{" / "}
|
||||
{isMultiCatalog && activeCatalog ? (
|
||||
<>
|
||||
<Link
|
||||
to="."
|
||||
search={{ catalog: undefined }}
|
||||
className="hover:underline"
|
||||
>
|
||||
<Link to="." search={{ catalog: undefined }} className="hover:underline">
|
||||
{decodedBrandName}
|
||||
</Link>
|
||||
{" / "}
|
||||
@@ -110,17 +130,13 @@ function CatalogModelsPage() {
|
||||
|
||||
{catalogsLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={`cat-skel-${i}`} className="h-24 w-full rounded-xl" />
|
||||
{KEYS_4.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : isMultiCatalog && !activeCatalog ? (
|
||||
) : isMultiCatalog && !activeCatalog && catalogs ? (
|
||||
// Sub-catalog selector
|
||||
<CatalogSelector
|
||||
catalogs={catalogs!}
|
||||
brandName={brandName}
|
||||
brandLabel={decodedBrandName}
|
||||
/>
|
||||
<CatalogSelector catalogs={catalogs} brandName={brandName} brandLabel={decodedBrandName} />
|
||||
) : modelsLoading ? (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -128,8 +144,8 @@ function CatalogModelsPage() {
|
||||
{t("catalog.loadingModels")}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<Skeleton key={`model-skel-${i}`} className="h-24 w-full rounded-lg" />
|
||||
{KEYS_9.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,10 +155,57 @@ function CatalogModelsPage() {
|
||||
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.map((model) => (
|
||||
<ModelCard key={model.id} model={model} brandName={brandName} />
|
||||
))}
|
||||
<div className="space-y-3">
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Liste"
|
||||
>
|
||||
<List className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === "grid" ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.map((model) => (
|
||||
<ModelCard key={model.id} model={model} brandName={brandName} />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "tree" ? (
|
||||
<ModelListTree models={models} brandName={brandName} />
|
||||
) : (
|
||||
<ModelListColumns models={models} brandName={brandName} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -196,7 +259,7 @@ function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: str
|
||||
<Link
|
||||
to="/dashboard/catalog/$brandName/$modelId"
|
||||
params={{ brandName, modelId: model.id }}
|
||||
search={{ body: undefined, engine: undefined, gearbox: undefined }}
|
||||
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
|
||||
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
<p className="font-semibold">{model.model}</p>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
const SchemaViewer = lazy(() =>
|
||||
import("@/components/schema/schema-viewer").then((mod) => ({
|
||||
default: mod.SchemaViewer,
|
||||
@@ -21,8 +25,8 @@ function SchemaViewerFallback() {
|
||||
</div>
|
||||
<div className="w-full space-y-3 p-4 md:w-[40%]">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,6 +40,7 @@ export const Route = createFileRoute(
|
||||
body: typeof search.body === "string" ? search.body : undefined,
|
||||
engine: typeof search.engine === "string" ? search.engine : undefined,
|
||||
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
|
||||
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
|
||||
}),
|
||||
component: CatalogCategoryPage,
|
||||
});
|
||||
@@ -59,7 +64,18 @@ function CatalogCategoryPage() {
|
||||
const engine = search.engine;
|
||||
const gearbox = search.gearbox;
|
||||
|
||||
const variantSearch = body || engine || gearbox ? { body, engine, gearbox } : undefined;
|
||||
const mgp = search.mgp;
|
||||
const variantSearch =
|
||||
body || engine || gearbox || mgp ? { body, engine, gearbox, mgp } : undefined;
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox],
|
||||
@@ -77,13 +93,23 @@ function CatalogCategoryPage() {
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
||||
params: { brandName, modelId, categoryId: data.parentId },
|
||||
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
|
||||
search: variantSearch ?? {
|
||||
body: undefined,
|
||||
engine: undefined,
|
||||
gearbox: undefined,
|
||||
mgp: undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId",
|
||||
params: { brandName, modelId },
|
||||
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
|
||||
search: variantSearch ?? {
|
||||
body: undefined,
|
||||
engine: undefined,
|
||||
gearbox: undefined,
|
||||
mgp: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -111,30 +137,89 @@ function CatalogCategoryPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Agac"
|
||||
>
|
||||
<List className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{hasChildren ? (
|
||||
<CategoryGrid
|
||||
categories={data.children}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
parentId={categoryId}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
viewMode === "grid" ? (
|
||||
<CategoryGrid
|
||||
categories={data.children}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
parentId={categoryId}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree
|
||||
categories={data.children}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : (
|
||||
<CategoryColumns
|
||||
categories={data.children}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Suspense fallback={<SchemaViewerFallback />}>
|
||||
<SchemaViewer
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
|
||||
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
|
||||
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector";
|
||||
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
|
||||
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
|
||||
validateSearch: (search) => ({
|
||||
body: typeof search.body === "string" ? search.body : undefined,
|
||||
engine: typeof search.engine === "string" ? search.engine : undefined,
|
||||
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
|
||||
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
|
||||
}),
|
||||
component: CatalogVehiclePage,
|
||||
});
|
||||
@@ -38,15 +42,16 @@ function CatalogVehiclePage() {
|
||||
const body = search.body;
|
||||
const engine = search.engine;
|
||||
const gearbox = search.gearbox;
|
||||
const hasVariant = !!(body || engine || gearbox);
|
||||
const mgp = search.mgp;
|
||||
const hasVariant = !!(body || engine || gearbox || mgp);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree">(
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const decodedBrandName = decodeURIComponent(brandName);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree") => {
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
@@ -58,24 +63,46 @@ function CatalogVehiclePage() {
|
||||
});
|
||||
|
||||
const isPsa = vehicle?.architecture === "LEGACY_PSA";
|
||||
const isP4Legacy = [
|
||||
"LEGACY_FORD",
|
||||
"LEGACY_VOLVO",
|
||||
].includes(vehicle?.architecture);
|
||||
const isP4Legacy = ["LEGACY_FORD", "LEGACY_VOLVO"].includes(vehicle?.architecture);
|
||||
const isP5WithRestrictions =
|
||||
vehicle?.architecture === "P5_MODERN" &&
|
||||
!!vehicle?.catalogPath &&
|
||||
!vehicle.catalogPath.includes("/mainGroup");
|
||||
const showPsaVariantSelector = isPsa && !hasVariant;
|
||||
const showFordVariantSelector = isP4Legacy && !hasVariant;
|
||||
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector;
|
||||
const showP5RestrictionSelector = isP5WithRestrictions && !hasVariant;
|
||||
const showVariantSelector =
|
||||
showPsaVariantSelector || showFordVariantSelector || showP5RestrictionSelector;
|
||||
|
||||
const variantSearch = hasVariant ? { body, engine, gearbox } : undefined;
|
||||
const variantSearch = hasVariant ? { body, engine, gearbox, mgp } : undefined;
|
||||
|
||||
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
|
||||
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox],
|
||||
queryFn: () =>
|
||||
api.get<any[]>(`/catalog/vehicles/${modelId}/categories${buildVariantQuery(body, engine, gearbox)}`),
|
||||
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox, mgp],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (body) params.set("body", body);
|
||||
if (engine) params.set("engine", engine);
|
||||
if (gearbox) params.set("gearbox", gearbox);
|
||||
if (mgp) params.set("mgp", mgp);
|
||||
const qs = params.toString();
|
||||
return api.get<any[]>(`/catalog/vehicles/${modelId}/categories${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
enabled: !!modelId && !vehicleLoading && !showVariantSelector,
|
||||
});
|
||||
|
||||
const handleVariantSelect = (selectedBody: string, selectedEngine: string, selectedGearbox: string) => {
|
||||
const handleP5RestrictionComplete = (mainGroupsPath: string) => {
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId",
|
||||
params: { brandName, modelId },
|
||||
search: { mgp: mainGroupsPath, body: undefined, engine: undefined, gearbox: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
const handleVariantSelect = (
|
||||
selectedBody: string,
|
||||
selectedEngine: string,
|
||||
selectedGearbox: string,
|
||||
) => {
|
||||
const norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined);
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId",
|
||||
@@ -87,6 +114,7 @@ function CatalogVehiclePage() {
|
||||
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
|
||||
engine: norm(selectedEngine),
|
||||
gearbox: norm(selectedGearbox),
|
||||
mgp: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -103,56 +131,96 @@ function CatalogVehiclePage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header / Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
params: { brandName },
|
||||
search: { catalog: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
</Link>
|
||||
{" / "}
|
||||
<Link
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName }}
|
||||
search={{ catalog: undefined }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{decodedBrandName}
|
||||
</Link>
|
||||
{" / "}
|
||||
<span className="font-medium text-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
params: { brandName },
|
||||
search: { catalog: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
</Link>
|
||||
{" / "}
|
||||
<Link
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName }}
|
||||
search={{ catalog: undefined }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{decodedBrandName}
|
||||
</Link>
|
||||
{" / "}
|
||||
<span className="font-medium text-foreground">{vehicle?.model}</span>
|
||||
{hasVariant && (
|
||||
<>
|
||||
{body && body !== "_all_" && (
|
||||
<>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="font-medium text-foreground">{body}</span>
|
||||
</>
|
||||
)}
|
||||
{engine && engine !== "_all_" && (
|
||||
<>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="font-medium text-foreground">{engine}</span>
|
||||
</>
|
||||
)}
|
||||
{gearbox && gearbox !== "_all_" && (
|
||||
<>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="font-medium text-foreground">{gearbox}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{vehicle?.model}
|
||||
</span>
|
||||
{hasVariant && (
|
||||
<>
|
||||
{body && body !== "_all_" && (
|
||||
<><span className="mx-1">/</span><span className="font-medium text-foreground">{body}</span></>
|
||||
)}
|
||||
{engine && engine !== "_all_" && (
|
||||
<><span className="mx-1">/</span><span className="font-medium text-foreground">{engine}</span></>
|
||||
)}
|
||||
{gearbox && gearbox !== "_all_" && (
|
||||
<><span className="mx-1">/</span><span className="font-medium text-foreground">{gearbox}</span></>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{vehicle?.year && (
|
||||
<span className="ml-2 text-base font-normal text-muted-foreground">
|
||||
({vehicle.year})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{vehicle?.model}
|
||||
{vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>}
|
||||
</h1>
|
||||
</div>
|
||||
{/* View toggle — always visible */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title="Agac"
|
||||
>
|
||||
<List className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={`rounded p-1.5 ${viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,32 +266,20 @@ function CatalogVehiclePage() {
|
||||
<PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
|
||||
) : showFordVariantSelector ? (
|
||||
<FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
|
||||
) : showP5RestrictionSelector ? (
|
||||
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
<List className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}
|
||||
>
|
||||
{categoriesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`cat-skel-${i}`} className="h-8 w-full" />
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
@@ -234,7 +290,7 @@ function CatalogVehiclePage() {
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : (
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
@@ -242,6 +298,14 @@ function CatalogVehiclePage() {
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : (
|
||||
<CategoryColumns
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Car,
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertCircle, ArrowLeft, Car, ChevronRight, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")(
|
||||
{
|
||||
component: EmexVehicleListPage,
|
||||
},
|
||||
);
|
||||
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
|
||||
component: EmexVehicleListPage,
|
||||
});
|
||||
|
||||
// ── Types ────────────────────────────────────────────
|
||||
|
||||
@@ -70,18 +62,12 @@ function EmexVehicleListPage() {
|
||||
});
|
||||
|
||||
// Parse wizard state
|
||||
const determined = useMemo(
|
||||
() => wizardRows?.filter((r) => r.determined) ?? [],
|
||||
[wizardRows],
|
||||
);
|
||||
const determined = useMemo(() => wizardRows?.filter((r) => r.determined) ?? [], [wizardRows]);
|
||||
const undetermined = useMemo(
|
||||
() =>
|
||||
wizardRows?.filter((r) => !r.determined && r.options?.length > 0) ?? [],
|
||||
() => wizardRows?.filter((r) => !r.determined && r.options?.length > 0) ?? [],
|
||||
[wizardRows],
|
||||
);
|
||||
const allDetermined = wizardRows
|
||||
? wizardRows.length > 0 && undetermined.length === 0
|
||||
: false;
|
||||
const allDetermined = wizardRows ? wizardRows.length > 0 && undetermined.length === 0 : false;
|
||||
|
||||
// Get the "Sales Designation" and "Model" from determined params
|
||||
const wizardMatch = useMemo(() => {
|
||||
@@ -89,12 +75,7 @@ function EmexVehicleListPage() {
|
||||
let salesDesignation: string | null = null;
|
||||
let model: string | null = null;
|
||||
|
||||
for (const key of [
|
||||
"Sales Designation",
|
||||
"Name",
|
||||
"Modification",
|
||||
"Model name",
|
||||
]) {
|
||||
for (const key of ["Sales Designation", "Name", "Modification", "Model name"]) {
|
||||
const row = determined.find((r) => r.name === key);
|
||||
if (row?.value && row.value !== "None") {
|
||||
salesDesignation = row.value;
|
||||
@@ -113,15 +94,10 @@ function EmexVehicleListPage() {
|
||||
|
||||
// When all wizard params are determined, search DB for matching vehicles
|
||||
const { data: matchedVehicles, isLoading: matchLoading } = useQuery({
|
||||
queryKey: [
|
||||
"emex-wizard-vehicles",
|
||||
catalogCode,
|
||||
wizardMatch?.name,
|
||||
wizardMatch?.model,
|
||||
],
|
||||
queryKey: ["emex-wizard-vehicles", catalogCode, wizardMatch?.name, wizardMatch?.model],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ name: wizardMatch!.name });
|
||||
if (wizardMatch!.model) params.set("model", wizardMatch!.model);
|
||||
const params = new URLSearchParams({ name: wizardMatch?.name ?? "" });
|
||||
if (wizardMatch?.model) params.set("model", wizardMatch?.model);
|
||||
return api.get<EmexVehicle[]>(
|
||||
`/catalog/emex/brands/${catalogCode}/wizard-vehicles?${params}`,
|
||||
);
|
||||
@@ -149,9 +125,7 @@ function EmexVehicleListPage() {
|
||||
{t("catalog.backToBrands")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">
|
||||
{decodeURIComponent(catalogCode)}
|
||||
</h1>
|
||||
<h1 className="text-xl font-bold">{decodeURIComponent(catalogCode)}</h1>
|
||||
{determined.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="mr-1 size-3.5" />
|
||||
@@ -168,8 +142,7 @@ function EmexVehicleListPage() {
|
||||
key={r.name}
|
||||
className="rounded-md border border-border bg-muted/50 px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground">{r.name}:</span>{" "}
|
||||
{r.value}
|
||||
<span className="text-muted-foreground">{r.name}:</span> {r.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -208,15 +181,9 @@ function EmexVehicleListPage() {
|
||||
</div>
|
||||
) : matchedVehicles && matchedVehicles.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{matchedVehicles.length} varyant
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{matchedVehicles.length} varyant</p>
|
||||
{matchedVehicles.map((v) => (
|
||||
<VehicleRow
|
||||
key={v.id}
|
||||
vehicle={v}
|
||||
catalogCode={catalogCode}
|
||||
/>
|
||||
<VehicleRow key={v.id} vehicle={v} catalogCode={catalogCode} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -234,14 +201,9 @@ function EmexVehicleListPage() {
|
||||
)}
|
||||
|
||||
{/* Empty state: no wizard rows at all */}
|
||||
{!wizardLoading &&
|
||||
!wizardError &&
|
||||
wizardRows &&
|
||||
wizardRows.length === 0 && (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noModels")}
|
||||
</p>
|
||||
)}
|
||||
{!wizardLoading && !wizardError && wizardRows && wizardRows.length === 0 && (
|
||||
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -267,9 +229,7 @@ function WizardStep({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium">{row.name}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.options.length} seçenek
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{row.options.length} seçenek</span>
|
||||
</div>
|
||||
|
||||
{row.options.length > 10 && (
|
||||
@@ -295,9 +255,7 @@ function WizardStep({
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Sonuç bulunamadı
|
||||
</p>
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">Sonuç bulunamadı</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,14 +277,10 @@ function VehicleRow({
|
||||
parts.push(vehicle.optionsRaw);
|
||||
}
|
||||
const raw = vehicle.optionsRaw?.toLowerCase() || "";
|
||||
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase()))
|
||||
parts.push(vehicle.engine);
|
||||
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase())) parts.push(vehicle.engine);
|
||||
if (vehicle.bodyType && !raw.includes(vehicle.bodyType.toLowerCase()))
|
||||
parts.push(vehicle.bodyType);
|
||||
if (
|
||||
vehicle.transmission &&
|
||||
!raw.includes(vehicle.transmission.toLowerCase())
|
||||
)
|
||||
if (vehicle.transmission && !raw.includes(vehicle.transmission.toLowerCase()))
|
||||
parts.push(vehicle.transmission);
|
||||
if (vehicle.driveType && !raw.includes(vehicle.driveType.toLowerCase()))
|
||||
parts.push(vehicle.driveType);
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { EMEX_GROUP_HIERARCHY } from "@/lib/emex-group-hierarchy";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, FolderOpen, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId/",
|
||||
)({
|
||||
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode_/$vehicleId/")({
|
||||
component: EmexGroupListPage,
|
||||
});
|
||||
|
||||
@@ -74,19 +72,14 @@ function EmexGroupListPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/dashboard/catalog/emex/$catalogCode"
|
||||
params={{ catalogCode }}
|
||||
>
|
||||
<Link to="/dashboard/catalog/emex/$catalogCode" params={{ catalogCode }}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToModels")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{t("catalog.categories")}</h1>
|
||||
{groups && (
|
||||
<span className="text-sm text-muted-foreground">({groups.length})</span>
|
||||
)}
|
||||
{groups && <span className="text-sm text-muted-foreground">({groups.length})</span>}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -96,9 +89,7 @@ function EmexGroupListPage() {
|
||||
))}
|
||||
</div>
|
||||
) : !groups || groups.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noCategories")}
|
||||
</p>
|
||||
<p className="py-8 text-center text-muted-foreground">{t("catalog.noCategories")}</p>
|
||||
) : isFlat ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
@@ -195,8 +186,5 @@ function GroupLink({
|
||||
}
|
||||
|
||||
function countGroups(node: TreeNode): number {
|
||||
return (
|
||||
node.groups.length +
|
||||
node.children.reduce((sum, c) => sum + countGroups(c), 0)
|
||||
);
|
||||
return node.groups.length + node.children.reduce((sum, c) => sum + countGroups(c), 0);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { lazy, Suspense, useState, useEffect } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Part, SchemaPic } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { Part, SchemaPic } from "@/hooks/use-parts";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
|
||||
const SchemaViewer = lazy(() =>
|
||||
import("@/components/schema/schema-viewer").then((mod) => ({
|
||||
@@ -38,8 +38,7 @@ function EmexGroupPartsPage() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["emex-group-parts", vehicleId, groupId],
|
||||
queryFn: () =>
|
||||
api.get<EmexGroupParts>(`/catalog/emex/vehicles/${vehicleId}/groups/${groupId}`),
|
||||
queryFn: () => api.get<EmexGroupParts>(`/catalog/emex/vehicles/${vehicleId}/groups/${groupId}`),
|
||||
});
|
||||
|
||||
// Reset schema store and image index on group change
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Library } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/pcat/$catalogId/",
|
||||
)({
|
||||
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId/")({
|
||||
component: PcatModelsPage,
|
||||
});
|
||||
|
||||
@@ -27,8 +25,7 @@ function PcatModelsPage() {
|
||||
|
||||
const { data: models, isLoading } = useQuery({
|
||||
queryKey: ["pcat-models", catalogId],
|
||||
queryFn: () =>
|
||||
api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
|
||||
queryFn: () => api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -50,9 +47,7 @@ function PcatModelsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : !models || models.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noModels")}
|
||||
</p>
|
||||
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{models.map((model) => (
|
||||
@@ -64,11 +59,7 @@ function PcatModelsPage() {
|
||||
>
|
||||
{model.imgUrl ? (
|
||||
<img
|
||||
src={
|
||||
model.imgUrl.startsWith("//")
|
||||
? `https:${model.imgUrl}`
|
||||
: model.imgUrl
|
||||
}
|
||||
src={model.imgUrl.startsWith("//") ? `https:${model.imgUrl}` : model.imgUrl}
|
||||
alt={model.name}
|
||||
className="mb-2 h-16 w-auto object-contain"
|
||||
/>
|
||||
@@ -84,9 +75,7 @@ function PcatModelsPage() {
|
||||
</p>
|
||||
)}
|
||||
{model.carsCount > 0 && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{model.carsCount} araç
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{model.carsCount} araç</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Car, ChevronRight } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/pcat/$catalogId_/$modelId/",
|
||||
)({
|
||||
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId/")({
|
||||
component: PcatCarsPage,
|
||||
});
|
||||
|
||||
@@ -35,10 +33,7 @@ function PcatCarsPage() {
|
||||
|
||||
const { data: cars, isLoading } = useQuery({
|
||||
queryKey: ["pcat-cars", catalogId, modelId],
|
||||
queryFn: () =>
|
||||
api.get<PcatCar[]>(
|
||||
`/catalog/pcat/catalogs/${catalogId}/models/${modelId}/cars`,
|
||||
),
|
||||
queryFn: () => api.get<PcatCar[]>(`/catalog/pcat/catalogs/${catalogId}/models/${modelId}/cars`),
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -56,10 +51,7 @@ function PcatCarsPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/dashboard/catalog/pcat/$catalogId"
|
||||
params={{ catalogId }}
|
||||
>
|
||||
<Link to="/dashboard/catalog/pcat/$catalogId" params={{ catalogId }}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToModels")}
|
||||
@@ -75,9 +67,7 @@ function PcatCarsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : !cars || cars.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noModels")}
|
||||
</p>
|
||||
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
) : (
|
||||
<>
|
||||
{cars.length > 10 && (
|
||||
@@ -90,9 +80,7 @@ function PcatCarsPage() {
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filtered.length} araç
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{filtered.length} araç</p>
|
||||
{filtered.map((car) => (
|
||||
<Link
|
||||
key={car.id}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronRight, FolderOpen } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/",
|
||||
)({
|
||||
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/")({
|
||||
component: PcatCarGroupsPage,
|
||||
});
|
||||
|
||||
@@ -26,23 +24,15 @@ function PcatCarGroupsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogId, modelId, carId } = Route.useParams();
|
||||
|
||||
const [parentStack, setParentStack] = useState<
|
||||
{ id: string; name: string }[]
|
||||
>([]);
|
||||
const [parentStack, setParentStack] = useState<{ id: string; name: string }[]>([]);
|
||||
const currentParentId =
|
||||
parentStack.length > 0
|
||||
? parentStack[parentStack.length - 1].id
|
||||
: undefined;
|
||||
parentStack.length > 0 ? parentStack[parentStack.length - 1].id : undefined;
|
||||
|
||||
const { data: groups, isLoading } = useQuery({
|
||||
queryKey: ["pcat-car-groups", carId, currentParentId || "root"],
|
||||
queryFn: () => {
|
||||
const params = currentParentId
|
||||
? `?parentId=${encodeURIComponent(currentParentId)}`
|
||||
: "";
|
||||
return api.get<PcatGroup[]>(
|
||||
`/catalog/pcat/cars/${carId}/groups${params}`,
|
||||
);
|
||||
const params = currentParentId ? `?parentId=${encodeURIComponent(currentParentId)}` : "";
|
||||
return api.get<PcatGroup[]>(`/catalog/pcat/cars/${carId}/groups${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,10 +55,7 @@ function PcatCarGroupsPage() {
|
||||
{t("catalog.backToCategories")}
|
||||
</Button>
|
||||
) : (
|
||||
<Link
|
||||
to="/dashboard/catalog/pcat/$catalogId/$modelId"
|
||||
params={{ catalogId, modelId }}
|
||||
>
|
||||
<Link to="/dashboard/catalog/pcat/$catalogId/$modelId" params={{ catalogId, modelId }}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToModels")}
|
||||
@@ -93,9 +80,7 @@ function PcatCarGroupsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : !groups || groups.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noCategories")}
|
||||
</p>
|
||||
<p className="py-8 text-center text-muted-foreground">{t("catalog.noCategories")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{groups.map((group) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { lazy, Suspense, useState, useEffect } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Hotspot, Part, SchemaPic } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { Part, SchemaPic, Hotspot } from "@/hooks/use-parts";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
|
||||
const SchemaViewer = lazy(() =>
|
||||
import("@/components/schema/schema-viewer").then((mod) => ({
|
||||
@@ -43,9 +43,7 @@ function PcatSchemaPage() {
|
||||
const { data: schemaImages, isLoading: imagesLoading } = useQuery({
|
||||
queryKey: ["pcat-schemas", carId, groupId],
|
||||
queryFn: () =>
|
||||
api.get<PcatSchemaImage[]>(
|
||||
`/catalog/pcat/cars/${carId}/groups/${groupId}/schemas`,
|
||||
),
|
||||
api.get<PcatSchemaImage[]>(`/catalog/pcat/cars/${carId}/groups/${groupId}/schemas`),
|
||||
});
|
||||
|
||||
const activeSchema = schemaImages?.[activeImageIndex];
|
||||
@@ -54,10 +52,7 @@ function PcatSchemaPage() {
|
||||
// Fetch detail for active schema image
|
||||
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||
queryKey: ["pcat-schema-detail", activeSchema?.id],
|
||||
queryFn: () =>
|
||||
api.get<PcatSchemaDetail>(
|
||||
`/catalog/pcat/schemas/${activeSchema!.id}`,
|
||||
),
|
||||
queryFn: () => api.get<PcatSchemaDetail>(`/catalog/pcat/schemas/${activeSchema?.id}`),
|
||||
enabled: !!activeSchema?.id,
|
||||
});
|
||||
|
||||
@@ -84,9 +79,7 @@ function PcatSchemaPage() {
|
||||
{t("catalog.backToCategories")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">
|
||||
{activeSchema?.name || t("catalog.parts")}
|
||||
</h1>
|
||||
<h1 className="text-xl font-bold">{activeSchema?.name || t("catalog.parts")}</h1>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
|
||||
const HISTORY_SKEL_KEYS = ["h0", "h1", "h2"];
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/history")({
|
||||
component: HistoryPage,
|
||||
@@ -24,8 +26,8 @@ function HistoryPage() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
{HISTORY_SKEL_KEYS.map((k) => (
|
||||
<Skeleton key={k} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
@@ -41,11 +43,7 @@ function HistoryPage() {
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{data.map((vehicle: any) => (
|
||||
<Link
|
||||
key={vehicle.id}
|
||||
to="/dashboard/vehicles/$id"
|
||||
params={{ id: vehicle.id }}
|
||||
>
|
||||
<Link key={vehicle.id} to="/dashboard/vehicles/$id" params={{ id: vehicle.id }}>
|
||||
<Card className="transition-shadow hover:shadow-md cursor-pointer">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button, Badge, Skeleton, Separator } from "@sase/ui";
|
||||
import { KEYS_4 } from "@/lib/keys";
|
||||
import { Badge, Button, Separator, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
Search,
|
||||
Car,
|
||||
Database,
|
||||
User,
|
||||
ArrowRight,
|
||||
Crown,
|
||||
Calendar,
|
||||
Car,
|
||||
CheckCircle2,
|
||||
Crown,
|
||||
Database,
|
||||
Search,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/")({
|
||||
@@ -73,9 +75,7 @@ function StatCard({
|
||||
<p className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
{detail && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
@@ -178,7 +178,9 @@ function DashboardHome() {
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>("/subscriptions/me");
|
||||
return await api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
|
||||
"/subscriptions/me",
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -230,8 +232,8 @@ function DashboardHome() {
|
||||
{/* ─── STAT CARDS ─────────────────────────────────────────────── */}
|
||||
{isLoadingCards ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={`card-skel-${i}`} className="h-64 w-full rounded-2xl" />
|
||||
{KEYS_4.map((__k) => (
|
||||
<Skeleton key={__k} className="h-64 w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -265,19 +267,13 @@ function DashboardHome() {
|
||||
label="Aktif Marka"
|
||||
detail="Erişilebilir Marka"
|
||||
detailValue={maxBrands > 0 ? String(maxBrands) : "—"}
|
||||
progress={
|
||||
maxBrands > 0 ? Math.round((brandCount / maxBrands) * 100) : undefined
|
||||
}
|
||||
progress={maxBrands > 0 ? Math.round((brandCount / maxBrands) * 100) : undefined}
|
||||
buttonLabel={maxBrands === 0 ? "Plan Seçin" : undefined}
|
||||
buttonTo={maxBrands === 0 ? "/dashboard/subscription" : undefined}
|
||||
/>
|
||||
|
||||
{/* 4. Profil */}
|
||||
<ProfileCard
|
||||
name={user?.name ?? "—"}
|
||||
email={user?.email ?? "—"}
|
||||
initials={initials}
|
||||
/>
|
||||
<ProfileCard name={user?.name ?? "—"} email={user?.email ?? "—"} initials={initials} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -294,7 +290,9 @@ function DashboardHome() {
|
||||
|
||||
{subLoading ? (
|
||||
<Skeleton className="h-48 w-full rounded-2xl" />
|
||||
) : subscription && (subscription.status === "active" || (subscription.status === "trial" && !subData?.eligibleForTrial)) ? (
|
||||
) : subscription &&
|
||||
(subscription.status === "active" ||
|
||||
(subscription.status === "trial" && !subData?.eligibleForTrial)) ? (
|
||||
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
{/* Plan Info */}
|
||||
@@ -308,15 +306,15 @@ function DashboardHome() {
|
||||
<h3 className="text-lg font-bold">
|
||||
{subscription.plan?.name ?? "Aktif Plan"}
|
||||
</h3>
|
||||
<Badge variant="default" className="bg-emerald-600 text-xs">
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
|
||||
>
|
||||
{subscription.status === "trial" ? "Deneme" : "Aktif"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{subscription.billingPeriod === "yearly"
|
||||
? "Yıllık"
|
||||
: "Aylık"}{" "}
|
||||
abonelik
|
||||
{subscription.billingPeriod === "yearly" ? "Yıllık" : "Aylık"} abonelik
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,7 +323,12 @@ function DashboardHome() {
|
||||
{subscription.brands && subscription.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.brands.map((b) => (
|
||||
<Badge key={b.brandId} variant="outline">
|
||||
<Badge
|
||||
key={b.brandId}
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<CarBrandLogo brandName={b.brandName} size={16} className="shrink-0" />
|
||||
{b.brandName}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -339,9 +342,7 @@ function DashboardHome() {
|
||||
<Calendar className="size-3.5" />
|
||||
Başlangıç:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{new Date(subscription.startDate).toLocaleDateString(
|
||||
"tr-TR",
|
||||
)}
|
||||
{new Date(subscription.startDate).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -350,9 +351,7 @@ function DashboardHome() {
|
||||
<Calendar className="size-3.5" />
|
||||
Bitiş:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{new Date(subscription.endDate).toLocaleDateString(
|
||||
"tr-TR",
|
||||
)}
|
||||
{new Date(subscription.endDate).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -360,16 +359,12 @@ function DashboardHome() {
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{[
|
||||
"Sınırsız şase arama",
|
||||
"Parça kataloğu",
|
||||
"İnteraktif şema",
|
||||
].map((f) => (
|
||||
{["Sınırsız şase arama", "Parça kataloğu", "İnteraktif şema"].map((f) => (
|
||||
<span
|
||||
key={f}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
||||
<CheckCircle2 className="size-3.5 text-brand" />
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
@@ -432,11 +427,7 @@ function DashboardHome() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{history.slice(0, 3).map((v: any) => (
|
||||
<Link
|
||||
key={v.id}
|
||||
to="/dashboard/vehicles/$id"
|
||||
params={{ id: v.id }}
|
||||
>
|
||||
<Link key={v.id} to="/dashboard/vehicles/$id" params={{ id: v.id }}>
|
||||
<div className="group flex items-center gap-4 rounded-2xl border border-border bg-background p-4 transition-colors hover:bg-accent">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
<Car className="size-4 text-muted-foreground" />
|
||||
@@ -445,9 +436,7 @@ function DashboardHome() {
|
||||
<p className="truncate text-sm font-medium">
|
||||
{v.brandName} {v.model}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{v.vin}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{v.vin}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{v.year}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Input, Badge, Separator } from "@sase/ui";
|
||||
import {
|
||||
Search,
|
||||
Car,
|
||||
Loader2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "@/lib/api-client";
|
||||
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { KEYS_17 } from "@/lib/keys";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
|
||||
import { Badge, Button, Input, Separator } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle, Car, Clock, Loader2, Search, Send } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,9 +24,16 @@ function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
|
||||
const corrections: string[] = [];
|
||||
const cleaned = raw.replace(/[IOQioq]/g, (ch) => {
|
||||
const upper = ch.toUpperCase();
|
||||
if (upper === "I") { corrections.push("I→1"); return "1"; }
|
||||
if (upper === "O") { corrections.push("O→0"); return "0"; }
|
||||
corrections.push("Q→9"); return "9";
|
||||
if (upper === "I") {
|
||||
corrections.push("I→1");
|
||||
return "1";
|
||||
}
|
||||
if (upper === "O") {
|
||||
corrections.push("O→0");
|
||||
return "0";
|
||||
}
|
||||
corrections.push("Q→9");
|
||||
return "9";
|
||||
});
|
||||
return { cleaned, corrections };
|
||||
}
|
||||
@@ -140,9 +141,7 @@ function SearchPage() {
|
||||
startAction("vin-decode", { vin: cleanVin });
|
||||
capture("vin_decoded", { vin: cleanVin });
|
||||
if (!isValidVin(cleanVin)) {
|
||||
setError(
|
||||
"Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
|
||||
);
|
||||
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,7 +169,8 @@ function SearchPage() {
|
||||
params: { id: data.id },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
|
||||
capture("vin_decode_error", { vin: cleanVin, error: message });
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
@@ -217,7 +217,7 @@ function SearchPage() {
|
||||
try {
|
||||
const payload: Record<string, unknown> = { vin: candidateVin };
|
||||
if (candidateSource === "emex") {
|
||||
payload.emexCarIndex = parseInt(carId, 10);
|
||||
payload.emexCarIndex = Number.parseInt(carId, 10);
|
||||
} else {
|
||||
payload.pcatCarId = carId;
|
||||
}
|
||||
@@ -236,9 +236,7 @@ function SearchPage() {
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "Bir hata oluştu. Lütfen tekrar deneyin.";
|
||||
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
|
||||
setError(message);
|
||||
setCandidates(null);
|
||||
setCandidateSource(null);
|
||||
@@ -300,11 +298,11 @@ function SearchPage() {
|
||||
|
||||
{/* 17-segment progress bar */}
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 17 }).map((_, i) => (
|
||||
{KEYS_17.map((k, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={k}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
|
||||
i < vin.length ? "bg-emerald-500" : "bg-muted"
|
||||
i < vin.length ? "bg-brand" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
@@ -312,9 +310,7 @@ function SearchPage() {
|
||||
|
||||
{/* Counter + Example VIN */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{vin.length}/17 karakter
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">{vin.length}/17 karakter</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillExampleVin}
|
||||
@@ -391,17 +387,15 @@ function SearchPage() {
|
||||
{previewLoading && (
|
||||
<div className="flex items-center justify-center gap-3 rounded-2xl border border-border bg-background p-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Araç bilgileri alınıyor...
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Araç bilgileri alınıyor...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && !previewLoading && (
|
||||
<div className="rounded-2xl border border-emerald-500/30 bg-background p-5 sm:p-6">
|
||||
<div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<Car className="size-5 text-emerald-500" />
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
|
||||
<Car className="size-5 text-brand" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
|
||||
@@ -414,7 +408,7 @@ function SearchPage() {
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-600 text-xs text-white"
|
||||
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
|
||||
>
|
||||
Araç tanımlandı
|
||||
</Badge>
|
||||
@@ -428,7 +422,6 @@ function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* ─── SECTION 4: Son Aramalar ────────────────────────────────────── */}
|
||||
{history && history.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
@@ -466,9 +459,7 @@ function SearchPage() {
|
||||
<p className="truncate text-sm font-medium">
|
||||
{v.brandName} {v.model}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{v.vin}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{v.vin}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{v.year}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Button, Input, Badge, Separator } from "@sase/ui";
|
||||
import {
|
||||
FlaskConical,
|
||||
Search,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Check,
|
||||
Car,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "@/lib/api-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Badge, Button, Input, Separator } from "@sase/ui";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle, Car, Check, Copy, FlaskConical, Loader2, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,9 +19,16 @@ function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
|
||||
const corrections: string[] = [];
|
||||
const cleaned = raw.replace(/[IOQioq]/g, (ch) => {
|
||||
const upper = ch.toUpperCase();
|
||||
if (upper === "I") { corrections.push("I→1"); return "1"; }
|
||||
if (upper === "O") { corrections.push("O→0"); return "0"; }
|
||||
corrections.push("Q→9"); return "9";
|
||||
if (upper === "I") {
|
||||
corrections.push("I→1");
|
||||
return "1";
|
||||
}
|
||||
if (upper === "O") {
|
||||
corrections.push("O→0");
|
||||
return "0";
|
||||
}
|
||||
corrections.push("Q→9");
|
||||
return "9";
|
||||
});
|
||||
return { cleaned, corrections };
|
||||
}
|
||||
@@ -135,7 +134,8 @@ function ServiceTestPage() {
|
||||
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
|
||||
setError(message);
|
||||
toast.error("Servis testi başarısız");
|
||||
} finally {
|
||||
@@ -160,7 +160,7 @@ function ServiceTestPage() {
|
||||
try {
|
||||
const data = await api.post<any>("/vehicles/decode", {
|
||||
vin: candidateVin,
|
||||
emexCarIndex: parseInt(carId, 10),
|
||||
emexCarIndex: Number.parseInt(carId, 10),
|
||||
});
|
||||
setCandidates(null);
|
||||
if (data.id) {
|
||||
@@ -262,9 +262,7 @@ function ServiceTestPage() {
|
||||
|
||||
{/* Counter + Example VIN */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{vin.length}/17 karakter
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">{vin.length}/17 karakter</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillExampleVin}
|
||||
@@ -311,7 +309,8 @@ function ServiceTestPage() {
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{result.result.vehicle.year || "—"}
|
||||
{result.result.vehicle.engineCode && ` — Motor: ${result.result.vehicle.engineCode}`}
|
||||
{result.result.vehicle.engineCode &&
|
||||
` — Motor: ${result.result.vehicle.engineCode}`}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="default" className="bg-emerald-600 text-xs text-white">
|
||||
@@ -338,60 +337,55 @@ function ServiceTestPage() {
|
||||
)}
|
||||
|
||||
{/* ─── SECTION 3: Result Card (JSON for failures or non-vehicle results) ── */}
|
||||
{result && (!result.success || result.result?.type !== "vehicle") && result.result?.type !== "candidates" && (
|
||||
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
{/* Meta badges */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-blue-600 text-xs text-white"
|
||||
>
|
||||
{result.service}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{result.responseTimeMs.toLocaleString("tr-TR")}ms
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={result.success ? "default" : "destructive"}
|
||||
className={`text-xs ${result.success ? "bg-emerald-600 text-white" : ""}`}
|
||||
>
|
||||
{result.success ? "Başarılı" : "Başarısız"}
|
||||
</Badge>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyJson}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
{copied ? "Kopyalandı" : "JSON Kopyala"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{result.error && (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">{result.error}</p>
|
||||
{result &&
|
||||
(!result.success || result.result?.type !== "vehicle") &&
|
||||
result.result?.type !== "candidates" && (
|
||||
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
{/* Meta badges */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="default" className="bg-blue-600 text-xs text-white">
|
||||
{result.service}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{result.responseTimeMs.toLocaleString("tr-TR")}ms
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={result.success ? "default" : "destructive"}
|
||||
className={`text-xs ${result.success ? "bg-emerald-600 text-white" : ""}`}
|
||||
>
|
||||
{result.success ? "Başarılı" : "Başarısız"}
|
||||
</Badge>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyJson}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
{copied ? "Kopyalandı" : "JSON Kopyala"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON output */}
|
||||
{result.result !== null && (
|
||||
<>
|
||||
<Separator className="my-4 bg-border" />
|
||||
<pre className="max-h-[600px] overflow-auto rounded-xl border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(result.result, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Error message */}
|
||||
{result.error && (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">{result.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON output */}
|
||||
{result.result !== null && (
|
||||
<>
|
||||
<Separator className="my-4 bg-border" />
|
||||
<pre className="max-h-[600px] overflow-auto rounded-xl border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(result.result, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Vehicle Selection Modal (EMEX multi-result) ──────────────── */}
|
||||
{candidates && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense, lazy } from "react";
|
||||
|
||||
const SettingsContent = lazy(() =>
|
||||
import("@/components/settings/settings-content").then((mod) => ({
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { capture, setPeopleProperties } from "@/lib/posthog";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { BRAND_SKELETON_KEYS } from "@/lib/keys";
|
||||
import { capture, setPeopleProperties } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getUserSettings } from "@/lib/user-settings";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
@@ -19,10 +21,18 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@sase/ui";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Crown, Sparkles, ShieldCheck, Loader2, CheckCircle2, ArrowRight } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getUserSettings } from "@/lib/user-settings";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import confetti from "canvas-confetti";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Crown,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Suspense, lazy, useEffect, useRef, useState } from "react";
|
||||
|
||||
const BrandSelector = lazy(() =>
|
||||
import("@/components/subscription/brand-selector").then((mod) => ({
|
||||
@@ -30,9 +40,7 @@ const BrandSelector = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const LazyPlayer = lazy(() =>
|
||||
import("@remotion/player").then((mod) => ({ default: mod.Player })),
|
||||
);
|
||||
const LazyPlayer = lazy(() => import("@remotion/player").then((mod) => ({ default: mod.Player })));
|
||||
|
||||
const LazyOnboardingProgress = lazy(() =>
|
||||
import("@/remotion/OnboardingProgress").then((mod) => ({
|
||||
@@ -43,8 +51,8 @@ const LazyOnboardingProgress = lazy(() =>
|
||||
function BrandSelectorFallback() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`brand-skel-${i}`} className="h-24 w-full rounded-lg" />
|
||||
{BRAND_SKELETON_KEYS.map((k) => (
|
||||
<Skeleton key={k} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -148,7 +156,10 @@ function SubscriptionPage() {
|
||||
|
||||
const { data: subData, isLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: () => api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>("/subscriptions/me"),
|
||||
queryFn: () =>
|
||||
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
|
||||
"/subscriptions/me",
|
||||
),
|
||||
});
|
||||
|
||||
const subscription = subData?.subscription;
|
||||
@@ -163,7 +174,7 @@ function SubscriptionPage() {
|
||||
billing_period: subscription.billingPeriod,
|
||||
});
|
||||
}
|
||||
}, [subscription?.status, subscription?.plan?.key]);
|
||||
}, [subscription]);
|
||||
|
||||
// Apply referral code from Google OAuth callback
|
||||
useEffect(() => {
|
||||
@@ -217,7 +228,7 @@ function SubscriptionPage() {
|
||||
setOnboardingPhase("provisioning");
|
||||
trialMutation.mutate();
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}, [welcome, subData, eligibleForTrial]);
|
||||
}, [welcome, subData, eligibleForTrial, trialMutation.mutate]);
|
||||
|
||||
// Transition from provisioning → completed when both animation and mutation are done
|
||||
useEffect(() => {
|
||||
@@ -231,7 +242,7 @@ function SubscriptionPage() {
|
||||
spread: 80,
|
||||
origin: { y: 0.6 },
|
||||
});
|
||||
}, [onboardingPhase, animationEnded, trialMutation.isSuccess]);
|
||||
}, [onboardingPhase, animationEnded, trialMutation.isSuccess, queryClient.invalidateQueries]);
|
||||
|
||||
// Animation timer: 210 frames / 30fps = 7s + small buffer
|
||||
useEffect(() => {
|
||||
@@ -295,12 +306,12 @@ function SubscriptionPage() {
|
||||
if (onboardingPhase === "provisioning") {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
|
||||
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
|
||||
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
|
||||
<CardContent className="relative flex flex-col items-center gap-6 py-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 animate-pulse text-emerald-600 dark:text-emerald-400" />
|
||||
<h2 className="text-xl font-bold text-emerald-900 dark:text-emerald-100">
|
||||
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
|
||||
<h2 className="text-xl font-bold text-foreground">
|
||||
{t("subscription.onboarding.provisioning")}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -308,7 +319,7 @@ function SubscriptionPage() {
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[200px] w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -325,7 +336,7 @@ function SubscriptionPage() {
|
||||
|
||||
{/* If animation finished but mutation still pending */}
|
||||
{animationEnded && trialMutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("subscription.onboarding.step4")}...
|
||||
</div>
|
||||
@@ -337,10 +348,7 @@ function SubscriptionPage() {
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("subscription.onboarding.error")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => trialMutation.mutate()}
|
||||
>
|
||||
<Button variant="outline" onClick={() => trialMutation.mutate()}>
|
||||
{t("subscription.onboarding.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -356,24 +364,30 @@ function SubscriptionPage() {
|
||||
const freshSub = subData?.subscription;
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
|
||||
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
|
||||
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
|
||||
<CardContent className="relative flex flex-col items-center gap-6 py-10">
|
||||
<CheckCircle2 className="h-16 w-16 text-emerald-500" />
|
||||
<CheckCircle2 className="h-16 w-16 text-brand" />
|
||||
|
||||
<h2 className="text-center text-2xl font-bold text-emerald-900 dark:text-emerald-100">
|
||||
<h2 className="text-center text-2xl font-bold text-foreground">
|
||||
{t("subscription.onboarding.completed")}
|
||||
</h2>
|
||||
|
||||
{/* Subscription info box */}
|
||||
<div className="w-full max-w-md space-y-4 rounded-xl border border-emerald-200 bg-white/60 p-5 dark:border-emerald-800 dark:bg-white/5">
|
||||
<div className="w-full max-w-md space-y-4 rounded-xl border border-brand/20 bg-background/60 p-5 backdrop-blur-sm dark:bg-background/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t("subscription.currentPlan")}</span>
|
||||
<Badge className="bg-emerald-600 text-white">Full Paket</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("subscription.currentPlan")}
|
||||
</span>
|
||||
<Badge className="bg-brand text-brand-foreground">Full Paket</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span>
|
||||
<span className="text-sm font-medium">{t("subscription.onboarding.trialDuration")}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("subscription.billingPeriod")}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{t("subscription.onboarding.trialDuration")}
|
||||
</span>
|
||||
</div>
|
||||
{freshSub?.endDate && (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -386,8 +400,8 @@ function SubscriptionPage() {
|
||||
<Separator />
|
||||
<ul className="space-y-2 text-sm">
|
||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
|
||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<li key={f} className="flex items-center gap-2 text-foreground/85">
|
||||
<Check className="h-4 w-4 text-brand" />
|
||||
{t(`subscription.features.${f}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -396,7 +410,7 @@ function SubscriptionPage() {
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
className="bg-brand text-brand-foreground hover:bg-brand/90"
|
||||
onClick={() => navigate({ to: "/dashboard/search" })}
|
||||
>
|
||||
{t("subscription.onboarding.startSearching")}
|
||||
@@ -441,7 +455,8 @@ function SubscriptionPage() {
|
||||
<p className="mb-2 text-sm font-medium">{t("subscription.accessibleBrands")}:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.brands.map((b) => (
|
||||
<Badge key={b.brandId} variant="outline">
|
||||
<Badge key={b.brandId} variant="outline" className="flex items-center gap-1.5">
|
||||
<CarBrandLogo brandName={b.brandName} size={16} className="shrink-0" />
|
||||
{b.brandName}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -469,10 +484,16 @@ function SubscriptionPage() {
|
||||
</div>
|
||||
|
||||
{subscription.status === "trial" && subscription.endDate && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-brand/10 px-3 py-2 text-sm text-brand">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{(() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||
const days = Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(new Date(subscription.endDate).getTime() - Date.now()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
),
|
||||
);
|
||||
return `${days} gün kaldı`;
|
||||
})()}
|
||||
</div>
|
||||
@@ -513,7 +534,13 @@ function SubscriptionPage() {
|
||||
</Dialog>
|
||||
)}
|
||||
{subscription.status === "cancelled" && (
|
||||
<Button onClick={() => { capture("subscription_resumed"); resumeMutation.mutate(); }} disabled={resumeMutation.isPending}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
capture("subscription_resumed");
|
||||
resumeMutation.mutate();
|
||||
}}
|
||||
disabled={resumeMutation.isPending}
|
||||
>
|
||||
{resumeMutation.isPending
|
||||
? t("subscription.resuming")
|
||||
: t("subscription.resumeSubscription")}
|
||||
@@ -525,44 +552,43 @@ function SubscriptionPage() {
|
||||
)}
|
||||
|
||||
{/* Trial CTA Card */}
|
||||
{eligibleForTrial && (!subscription || subscription.status === "expired" || subscription.status === "trial") && (
|
||||
<Card className="relative overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<CardHeader className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
<CardTitle className="text-emerald-900 dark:text-emerald-100">
|
||||
{t("subscription.trialTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-emerald-700/80 dark:text-emerald-300/80">
|
||||
{t("subscription.trialDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative space-y-4">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
|
||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
{t(`subscription.features.${f}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => {
|
||||
startAction("trial-start");
|
||||
capture("trial_started");
|
||||
trialMutation.mutate();
|
||||
}}
|
||||
disabled={trialMutation.isPending}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{trialMutation.isPending ? t("common.loading") : t("subscription.startTrial")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{eligibleForTrial &&
|
||||
(!subscription || subscription.status === "expired" || subscription.status === "trial") && (
|
||||
<Card className="relative overflow-hidden border-brand/25 bg-brand/5">
|
||||
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
|
||||
<CardHeader className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-brand" />
|
||||
<CardTitle className="text-foreground">{t("subscription.trialTitle")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t("subscription.trialDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative space-y-4">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-foreground/85">
|
||||
<Check className="h-4 w-4 text-brand" />
|
||||
{t(`subscription.features.${f}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
className="bg-brand text-brand-foreground hover:bg-brand/90"
|
||||
onClick={() => {
|
||||
startAction("trial-start");
|
||||
capture("trial_started");
|
||||
trialMutation.mutate();
|
||||
}}
|
||||
disabled={trialMutation.isPending}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{trialMutation.isPending ? t("common.loading") : t("subscription.startTrial")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No Subscription Banner */}
|
||||
{!eligibleForTrial && !subscription && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense, lazy } from "react";
|
||||
|
||||
const PaymentContent = lazy(() =>
|
||||
import("@/components/payment/payment-content").then((mod) => ({
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useCategoryParts } from "@/hooks/use-parts";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { useCategoryParts } from "@/hooks/use-parts";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
|
||||
import { KEYS_6, KEYS_8 } from "@/lib/keys";
|
||||
const SchemaViewer = lazy(() =>
|
||||
import("@/components/schema/schema-viewer").then((mod) => ({
|
||||
default: mod.SchemaViewer,
|
||||
@@ -20,8 +23,8 @@ function SchemaViewerFallback() {
|
||||
</div>
|
||||
<div className="w-full space-y-3 p-4 md:w-[40%]">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,8 +34,8 @@ function SchemaViewerFallback() {
|
||||
function CategoryGridFallback() {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={`cat-grid-skel-${i}`} className="h-24 w-full rounded-lg" />
|
||||
{KEYS_6.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -49,6 +52,15 @@ function VehicleCategoryPage() {
|
||||
|
||||
const hasChildren = data?.children && data.children.length > 0;
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (data?.parentId) {
|
||||
navigate({
|
||||
@@ -66,25 +78,57 @@ function VehicleCategoryPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri don"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{data?.name || "Kategori Detayi"}
|
||||
</h1>
|
||||
{data?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} title="Geri don">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{data?.name || "Kategori Detayi"}</h1>
|
||||
{data?.description && (
|
||||
<p className="text-sm text-muted-foreground">{data.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Agac"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
@@ -95,17 +139,18 @@ function VehicleCategoryPage() {
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && !data && (
|
||||
<CategoryGridFallback />
|
||||
)}
|
||||
{isLoading && !data && <CategoryGridFallback />}
|
||||
|
||||
{/* Parent category — show children grid */}
|
||||
{hasChildren && (
|
||||
<CategoryGrid
|
||||
categories={data.children!}
|
||||
vehicleId={id}
|
||||
/>
|
||||
)}
|
||||
{/* Parent category — show children */}
|
||||
{hasChildren &&
|
||||
data?.children &&
|
||||
(viewMode === "grid" ? (
|
||||
<CategoryGrid categories={data.children} vehicleId={id} />
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree categories={data.children} vehicleId={id} />
|
||||
) : (
|
||||
<CategoryColumns categories={data.children} vehicleId={id} />
|
||||
))}
|
||||
|
||||
{/* Leaf category — show schema viewer */}
|
||||
{data && !hasChildren && (
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Card, CardContent, CardHeader, CardTitle, cn } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
|
||||
import { Button } from "@sase/ui";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
||||
component: VehicleDetailPage,
|
||||
});
|
||||
|
||||
function VehicleDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree">(
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree") => {
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
@@ -51,19 +54,19 @@ function VehicleDetailPage() {
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.history.back()}
|
||||
title="Geri don"
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={() => window.history.back()} title="Geri don">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{vehicle?.brandName} {vehicle?.model} {vehicle?.year && `(${vehicle.year})`}
|
||||
</h2>
|
||||
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{vehicle?.brandName && (
|
||||
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{vehicle?.brandName} {vehicle?.model} {vehicle?.year && `(${vehicle.year})`}
|
||||
</h2>
|
||||
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,10 +90,9 @@ function VehicleDetailPage() {
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={cn(
|
||||
"p-1.5 rounded",
|
||||
viewMode === "grid"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -99,32 +101,42 @@ function VehicleDetailPage() {
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={cn(
|
||||
"p-1.5 rounded",
|
||||
viewMode === "tree"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Agac"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={cn(
|
||||
"p-1.5 rounded",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}
|
||||
>
|
||||
{categoriesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`cat-skel-${i}`} className="h-8 w-full" />
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<CategoryGrid
|
||||
categories={categoryTree || []}
|
||||
vehicleId={id}
|
||||
/>
|
||||
<CategoryGrid categories={categoryTree || []} vehicleId={id} />
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree categories={categoryTree || []} vehicleId={id} />
|
||||
) : (
|
||||
<CategoryTree
|
||||
categories={categoryTree || []}
|
||||
vehicleId={id}
|
||||
/>
|
||||
<CategoryColumns categories={categoryTree || []} vehicleId={id} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button, Input } from "@sase/ui";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import {
|
||||
Search,
|
||||
Car,
|
||||
ArrowRight,
|
||||
Lock,
|
||||
Loader2,
|
||||
FolderTree,
|
||||
MousePointerClick,
|
||||
Sun,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { KEYS_16, KEYS_17 } from "@/lib/keys";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Input } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowRight,
|
||||
Car,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
Lock,
|
||||
Moon,
|
||||
MousePointerClick,
|
||||
Search,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/demo")({
|
||||
component: DemoPage,
|
||||
@@ -118,17 +119,22 @@ function DemoPage() {
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Tema değiştir"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
<span className="rounded-full bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-600">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full bg-brand" />
|
||||
Demo
|
||||
</span>
|
||||
<Link to="/register">
|
||||
<Button size="sm" className="rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-foreground text-background hover:bg-foreground/90"
|
||||
>
|
||||
Tam Erişim
|
||||
<ArrowRight className="ml-1.5 size-3.5" />
|
||||
</Button>
|
||||
@@ -137,17 +143,22 @@ function DemoPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||
<main id="main-content" className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||
{/* Step indicator */}
|
||||
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<button
|
||||
onClick={() => { setStep("vin"); setSelectedCategory(null); }}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("vin");
|
||||
setSelectedCategory(null);
|
||||
}}
|
||||
className={`rounded-full px-3 py-1 transition ${step === "vin" ? "bg-foreground text-background" : "bg-muted"}`}
|
||||
>
|
||||
1. VIN Girin
|
||||
</button>
|
||||
<div className="h-px w-6 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => vinPreview && setStep("categories")}
|
||||
className={`rounded-full px-3 py-1 transition ${step === "categories" ? "bg-foreground text-background" : "bg-muted"} ${!vinPreview ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
disabled={!vinPreview}
|
||||
@@ -156,6 +167,7 @@ function DemoPage() {
|
||||
</button>
|
||||
<div className="h-px w-6 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedCategory && setStep("schema")}
|
||||
className={`rounded-full px-3 py-1 transition ${step === "schema" ? "bg-foreground text-background" : "bg-muted"} ${!selectedCategory ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
disabled={!selectedCategory}
|
||||
@@ -189,11 +201,11 @@ function DemoPage() {
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 17 }).map((_, i) => (
|
||||
{KEYS_17.map((k, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={k}
|
||||
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
|
||||
i < vin.length ? "bg-emerald-500" : "bg-border"
|
||||
i < vin.length ? "bg-brand" : "bg-border"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
@@ -207,9 +219,9 @@ function DemoPage() {
|
||||
)}
|
||||
|
||||
{vinPreview && !vinLoading && (
|
||||
<div className="animate-fade-in-up rounded-2xl border border-emerald-500/30 bg-surface p-6">
|
||||
<div className="animate-fade-in-up rounded-2xl border border-brand/30 bg-surface p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Car className="size-6 text-emerald-500" />
|
||||
<Car className="size-6 text-brand" />
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">
|
||||
{vinPreview.make} {vinPreview.model}
|
||||
@@ -221,7 +233,8 @@ function DemoPage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setStep("categories")}
|
||||
className="mt-4 w-full rounded-full bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
variant="brand"
|
||||
className="mt-4 w-full rounded-full"
|
||||
>
|
||||
Parça Kataloğuna Devam Et
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
@@ -237,6 +250,7 @@ function DemoPage() {
|
||||
|
||||
{!vin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVin("WVWZZZ1JZ3W597935")}
|
||||
className="mx-auto block text-sm text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
|
||||
>
|
||||
@@ -261,7 +275,11 @@ function DemoPage() {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setStep("vin"); setSelectedCategory(null); }}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("vin");
|
||||
setSelectedCategory(null);
|
||||
}}
|
||||
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Farklı VIN dene
|
||||
@@ -271,6 +289,7 @@ function DemoPage() {
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{EXAMPLE_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
type="button"
|
||||
key={cat.name}
|
||||
onClick={() => {
|
||||
setSelectedCategory(cat.name);
|
||||
@@ -309,6 +328,7 @@ function DemoPage() {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("categories")}
|
||||
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
@@ -328,14 +348,18 @@ function DemoPage() {
|
||||
<div className="relative aspect-square bg-muted/50 p-6">
|
||||
{/* Simplified schema grid */}
|
||||
<div className="grid h-full grid-cols-4 grid-rows-4 gap-2">
|
||||
{Array.from({ length: 16 }).map((_, i) => (
|
||||
{KEYS_16.map((k, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={k}
|
||||
className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${
|
||||
[2, 5, 9, 13].includes(i) ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-500" : "bg-muted/50"
|
||||
[2, 5, 9, 13].includes(i)
|
||||
? "border-brand/50 bg-brand/10 text-brand"
|
||||
: "bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{[2, 5, 9, 13].includes(i) ? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position : ""}
|
||||
{[2, 5, 9, 13].includes(i)
|
||||
? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position
|
||||
: ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -364,7 +388,7 @@ function DemoPage() {
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
|
||||
</div>
|
||||
{idx < 2 ? (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-500">
|
||||
<span className="rounded-full bg-brand/10 px-2 py-0.5 text-xs text-brand">
|
||||
Görünür
|
||||
</span>
|
||||
) : (
|
||||
@@ -380,7 +404,7 @@ function DemoPage() {
|
||||
<div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center">
|
||||
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
7 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||
30 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||
</p>
|
||||
<Link to="/register">
|
||||
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/kvkk")({
|
||||
component: KvkkPage,
|
||||
@@ -25,49 +25,37 @@ function KvkkPage() {
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">
|
||||
KVKK Aydınlatma Metni
|
||||
</h1>
|
||||
<h1 className="text-4xl font-bold">KVKK Aydınlatma Metni</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
6698 Sayılı Kişisel Verilerin Korunması Kanunu Kapsamında
|
||||
Aydınlatma Metni
|
||||
6698 Sayılı Kişisel Verilerin Korunması Kanunu Kapsamında Aydınlatma Metni
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
1. Veri Sorumlusu
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">1. Veri Sorumlusu</h2>
|
||||
<p className="mt-3">
|
||||
6698 sayılı Kişisel Verilerin Korunması Kanunu ("KVKK")
|
||||
uyarınca, kişisel verileriniz veri sorumlusu sıfatıyla
|
||||
Sase.tr tarafından aşağıda açıklanan kapsamda işlenmektedir.
|
||||
6698 sayılı Kişisel Verilerin Korunması Kanunu ("KVKK") uyarınca, kişisel verileriniz
|
||||
veri sorumlusu sıfatıyla Sase.tr tarafından aşağıda açıklanan kapsamda işlenmektedir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
2. İşlenen Kişisel Veriler
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">2. İşlenen Kişisel Veriler</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Kimlik Bilgileri:</strong> Ad, soyad
|
||||
</li>
|
||||
<li>
|
||||
<strong>İletişim Bilgileri:</strong> E-posta adresi,
|
||||
telefon numarası (isteğe bağlı)
|
||||
<strong>İletişim Bilgileri:</strong> E-posta adresi, telefon numarası (isteğe bağlı)
|
||||
</li>
|
||||
<li>
|
||||
<strong>İşlem Güvenliği:</strong> IP adresi, oturum
|
||||
bilgileri, log kayıtları
|
||||
<strong>İşlem Güvenliği:</strong> IP adresi, oturum bilgileri, log kayıtları
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kullanım Verileri:</strong> Arama geçmişi,
|
||||
platform kullanım istatistikleri
|
||||
<strong>Kullanım Verileri:</strong> Arama geçmişi, platform kullanım istatistikleri
|
||||
</li>
|
||||
<li>
|
||||
<strong>Finansal Bilgiler:</strong> Fatura bilgileri,
|
||||
abonelik durumu
|
||||
<strong>Finansal Bilgiler:</strong> Fatura bilgileri, abonelik durumu
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -77,35 +65,23 @@ function KvkkPage() {
|
||||
3. Kişisel Verilerin İşlenme Amaçları
|
||||
</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>Üyelik işlemlerinin gerçekleştirilmesi ve hesap yönetimi</li>
|
||||
<li>Platform hizmetlerinin sunulması ve iyileştirilmesi</li>
|
||||
<li>Ödeme ve faturalama işlemlerinin yürütülmesi</li>
|
||||
<li>Müşteri destek taleplerinin karşılanması</li>
|
||||
<li>
|
||||
Üyelik işlemlerinin gerçekleştirilmesi ve hesap yönetimi
|
||||
</li>
|
||||
<li>
|
||||
Platform hizmetlerinin sunulması ve iyileştirilmesi
|
||||
</li>
|
||||
<li>
|
||||
Ödeme ve faturalama işlemlerinin yürütülmesi
|
||||
</li>
|
||||
<li>
|
||||
Müşteri destek taleplerinin karşılanması
|
||||
</li>
|
||||
<li>
|
||||
Yasal yükümlülüklerin yerine getirilmesi (5651 sayılı
|
||||
Kanun kapsamında log tutma yükümlülüğü dahil)
|
||||
</li>
|
||||
<li>
|
||||
İstatistiksel analiz ve hizmet geliştirme
|
||||
Yasal yükümlülüklerin yerine getirilmesi (5651 sayılı Kanun kapsamında log tutma
|
||||
yükümlülüğü dahil)
|
||||
</li>
|
||||
<li>İstatistiksel analiz ve hizmet geliştirme</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
4. Hukuki Sebepler
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">4. Hukuki Sebepler</h2>
|
||||
<p className="mt-3">
|
||||
Kişisel verileriniz, KVKK'nın 5. maddesinde belirtilen
|
||||
aşağıdaki hukuki sebeplere dayanılarak işlenmektedir:
|
||||
Kişisel verileriniz, KVKK'nın 5. maddesinde belirtilen aşağıdaki hukuki sebeplere
|
||||
dayanılarak işlenmektedir:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc space-y-2 pl-6">
|
||||
<li>Sözleşmenin kurulması ve ifası</li>
|
||||
@@ -116,84 +92,56 @@ function KvkkPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
5. Verilerin Aktarımı
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">5. Verilerin Aktarımı</h2>
|
||||
<p className="mt-3">
|
||||
Kişisel verileriniz, hizmetin sunulması amacıyla yurt
|
||||
içindeki iş ortaklarımız (hosting, ödeme altyapısı) ve
|
||||
yasal zorunluluk halinde yetkili kamu kurum ve kuruluşlarıyla
|
||||
paylaşılabilir. Yurt dışına veri aktarımı, KVKK'nın 9.
|
||||
maddesi kapsamındaki güvencelere uygun olarak
|
||||
gerçekleştirilir.
|
||||
Kişisel verileriniz, hizmetin sunulması amacıyla yurt içindeki iş ortaklarımız
|
||||
(hosting, ödeme altyapısı) ve yasal zorunluluk halinde yetkili kamu kurum ve
|
||||
kuruluşlarıyla paylaşılabilir. Yurt dışına veri aktarımı, KVKK'nın 9. maddesi
|
||||
kapsamındaki güvencelere uygun olarak gerçekleştirilir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
6. Veri Saklama Süresi
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">6. Veri Saklama Süresi</h2>
|
||||
<p className="mt-3">
|
||||
Kişisel verileriniz, işlenme amaçlarının gerektirdiği süre
|
||||
boyunca ve yasal saklama yükümlülükleri kapsamında muhafaza
|
||||
edilir. Süre sona erdiğinde veriler silinir, yok edilir
|
||||
veya anonim hale getirilir.
|
||||
Kişisel verileriniz, işlenme amaçlarının gerektirdiği süre boyunca ve yasal saklama
|
||||
yükümlülükleri kapsamında muhafaza edilir. Süre sona erdiğinde veriler silinir, yok
|
||||
edilir veya anonim hale getirilir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
7. Haklarınız
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
KVKK'nın 11. maddesi uyarınca aşağıdaki haklara
|
||||
sahipsiniz:
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-foreground">7. Haklarınız</h2>
|
||||
<p className="mt-3">KVKK'nın 11. maddesi uyarınca aşağıdaki haklara sahipsiniz:</p>
|
||||
<ul className="mt-2 list-disc space-y-2 pl-6">
|
||||
<li>Kişisel verilerinizin işlenip işlenmediğini öğrenme</li>
|
||||
<li>İşlenmişse buna ilişkin bilgi talep etme</li>
|
||||
<li>İşlenme amacını ve amacına uygun kullanılıp kullanılmadığını öğrenme</li>
|
||||
<li>Yurt içinde veya yurt dışında aktarıldığı üçüncü kişileri bilme</li>
|
||||
<li>Eksik veya yanlış işlenmişse düzeltilmesini isteme</li>
|
||||
<li>
|
||||
İşlenme amacını ve amacına uygun kullanılıp
|
||||
kullanılmadığını öğrenme
|
||||
KVKK'nın 7. maddesindeki şartlar çerçevesinde silinmesini veya yok edilmesini isteme
|
||||
</li>
|
||||
<li>
|
||||
Yurt içinde veya yurt dışında aktarıldığı üçüncü
|
||||
kişileri bilme
|
||||
İşlenen verilerin münhasıran otomatik sistemler vasıtasıyla analiz edilmesi
|
||||
suretiyle aleyhinize bir sonucun ortaya çıkmasına itiraz etme
|
||||
</li>
|
||||
<li>
|
||||
Eksik veya yanlış işlenmişse düzeltilmesini isteme
|
||||
</li>
|
||||
<li>
|
||||
KVKK'nın 7. maddesindeki şartlar çerçevesinde silinmesini
|
||||
veya yok edilmesini isteme
|
||||
</li>
|
||||
<li>
|
||||
İşlenen verilerin münhasıran otomatik sistemler
|
||||
vasıtasıyla analiz edilmesi suretiyle aleyhinize bir
|
||||
sonucun ortaya çıkmasına itiraz etme
|
||||
</li>
|
||||
<li>
|
||||
Kanuna aykırı işlenmesi sebebiyle zarara uğramanız
|
||||
halinde zararın giderilmesini talep etme
|
||||
Kanuna aykırı işlenmesi sebebiyle zarara uğramanız halinde zararın giderilmesini
|
||||
talep etme
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
8. Başvuru Yöntemi
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">8. Başvuru Yöntemi</h2>
|
||||
<p className="mt-3">
|
||||
Yukarıda belirtilen haklarınızı kullanmak için{" "}
|
||||
<a
|
||||
href="mailto:info@sase.tr"
|
||||
className="text-primary underline"
|
||||
>
|
||||
<a href="mailto:info@sase.tr" className="text-primary underline">
|
||||
info@sase.tr
|
||||
</a>{" "}
|
||||
adresine kimliğinizi tespit edici belgelerle birlikte
|
||||
yazılı olarak başvurabilirsiniz. Başvurular en geç 30 gün
|
||||
içinde sonuçlandırılır.
|
||||
adresine kimliğinizi tespit edici belgelerle birlikte yazılı olarak başvurabilirsiniz.
|
||||
Başvurular en geç 30 gün içinde sonuçlandırılır.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/pricing")({
|
||||
component: PricingPage,
|
||||
@@ -63,7 +63,7 @@ function PricingPage() {
|
||||
usePageMeta({
|
||||
title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
|
||||
description:
|
||||
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 7 gün ücretsiz deneyin.",
|
||||
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 30 gün ücretsiz deneyin.",
|
||||
canonical: "https://sase.tr/pricing",
|
||||
});
|
||||
|
||||
@@ -85,12 +85,10 @@ function PricingPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 py-24">
|
||||
<main id="main-content" className="container mx-auto px-4 py-24">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">Fiyatlandırma</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
İhtiyacınıza uygun planı seçin.
|
||||
</p>
|
||||
<p className="mt-4 text-lg text-muted-foreground">İhtiyacınıza uygun planı seçin.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/privacy")({
|
||||
component: PrivacyPage,
|
||||
@@ -26,51 +26,42 @@ function PrivacyPage() {
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">Gizlilik Politikası</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Son güncelleme: 15 Şubat 2026
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Son güncelleme: 15 Şubat 2026</p>
|
||||
|
||||
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
1. Genel Bakış
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">1. Genel Bakış</h2>
|
||||
<p className="mt-3">
|
||||
Sase.tr ("Platform") olarak kullanıcılarımızın gizliliğine
|
||||
önem veriyoruz. Bu politika, kişisel verilerinizin nasıl
|
||||
toplandığını, işlendiğini ve korunduğunu açıklar.
|
||||
Sase.tr ("Platform") olarak kullanıcılarımızın gizliliğine önem veriyoruz. Bu
|
||||
politika, kişisel verilerinizin nasıl toplandığını, işlendiğini ve korunduğunu
|
||||
açıklar.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
2. Toplanan Veriler
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">2. Toplanan Veriler</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Hesap Bilgileri:</strong> Ad, e-posta adresi,
|
||||
telefon numarası (isteğe bağlı).
|
||||
<strong>Hesap Bilgileri:</strong> Ad, e-posta adresi, telefon numarası (isteğe
|
||||
bağlı).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kullanım Verileri:</strong> Arama geçmişi, VIN
|
||||
sorguları, sayfa görüntüleme istatistikleri.
|
||||
<strong>Kullanım Verileri:</strong> Arama geçmişi, VIN sorguları, sayfa görüntüleme
|
||||
istatistikleri.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Teknik Veriler:</strong> IP adresi, tarayıcı türü,
|
||||
cihaz bilgisi, çerez verileri.
|
||||
<strong>Teknik Veriler:</strong> IP adresi, tarayıcı türü, cihaz bilgisi, çerez
|
||||
verileri.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ödeme Bilgileri:</strong> Ödeme işlemleri üçüncü
|
||||
parti ödeme sağlayıcıları aracılığıyla gerçekleştirilir.
|
||||
Kredi kartı bilgileri tarafımızca saklanmaz.
|
||||
<strong>Ödeme Bilgileri:</strong> Ödeme işlemleri üçüncü parti ödeme sağlayıcıları
|
||||
aracılığıyla gerçekleştirilir. Kredi kartı bilgileri tarafımızca saklanmaz.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
3. Verilerin Kullanım Amacı
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">3. Verilerin Kullanım Amacı</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>Hizmetin sunulması ve iyileştirilmesi</li>
|
||||
<li>Kullanıcı hesaplarının yönetimi</li>
|
||||
@@ -81,52 +72,37 @@ function PrivacyPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
4. Veri Paylaşımı
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">4. Veri Paylaşımı</h2>
|
||||
<p className="mt-3">
|
||||
Kişisel verileriniz, yasal zorunluluklar dışında üçüncü
|
||||
taraflarla paylaşılmaz. Hizmet sağlayıcılarımız (hosting,
|
||||
ödeme altyapısı) yalnızca hizmetin işletilmesi için gerekli
|
||||
Kişisel verileriniz, yasal zorunluluklar dışında üçüncü taraflarla paylaşılmaz. Hizmet
|
||||
sağlayıcılarımız (hosting, ödeme altyapısı) yalnızca hizmetin işletilmesi için gerekli
|
||||
olan verilere erişir ve gizlilik sözleşmeleri ile bağlıdır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
5. Çerezler
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">5. Çerezler</h2>
|
||||
<p className="mt-3">
|
||||
Platform, oturum yönetimi ve kullanıcı deneyimini
|
||||
iyileştirmek amacıyla çerezler kullanır. Zorunlu çerezler
|
||||
hizmetin çalışması için gereklidir. Analitik çerezler
|
||||
Platform, oturum yönetimi ve kullanıcı deneyimini iyileştirmek amacıyla çerezler
|
||||
kullanır. Zorunlu çerezler hizmetin çalışması için gereklidir. Analitik çerezler
|
||||
kullanıcı tercihine bağlıdır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
6. Veri Güvenliği
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">6. Veri Güvenliği</h2>
|
||||
<p className="mt-3">
|
||||
Verileriniz SSL/TLS şifrelemesi ile korunur. Sunucularımız
|
||||
güvenli veri merkezlerinde barındırılır ve düzenli güvenlik
|
||||
denetimleri yapılır.
|
||||
Verileriniz SSL/TLS şifrelemesi ile korunur. Sunucularımız güvenli veri merkezlerinde
|
||||
barındırılır ve düzenli güvenlik denetimleri yapılır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
7. Haklarınız
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">7. Haklarınız</h2>
|
||||
<p className="mt-3">
|
||||
KVKK kapsamında kişisel verilerinize erişim, düzeltme,
|
||||
silme ve işlemeye itiraz etme haklarına sahipsiniz.
|
||||
Talepleriniz için{" "}
|
||||
<a
|
||||
href="mailto:info@sase.tr"
|
||||
className="text-primary underline"
|
||||
>
|
||||
KVKK kapsamında kişisel verilerinize erişim, düzeltme, silme ve işlemeye itiraz etme
|
||||
haklarına sahipsiniz. Talepleriniz için{" "}
|
||||
<a href="mailto:info@sase.tr" className="text-primary underline">
|
||||
info@sase.tr
|
||||
</a>{" "}
|
||||
adresine başvurabilirsiniz.
|
||||
@@ -134,13 +110,10 @@ function PrivacyPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
8. Değişiklikler
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">8. Değişiklikler</h2>
|
||||
<p className="mt-3">
|
||||
Bu politika zaman zaman güncellenebilir. Önemli
|
||||
değişiklikler e-posta veya platform içi bildirim yoluyla
|
||||
duyurulur.
|
||||
Bu politika zaman zaman güncellenebilir. Önemli değişiklikler e-posta veya platform
|
||||
içi bildirim yoluyla duyurulur.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/terms")({
|
||||
component: TermsPage,
|
||||
@@ -26,37 +26,28 @@ function TermsPage() {
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">Kullanım Koşulları</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Son güncelleme: 15 Şubat 2026
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Son güncelleme: 15 Şubat 2026</p>
|
||||
|
||||
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
1. Kabul ve Onay
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">1. Kabul ve Onay</h2>
|
||||
<p className="mt-3">
|
||||
Sase.tr platformunu ("Platform") kullanarak bu kullanım
|
||||
koşullarını kabul etmiş sayılırsınız. Koşulları kabul
|
||||
etmiyorsanız platformu kullanmayınız.
|
||||
Sase.tr platformunu ("Platform") kullanarak bu kullanım koşullarını kabul etmiş
|
||||
sayılırsınız. Koşulları kabul etmiyorsanız platformu kullanmayınız.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
2. Hizmet Tanımı
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">2. Hizmet Tanımı</h2>
|
||||
<p className="mt-3">
|
||||
Platform, şase numarası (VIN) ile araç tanımlama, orijinal
|
||||
yedek parça kataloğuna erişim ve interaktif şema görüntüleme
|
||||
hizmetleri sunar. Hizmetler abonelik modeli ile sunulur.
|
||||
Platform, şase numarası (VIN) ile araç tanımlama, orijinal yedek parça kataloğuna
|
||||
erişim ve interaktif şema görüntüleme hizmetleri sunar. Hizmetler abonelik modeli ile
|
||||
sunulur.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
3. Hesap Oluşturma
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">3. Hesap Oluşturma</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>Kayıt sırasında doğru ve güncel bilgi vermekle yükümlüsünüz.</li>
|
||||
<li>Hesap bilgilerinizin güvenliğinden siz sorumlusunuz.</li>
|
||||
@@ -66,30 +57,19 @@ function TermsPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
4. Abonelik ve Ödeme
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">4. Abonelik ve Ödeme</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>Abonelikler aylık veya yıllık olarak faturalandırılır.</li>
|
||||
<li>Yıllık aboneliklerde indirimli fiyat uygulanır.</li>
|
||||
<li>Abonelik, dönem sonunda otomatik olarak yenilenir.</li>
|
||||
<li>
|
||||
Abonelikler aylık veya yıllık olarak faturalandırılır.
|
||||
</li>
|
||||
<li>
|
||||
Yıllık aboneliklerde indirimli fiyat uygulanır.
|
||||
</li>
|
||||
<li>
|
||||
Abonelik, dönem sonunda otomatik olarak yenilenir.
|
||||
</li>
|
||||
<li>
|
||||
İptal işlemi mevcut dönemin sonunda geçerli olur;
|
||||
kalan süre için iade yapılmaz.
|
||||
İptal işlemi mevcut dönemin sonunda geçerli olur; kalan süre için iade yapılmaz.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
5. Kullanım Kuralları
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">5. Kullanım Kuralları</h2>
|
||||
<p className="mt-3">Aşağıdaki eylemler yasaktır:</p>
|
||||
<ul className="mt-2 list-disc space-y-2 pl-6">
|
||||
<li>Platformdaki verilerin toplu olarak çekilmesi (scraping)</li>
|
||||
@@ -100,47 +80,36 @@ function TermsPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
6. Fikri Mülkiyet
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">6. Fikri Mülkiyet</h2>
|
||||
<p className="mt-3">
|
||||
Platform üzerindeki tüm içerik, tasarım, yazılım ve
|
||||
veritabanı Sase.tr'ye aittir. Kullanıcılar yalnızca
|
||||
kişisel kullanım amacıyla erişim hakkına sahiptir.
|
||||
Platform üzerindeki tüm içerik, tasarım, yazılım ve veritabanı Sase.tr'ye aittir.
|
||||
Kullanıcılar yalnızca kişisel kullanım amacıyla erişim hakkına sahiptir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
7. Sorumluluk Sınırı
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">7. Sorumluluk Sınırı</h2>
|
||||
<p className="mt-3">
|
||||
Platform, sunulan bilgilerin doğruluğu için azami özeni
|
||||
gösterir ancak verilerin eksiksiz veya hatasız olduğunu
|
||||
garanti etmez. Yedek parça alım kararlarında son sorumluluk
|
||||
kullanıcıya aittir.
|
||||
Platform, sunulan bilgilerin doğruluğu için azami özeni gösterir ancak verilerin
|
||||
eksiksiz veya hatasız olduğunu garanti etmez. Yedek parça alım kararlarında son
|
||||
sorumluluk kullanıcıya aittir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
8. Hizmet Değişiklikleri
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">8. Hizmet Değişiklikleri</h2>
|
||||
<p className="mt-3">
|
||||
Sase.tr, hizmet içeriğini, fiyatlandırmayı ve bu koşulları
|
||||
önceden bildirimde bulunarak değiştirme hakkını saklı tutar.
|
||||
Önemli değişiklikler en az 30 gün önce duyurulur.
|
||||
Sase.tr, hizmet içeriğini, fiyatlandırmayı ve bu koşulları önceden bildirimde
|
||||
bulunarak değiştirme hakkını saklı tutar. Önemli değişiklikler en az 30 gün önce
|
||||
duyurulur.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
9. Uyuşmazlık Çözümü
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">9. Uyuşmazlık Çözümü</h2>
|
||||
<p className="mt-3">
|
||||
Bu koşullar Türkiye Cumhuriyeti kanunlarına tabidir.
|
||||
Uyuşmazlıklarda İstanbul mahkemeleri ve icra daireleri
|
||||
yetkilidir.
|
||||
Bu koşullar Türkiye Cumhuriyeti kanunlarına tabidir. Uyuşmazlıklarda İstanbul
|
||||
mahkemeleri ve icra daireleri yetkilidir.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ interface User {
|
||||
image: string | null;
|
||||
role: string;
|
||||
referralCode: string | null;
|
||||
createdAt: string | Date | null;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||