Merge pull request #11 from bytegrip/dev

Dev
This commit was merged in pull request #11.
This commit is contained in:
Daniel
2025-06-24 04:58:14 +03:00
committed by GitHub
28 changed files with 2582 additions and 167 deletions

View File

@@ -19,4 +19,8 @@
<ProjectReference Include="..\Imprink.Domain\Imprink.Domain.csproj" /> <ProjectReference Include="..\Imprink.Domain\Imprink.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Commands\Orders\" />
</ItemGroup>
</Project> </Project>

View File

@@ -8,7 +8,6 @@ namespace Imprink.WebApi.Controllers;
public class SeedingController(Seeder seeder) : ControllerBase public class SeedingController(Seeder seeder) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<bool>> Seed() public async Task<ActionResult<bool>> Seed()
{ {
await seeder.SeedAsync(); await seeder.SeedAsync();

View File

@@ -19,7 +19,6 @@ public class Seeder(ApplicationDbContext context)
private readonly string[] _textileImages = private readonly string[] _textileImages =
[ [
"https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=500", "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=500",
"https://images.unsplash.com/photo-1583743814966-8936f37f4ad2?w=500",
"https://images.unsplash.com/photo-1571945153237-4929e783af4a?w=500", "https://images.unsplash.com/photo-1571945153237-4929e783af4a?w=500",
"https://images.unsplash.com/photo-1618354691373-d851c5c3a990?w=500", "https://images.unsplash.com/photo-1618354691373-d851c5c3a990?w=500",
"https://images.unsplash.com/photo-1576566588028-4147f3842f27?w=500" "https://images.unsplash.com/photo-1576566588028-4147f3842f27?w=500"
@@ -29,14 +28,11 @@ public class Seeder(ApplicationDbContext context)
[ [
"https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=500", "https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=500",
"https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=500", "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=500",
"https://images.unsplash.com/photo-1544966503-7cc5ac882d2e?w=500",
"https://images.unsplash.com/photo-1553062407-98eeb64c6a62?w=500" "https://images.unsplash.com/photo-1553062407-98eeb64c6a62?w=500"
]; ];
private readonly string[] _paperImages = private readonly string[] _paperImages =
[ [
"https://images.unsplash.com/photo-1586281010691-79ab3d0f2102?w=500",
"https://images.unsplash.com/photo-1594736797933-d0401ba2fe65?w=500",
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=500", "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=500",
"https://images.unsplash.com/photo-1584464491033-06628f3a6b7b?w=500" "https://images.unsplash.com/photo-1584464491033-06628f3a6b7b?w=500"
]; ];

View File

@@ -0,0 +1,143 @@
'use client';
import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import PaymentForm from './components/PaymentForm';
import './globals.css';
const stripePromise = loadStripe('');
const products = [
{ id: '1', name: 'Premium Widget', price: 2999, description: 'High-quality widget for professionals' },
{ id: '2', name: 'Standard Widget', price: 1999, description: 'Reliable widget for everyday use' },
{ id: '3', name: 'Basic Widget', price: 999, description: 'Entry-level widget for beginners' }
];
export default function Home() {
const [selectedProduct, setSelectedProduct] = useState(null);
const [clientSecret, setClientSecret] = useState('');
const [orderId, setOrderId] = useState('');
const [loading, setLoading] = useState(false);
const handleProductSelect = async (product) => {
setLoading(true);
setSelectedProduct(product);
const newOrderId = Math.floor(Math.random() * 10000).toString();
setOrderId(newOrderId);
try {
const response = await fetch('https://impr.ink/api/stripe/create-payment-intent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: product.price,
orderId: newOrderId
}),
});
const data = await response.json();
if (data.clientSecret) {
setClientSecret(data.clientSecret);
} else {
console.error('Error creating payment intent:', data.error);
}
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handlePaymentSuccess = () => {
setSelectedProduct(null);
setClientSecret('');
setOrderId('');
};
const handleBackToProducts = () => {
setSelectedProduct(null);
setClientSecret('');
setOrderId('');
};
const appearance = {
theme: 'stripe',
variables: {
colorPrimary: '#0570de',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'Ideal Sans, system-ui, sans-serif',
spacingUnit: '2px',
borderRadius: '4px',
},
};
const options = {
clientSecret,
appearance,
};
return (
<div className="container">
<header>
<h1>🛍 Stripe Payment Demo</h1>
<p>Select a product to purchase</p>
</header>
{!selectedProduct ? (
<div className="products">
<h2>Products</h2>
<div className="product-grid">
{products.map((product) => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p className="description">{product.description}</p>
<p className="price">${(product.price / 100).toFixed(2)}</p>
<button
onClick={() => handleProductSelect(product)}
disabled={loading}
className="select-btn"
>
{loading ? 'Loading...' : 'Select'}
</button>
</div>
))}
</div>
</div>
) : (
<div className="checkout">
<div className="order-summary">
<h2>Order Summary</h2>
<div className="order-details">
<p><strong>Product:</strong> {selectedProduct.name}</p>
<p><strong>Order ID:</strong> {orderId}</p>
<p><strong>Amount:</strong> ${(selectedProduct.price / 100).toFixed(2)}</p>
</div>
</div>
{clientSecret && (
<Elements options={options} stripe={stripePromise}>
<PaymentForm
onSuccess={handlePaymentSuccess}
orderId={orderId}
/>
</Elements>
)}
<button
onClick={handleBackToProducts}
className="back-btn"
>
Back to Products
</button>
</div>
)}
</div>
);
}

View File

@@ -1,14 +1,5 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {}
webpack: (config, { dev }) => {
if (dev) {
config.watchOptions = {
poll: 1000,
aggregateTimeout: 300,
}
}
return config
},
}
export default nextConfig; export default nextConfig;

182
webui/package-lock.json generated
View File

@@ -9,19 +9,25 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@auth0/nextjs-auth0": "^4.6.1", "@auth0/nextjs-auth0": "^4.6.1",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@mui/material-nextjs": "^7.1.1",
"@mui/x-data-grid": "^8.5.2", "@mui/x-data-grid": "^8.5.2",
"@mui/x-date-pickers": "^8.5.2", "@mui/x-date-pickers": "^8.5.2",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1", "@stripe/stripe-js": "^7.3.1",
"axios": "^1.9.0", "axios": "^1.9.0",
"i18next": "^25.2.1",
"lucide-react": "^0.516.0", "lucide-react": "^0.516.0",
"next": "15.3.3", "next": "15.3.3",
"next-i18next": "^15.4.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-i18next": "^15.5.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -910,6 +916,41 @@
} }
} }
}, },
"node_modules/@mui/material-nextjs": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@mui/material-nextjs/-/material-nextjs-7.1.1.tgz",
"integrity": "sha512-6/tjmViYMI7XIqDTqK+n4t5B07YfVDq72emdBy/o8FLHsV7u477Ro0Aago2MQu8FrBQWDvzvvRkynIb02GjDBQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"next": "^13.0.0 || ^14.0.0 || ^15.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/cache": {
"optional": true
},
"@emotion/server": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/private-theming": { "node_modules/@mui/private-theming": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.1.tgz", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.1.tgz",
@@ -1650,6 +1691,16 @@
"tailwindcss": "4.1.8" "tailwindcss": "4.1.8"
} }
}, },
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz",
"integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==",
"license": "MIT",
"dependencies": {
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0"
}
},
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -1667,7 +1718,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.7.tgz",
"integrity": "sha512-BnsPLV43ddr05N71gaGzyZ5hzkCmGwhMvYc8zmvI8Ci1bRkkDSzDDVfAXfN2tk748OwI7ediiPX6PfT9p0QGVg==", "integrity": "sha512-BnsPLV43ddr05N71gaGzyZ5hzkCmGwhMvYc8zmvI8Ci1bRkkDSzDDVfAXfN2tk748OwI7ediiPX6PfT9p0QGVg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -1854,6 +1904,17 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js": {
"version": "3.43.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz",
"integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -2201,6 +2262,52 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.2.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz",
"integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.1"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-fs-backend": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.6.0.tgz",
"integrity": "sha512-3ZlhNoF9yxnM8pa8bWp5120/Ob6t4lVl1l/tbLmkml/ei3ud8IWySCHt2lrY5xWRlSU5D9IV2sm5bEbGuTqwTw==",
"license": "MIT"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2705,6 +2812,42 @@
} }
} }
}, },
"node_modules/next-i18next": {
"version": "15.4.2",
"resolved": "https://registry.npmjs.org/next-i18next/-/next-i18next-15.4.2.tgz",
"integrity": "sha512-zgRxWf7kdXtM686ecGIBQL+Bq0+DqAhRlasRZ3vVF0TmrNTWkVhs52n//oU3Fj5O7r/xOKkECDUwfOuXVwTK/g==",
"funding": [
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://locize.com"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2",
"@types/hoist-non-react-statics": "^3.3.6",
"core-js": "^3",
"hoist-non-react-statics": "^3.3.2",
"i18next-fs-backend": "^2.6.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"i18next": ">= 23.7.13",
"next": ">= 12.0.0",
"react": ">= 17.0.2",
"react-i18next": ">= 13.5.0"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -2875,6 +3018,32 @@
"react": "^19.1.0" "react": "^19.1.0"
} }
}, },
"node_modules/react-i18next": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz",
"integrity": "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
@@ -3133,6 +3302,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@@ -10,19 +10,25 @@
}, },
"dependencies": { "dependencies": {
"@auth0/nextjs-auth0": "^4.6.1", "@auth0/nextjs-auth0": "^4.6.1",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@mui/material-nextjs": "^7.1.1",
"@mui/x-data-grid": "^8.5.2", "@mui/x-data-grid": "^8.5.2",
"@mui/x-date-pickers": "^8.5.2", "@mui/x-date-pickers": "^8.5.2",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1", "@stripe/stripe-js": "^7.3.1",
"axios": "^1.9.0", "axios": "^1.9.0",
"i18next": "^25.2.1",
"lucide-react": "^0.516.0", "lucide-react": "^0.516.0",
"next": "15.3.3", "next": "15.3.3",
"next-i18next": "^15.4.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-i18next": "^15.5.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

BIN
webui/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,21 @@
'use client';
import { useEffect } from 'react';
import axios from 'axios';
export default function ClientLayoutEffect() {
useEffect(() => {
async function fetchData() {
try {
const res = await axios.get('/token');
console.log('Token response:', res.data);
} catch (error) {
console.error('Token fetch error:', error);
}
}
fetchData().then(r => console.log("Ok"));
}, []);
return null;
}

View File

@@ -0,0 +1,352 @@
'use client'
import {
AppBar,
Button,
Toolbar,
Typography,
Avatar,
Box,
IconButton,
Menu,
MenuItem,
Divider,
useMediaQuery,
useTheme,
Paper
} from "@mui/material";
import { useState } from "react";
import { useUser } from "@auth0/nextjs-auth0";
import {
Menu as MenuIcon,
Home,
PhotoLibrary,
ShoppingBag,
Store,
Dashboard,
AdminPanelSettings,
Api,
BugReport
} from "@mui/icons-material";
import ThemeToggleButton from "@/app/components/theme/ThemeToggleButton";
import useRoles from "@/app/components/hooks/useRoles";
export default function ImprinkAppBar() {
const { user, error, isLoading } = useUser();
const { isMerchant, isAdmin } = useRoles();
const [anchorEl, setAnchorEl] = useState(null);
const theme = useTheme();
const { isDarkMode, toggleTheme } = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const handleMenuOpen = (event) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const navigationLinks = [
{ label: 'Home', href: '/', icon: <Home />, show: true },
{ label: 'Gallery', href: '/gallery', icon: <PhotoLibrary />, show: true },
{ label: 'Orders', href: '/orders', icon: <ShoppingBag />, show: true },
{ label: 'Merchant', href: '/merchant', icon: <Store />, show: isMerchant },
];
const adminLinks = [
{ label: 'Dashboard', href: '/dashboard', icon: <Dashboard />, show: isMerchant },
{ label: 'Admin', href: '/admin', icon: <AdminPanelSettings />, show: isAdmin },
{ label: 'Swagger', href: '/swagger', icon: <Api />, show: isAdmin },
{ label: 'SEQ', href: '/seq', icon: <BugReport />, show: isAdmin },
];
const visibleLinks = navigationLinks.filter(link => link.show);
const visibleAdminLinks = adminLinks.filter(link => link.show);
const renderDesktopNavigation = () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{visibleLinks.map((link) => (
<Button
key={link.label}
color="inherit"
href={link.href}
startIcon={link.icon}
sx={{
minWidth: 'auto',
px: 2,
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.1)'
}
}}
>
{link.label}
</Button>
))}
</Box>
);
const renderAdminBar = () => {
if (!visibleAdminLinks.length || isMobile) return null;
return (
<Paper
elevation={1}
sx={{
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)',
borderTop: `1px solid ${theme.palette.divider}`,
borderRadius: 0,
py: 1.5,
px: 2
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 3,
justifyContent: 'center',
maxWidth: '1200px',
mx: 'auto'
}}>
<Typography
variant="caption"
sx={{
mr: 2,
color: 'text.secondary',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 0.5
}}
>
Admin Tools
</Typography>
{visibleAdminLinks.map((link) => (
<Button
key={link.label}
href={link.href}
startIcon={link.icon}
variant="text"
size="small"
sx={{
minWidth: 'auto',
px: 2,
py: 0.5,
color: 'text.primary',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
{link.label}
</Button>
))}
</Box>
</Paper>
);
};
const renderMobileMenu = () => (
<>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
onClick={handleMenuOpen}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
sx: {
mt: 1,
minWidth: 200,
'& .MuiMenuItem-root': {
px: 2,
py: 1.5,
gap: 2
}
}
}}
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
>
<Divider />
{[...visibleLinks, ...visibleAdminLinks].map((link) => (
<MenuItem
key={link.label}
onClick={handleMenuClose}
component="a"
href={link.href}
sx={{
color: 'inherit',
textDecoration: 'none',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
{link.icon}
<Typography variant="body2">{link.label}</Typography>
</MenuItem>
))}
{!isLoading && (
<>
<Divider />
{user ? (
<>
<MenuItem
sx={{
opacity: 1,
'&:hover': { backgroundColor: 'transparent' },
cursor: 'default'
}}
onClick={(e) => e.preventDefault()}
>
<Avatar
src={user.picture}
alt={user.name}
sx={{ width: 24, height: 24 }}
/>
<Typography variant="body2" noWrap>
{user.name}
</Typography>
</MenuItem>
<MenuItem onClick={(e) => e.stopPropagation()}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<Typography variant="body2">Theme</Typography>
<Box sx={{ ml: 'auto' }}>
<ThemeToggleButton />
</Box>
</Box>
</MenuItem>
<MenuItem
component="a"
href="/auth/logout"
onClick={handleMenuClose}
sx={{ color: 'error.main' }}
>
<Typography variant="body2">Logout</Typography>
</MenuItem>
</>
) : (
<>
<MenuItem
component="a"
href="/auth/login"
onClick={handleMenuClose}
>
<Typography variant="body2">Login</Typography>
</MenuItem>
<MenuItem
component="a"
href="/auth/login"
onClick={handleMenuClose}
sx={{
color: 'primary.main',
fontWeight: 'bold'
}}
>
<Typography variant="body2">Sign Up</Typography>
</MenuItem>
<MenuItem onClick={toggleTheme}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<Typography variant="body2">Theme</Typography>
<Box sx={{ ml: 'auto' }}>
{isDarkMode ? '🌙' : '☀️'}
</Box>
</Box>
</MenuItem>
</>
)}
</>
)}
</Menu>
</>
);
return (
<>
<AppBar position="static">
<Toolbar>
{isMobile && renderMobileMenu()}
<Typography
variant="h6"
component="div"
sx={{
flexGrow: isMobile ? 1 : 0,
mr: isMobile ? 0 : 4
}}
>
Imprink
</Typography>
{!isMobile && (
<>
<Box sx={{ flexGrow: 1 }}>
{renderDesktopNavigation()}
</Box>
<ThemeToggleButton sx={{ mr: 2 }} />
{isLoading ? (
<Box sx={{ ml: 2 }}>
<Typography variant="body2">Loading...</Typography>
</Box>
) : user ? (
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
<Avatar
src={user.picture}
alt={user.name}
sx={{ width: 32, height: 32, mr: 1 }}
/>
<Typography variant="body2" sx={{ mr: 2, color: 'inherit' }}>
{user.name}
</Typography>
<Button
color="inherit"
href="/auth/logout"
>
Logout
</Button>
</Box>
) : (
<Box sx={{ ml: 2 }}>
<Button
color="inherit"
href="/auth/login"
sx={{ mr: 1 }}
>
Login
</Button>
<Button
variant="contained"
href="/auth/login"
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': {
bgcolor: 'primary.dark'
}
}}
>
Sign Up
</Button>
</Box>
)}
</>
)}
</Toolbar>
</AppBar>
{renderAdminBar()}
</>
)
}

View File

@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react';
import { useUser } from '@auth0/nextjs-auth0';
import clientApi from '@/lib/clientApi';
export const useRoles = () => {
const { user } = useUser();
const [roles, setRoles] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUserRoles = async () => {
if (!user) {
setRoles([]);
setError(null);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await clientApi.get('/users/me/roles');
const userRoles = response.data.map(role => role.roleName.toLowerCase());
setRoles(userRoles);
} catch (err) {
console.error('Failed to fetch user roles:', err);
setError(err);
setRoles([]);
} finally {
setIsLoading(false);
}
};
fetchUserRoles().then(r => console.log(r));
}, [user]);
const hasRole = (roleName) => {
return roles.includes(roleName.toLowerCase());
};
const hasAnyRole = (roleNames) => {
return roleNames.some(roleName => hasRole(roleName));
};
const hasAllRoles = (roleNames) => {
return roleNames.every(roleName => hasRole(roleName));
};
// Common role checks based on your existing logic
const isMerchant = hasAnyRole(['merchant', 'admin']);
const isAdmin = hasRole('admin');
const isCustomer = hasRole('customer');
return {
roles,
isLoading,
error,
hasRole,
hasAnyRole,
hasAllRoles,
isMerchant,
isAdmin,
isCustomer
};
};
export default useRoles;

View File

@@ -0,0 +1,18 @@
'use client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { darkTheme } from './darkTheme';
import { lightTheme } from './lightTheme';
import { useTheme } from './ThemeContext';
export default function MuiThemeProvider({ children }) {
const { isDarkMode } = useTheme();
return (
<ThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
<CssBaseline />
{children}
</ThemeProvider>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext({ isDarkMode: true });
function getInitialTheme() {
if (typeof window === 'undefined') {
return null;
}
const savedTheme = localStorage.getItem('theme-preference');
if (savedTheme) {
return savedTheme === 'dark';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
export function ThemeContextProvider({ children }) {
const [isDarkMode, setIsDarkMode] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
const initialTheme = getInitialTheme();
if (initialTheme !== null) {
setIsDarkMode(initialTheme);
}
setIsInitialized(true);
}, []);
const toggleTheme = () => {
const newTheme = !isDarkMode;
setIsDarkMode(newTheme);
if (typeof window !== 'undefined') {
localStorage.setItem('theme-preference', newTheme ? 'dark' : 'light');
}
};
if (!isInitialized) {
return (
<div style={{
visibility: 'hidden',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%'
}}>
{children}
</div>
);
}
return (
<ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeContextProvider');
}
return context;
}

View File

@@ -0,0 +1,25 @@
'use client';
import { IconButton } from '@mui/material';
import { useTheme } from './ThemeContext';
export default function ThemeToggleButton() {
const { isDarkMode, toggleTheme } = useTheme();
return (
<IconButton
onClick={toggleTheme}
color="inherit"
sx={{
width: 40,
height: 40,
borderRadius: '8px',
'&:hover': {
backgroundColor: 'rgba(99, 102, 241, 0.1)',
},
}}
>
{isDarkMode ? '🌙' : '☀️'}
</IconButton>
);
}

View File

@@ -0,0 +1,279 @@
'use client'
import { createTheme } from '@mui/material/styles';
export const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#6366f1',
light: '#818cf8',
dark: '#4f46e5',
contrastText: '#ffffff',
},
secondary: {
main: '#f59e0b',
light: '#fbbf24',
dark: '#d97706',
contrastText: '#000000',
},
background: {
default: '#0f0f23',
paper: '#1a1a2e',
},
surface: {
main: '#16213e',
},
text: {
primary: '#f8fafc',
secondary: '#cbd5e1',
},
error: {
main: '#ef4444',
light: '#f87171',
dark: '#dc2626',
},
warning: {
main: '#f59e0b',
light: '#fbbf24',
dark: '#d97706',
},
info: {
main: '#06b6d4',
light: '#22d3ee',
dark: '#0891b2',
},
success: {
main: '#10b981',
light: '#34d399',
dark: '#059669',
},
divider: '#334155',
},
typography: {
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
h1: {
fontSize: '2.5rem',
fontWeight: 700,
lineHeight: 1.2,
letterSpacing: '-0.025em',
},
h2: {
fontSize: '2rem',
fontWeight: 600,
lineHeight: 1.3,
letterSpacing: '-0.025em',
},
h3: {
fontSize: '1.5rem',
fontWeight: 600,
lineHeight: 1.4,
letterSpacing: '-0.015em',
},
h4: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1.4,
},
h5: {
fontSize: '1.125rem',
fontWeight: 600,
lineHeight: 1.5,
},
h6: {
fontSize: '1rem',
fontWeight: 600,
lineHeight: 1.5,
},
body1: {
fontSize: '1rem',
lineHeight: 1.6,
fontWeight: 400,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.6,
fontWeight: 400,
},
button: {
fontSize: '0.875rem',
fontWeight: 500,
textTransform: 'none',
letterSpacing: '0.025em',
},
},
shape: {
borderRadius: 12,
},
shadows: [
'none',
'0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
],
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
scrollbarWidth: 'thin',
scrollbarColor: '#6366f1 #1a1a2e',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#1a1a2e',
},
'&::-webkit-scrollbar-thumb': {
background: '#6366f1',
borderRadius: '4px',
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: '8px',
padding: '10px 24px',
fontSize: '0.875rem',
fontWeight: 500,
boxShadow: 'none',
'&:hover': {
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
transform: 'translateY(-1px)',
},
'&:active': {
transform: 'translateY(0)',
},
},
contained: {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #5b21b6 0%, #7c3aed 100%)',
},
},
outlined: {
borderWidth: '1.5px',
'&:hover': {
borderWidth: '1.5px',
backgroundColor: 'rgba(99, 102, 241, 0.08)',
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#6366f1',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#6366f1',
borderWidth: '2px',
},
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
background: 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)',
border: '1px solid rgba(99, 102, 241, 0.1)',
backdropFilter: 'blur(20px)',
'&:hover': {
border: '1px solid rgba(99, 102, 241, 0.2)',
transform: 'translateY(-2px)',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
},
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
backgroundColor: '#1a1a2e',
border: '1px solid rgba(99, 102, 241, 0.1)',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
background: 'rgba(26, 26, 46, 0.8)',
backdropFilter: 'blur(20px)',
borderBottom: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
background: 'linear-gradient(180deg, #1a1a2e 0%, #16213e 100%)',
border: 'none',
borderRight: '1px solid rgba(99, 102, 241, 0.1)',
},
},
},
MuiChip: {
styleOverrides: {
root: {
background: 'rgba(99, 102, 241, 0.1)',
color: '#818cf8',
border: '1px solid rgba(99, 102, 241, 0.2)',
'&:hover': {
background: 'rgba(99, 102, 241, 0.2)',
},
},
},
},
MuiTab: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 500,
fontSize: '0.875rem',
'&.Mui-selected': {
color: '#6366f1',
},
},
},
},
MuiTabs: {
styleOverrides: {
indicator: {
background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
height: '3px',
borderRadius: '3px',
},
},
},
},
});

View File

@@ -0,0 +1,279 @@
'use client'
import { createTheme } from '@mui/material/styles';
export const lightTheme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#6366f1',
light: '#818cf8',
dark: '#4f46e5',
contrastText: '#ffffff',
},
secondary: {
main: '#f59e0b',
light: '#fbbf24',
dark: '#d97706',
contrastText: '#000000',
},
background: {
default: '#f8fafc',
paper: '#ffffff',
},
surface: {
main: '#f1f5f9',
},
text: {
primary: '#0f172a',
secondary: '#475569',
},
error: {
main: '#ef4444',
light: '#f87171',
dark: '#dc2626',
},
warning: {
main: '#f59e0b',
light: '#fbbf24',
dark: '#d97706',
},
info: {
main: '#06b6d4',
light: '#22d3ee',
dark: '#0891b2',
},
success: {
main: '#10b981',
light: '#34d399',
dark: '#059669',
},
divider: '#e2e8f0',
},
typography: {
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
h1: {
fontSize: '2.5rem',
fontWeight: 700,
lineHeight: 1.2,
letterSpacing: '-0.025em',
},
h2: {
fontSize: '2rem',
fontWeight: 600,
lineHeight: 1.3,
letterSpacing: '-0.025em',
},
h3: {
fontSize: '1.5rem',
fontWeight: 600,
lineHeight: 1.4,
letterSpacing: '-0.015em',
},
h4: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1.4,
},
h5: {
fontSize: '1.125rem',
fontWeight: 600,
lineHeight: 1.5,
},
h6: {
fontSize: '1rem',
fontWeight: 600,
lineHeight: 1.5,
},
body1: {
fontSize: '1rem',
lineHeight: 1.6,
fontWeight: 400,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.6,
fontWeight: 400,
},
button: {
fontSize: '0.875rem',
fontWeight: 500,
textTransform: 'none',
letterSpacing: '0.025em',
},
},
shape: {
borderRadius: 12,
},
shadows: [
'none',
'0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
],
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
scrollbarWidth: 'thin',
scrollbarColor: '#6366f1 #f1f5f9',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f5f9',
},
'&::-webkit-scrollbar-thumb': {
background: '#6366f1',
borderRadius: '4px',
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: '8px',
padding: '10px 24px',
fontSize: '0.875rem',
fontWeight: 500,
boxShadow: 'none',
'&:hover': {
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
transform: 'translateY(-1px)',
},
'&:active': {
transform: 'translateY(0)',
},
},
contained: {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #5b21b6 0%, #7c3aed 100%)',
},
},
outlined: {
borderWidth: '1.5px',
'&:hover': {
borderWidth: '1.5px',
backgroundColor: 'rgba(99, 102, 241, 0.08)',
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
backgroundColor: 'rgba(241, 245, 249, 0.5)',
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#6366f1',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#6366f1',
borderWidth: '2px',
},
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
background: 'linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)',
border: '1px solid rgba(99, 102, 241, 0.1)',
backdropFilter: 'blur(20px)',
'&:hover': {
border: '1px solid rgba(99, 102, 241, 0.2)',
transform: 'translateY(-2px)',
boxShadow: '0 20px 40px rgba(99, 102, 241, 0.15)',
},
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
backgroundColor: '#ffffff',
border: '1px solid rgba(99, 102, 241, 0.1)',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
background: 'rgba(255, 255, 255, 0.8)',
backdropFilter: 'blur(20px)',
borderBottom: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.1)',
color: '#0f172a',
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
border: 'none',
borderRight: '1px solid rgba(99, 102, 241, 0.1)',
},
},
},
MuiChip: {
styleOverrides: {
root: {
background: 'rgba(99, 102, 241, 0.7)',
border: '1px solid rgba(99, 102, 241, 0.2)',
'&:hover': {
background: 'rgba(99, 102, 241, 0.9)',
},
},
},
},
MuiTab: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 500,
fontSize: '0.875rem',
'&.Mui-selected': {
color: '#6366f1',
},
},
},
},
MuiTabs: {
styleOverrides: {
indicator: {
background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
height: '3px',
borderRadius: '3px',
},
},
},
},
});

View File

@@ -0,0 +1,661 @@
'use client';
import {
Box,
Container,
Typography,
Button,
Card,
CardContent,
CardMedia,
Grid,
Chip,
CircularProgress,
Alert,
TextField,
InputAdornment,
Pagination,
FormControl,
Select,
MenuItem,
InputLabel,
Drawer,
List,
ListItem,
ListItemButton,
ListItemText,
Collapse,
IconButton,
useMediaQuery,
useTheme,
Paper,
Slider,
Switch,
FormControlLabel
} from '@mui/material';
import { useState, useEffect, useCallback } from 'react';
import {
Search,
ExpandLess,
ExpandMore,
Close as CloseIcon,
Tune as TuneIcon
} from '@mui/icons-material';
import clientApi from "@/lib/clientApi";
import useRoles from "@/app/components/hooks/useRoles";
const SORT_OPTIONS = [
{ value: 'Name-ASC', label: 'Name (A-Z)' },
{ value: 'Name-DESC', label: 'Name (Z-A)' },
{ value: 'Price-ASC', label: 'Price (Low to High)' },
{ value: 'Price-DESC', label: 'Price (High to Low)' },
{ value: 'CreatedDate-DESC', label: 'Newest First' },
{ value: 'CreatedDate-ASC', label: 'Oldest First' }
];
const PAGE_SIZE_OPTIONS = [12, 24, 48, 96];
export default function GalleryPage() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { isAdmin } = useRoles();
const heightOffset = isAdmin ? 192 : 128;
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [totalPages, setTotalPages] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [categories, setCategories] = useState([]);
const [categoriesLoading, setCategoriesLoading] = useState(true);
const [expandedCategories, setExpandedCategories] = useState(new Set());
const [filters, setFilters] = useState({
pageNumber: 1,
pageSize: 24,
searchTerm: '',
categoryId: '',
minPrice: 0,
maxPrice: 1000,
isActive: true,
isCustomizable: null,
sortBy: 'Name',
sortDirection: 'ASC'
});
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [priceRange, setPriceRange] = useState([0, 1000]);
const [searchInput, setSearchInput] = useState('');
useEffect(() => {
const fetchCategories = async () => {
try {
const response = await clientApi.get('/products/categories');
setCategories(response.data);
} catch (err) {
console.error('Error fetching categories:', err);
} finally {
setCategoriesLoading(false);
}
};
fetchCategories();
}, []);
const fetchProducts = useCallback(async () => {
setLoading(true);
try {
const params = {
PageNumber: filters.pageNumber,
PageSize: filters.pageSize,
IsActive: filters.isActive,
SortBy: filters.sortBy,
SortDirection: filters.sortDirection
};
if (filters.searchTerm) params.SearchTerm = filters.searchTerm;
if (filters.categoryId) params.CategoryId = filters.categoryId;
if (filters.minPrice > 0) params.MinPrice = filters.minPrice;
if (filters.maxPrice < 1000) params.MaxPrice = filters.maxPrice;
if (filters.isCustomizable !== null) params.IsCustomizable = filters.isCustomizable;
const response = await clientApi.get('/products/', { params });
setProducts(response.data.items);
setTotalPages(response.data.totalPages);
setTotalCount(response.data.totalCount);
} catch (err) {
setError('Failed to load products');
console.error('Error fetching products:', err);
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
const handleFilterChange = (key, value) => {
setFilters(prev => ({
...prev,
[key]: value,
pageNumber: key !== 'pageNumber' ? 1 : value
}));
};
const handleSearch = () => {
handleFilterChange('searchTerm', searchInput);
};
const handlePriceRangeChange = (event, newValue) => {
setPriceRange(newValue);
};
const handlePriceRangeCommitted = (event, newValue) => {
handleFilterChange('minPrice', newValue[0]);
handleFilterChange('maxPrice', newValue[1]);
};
const handleSortChange = (value) => {
const [sortBy, sortDirection] = value.split('-');
handleFilterChange('sortBy', sortBy);
handleFilterChange('sortDirection', sortDirection);
};
const toggleCategoryExpansion = (categoryId) => {
const newExpanded = new Set(expandedCategories);
if (newExpanded.has(categoryId)) {
newExpanded.delete(categoryId);
} else {
newExpanded.add(categoryId);
}
setExpandedCategories(newExpanded);
};
const getChildCategories = (parentId) => {
return categories.filter(cat => cat.parentCategoryId === parentId);
};
const getParentCategories = () => {
return categories.filter(cat => !cat.parentCategoryId);
};
const CategorySidebar = () => (
<Box sx={{
width: 300,
height: '100%',
display: 'flex',
flexDirection: 'column'
}}>
<Typography variant="h6" gutterBottom sx={{
fontWeight: 'bold',
mb: 2,
p: 2,
pb: 1,
flexShrink: 0,
borderBottom: 1,
borderColor: 'divider'
}}>
Categories
</Typography>
<Box sx={{
flex: 1,
overflowY: 'auto',
px: 2,
minHeight: 0
}}>
<List dense>
<ListItem disablePadding>
<ListItemButton
selected={!filters.categoryId}
onClick={() => handleFilterChange('categoryId', '')}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemText primary="All Products" />
</ListItemButton>
</ListItem>
{getParentCategories().map((category) => {
const childCategories = getChildCategories(category.id);
const hasChildren = childCategories.length > 0;
const isExpanded = expandedCategories.has(category.id);
return (
<Box key={category.id}>
<ListItem disablePadding>
<ListItemButton
selected={filters.categoryId === category.id}
onClick={() => handleFilterChange('categoryId', category.id)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemText primary={category.name} />
{hasChildren && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleCategoryExpansion(category.id);
}}
>
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
)}
</ListItemButton>
</ListItem>
{hasChildren && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{childCategories.map((childCategory) => (
<ListItem key={childCategory.id} disablePadding sx={{ pl: 3 }}>
<ListItemButton
selected={filters.categoryId === childCategory.id}
onClick={() => handleFilterChange('categoryId', childCategory.id)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemText
primary={childCategory.name}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.9rem' } }}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Collapse>
)}
</Box>
);
})}
</List>
</Box>
<Box sx={{
p: 2,
borderTop: 1,
borderColor: 'divider',
flexShrink: 0,
backgroundColor: 'background.paper'
}}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold' }}>
Price Range
</Typography>
<Box sx={{ px: 1, mb: 2 }}>
<Slider
value={priceRange}
onChange={handlePriceRangeChange}
onChangeCommitted={handlePriceRangeCommitted}
valueLabelDisplay="auto"
min={0}
max={1000}
step={5}
valueLabelFormat={(value) => `$${value}`}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
<Typography variant="caption">${priceRange[0]}</Typography>
<Typography variant="caption">${priceRange[1]}</Typography>
</Box>
</Box>
<FormControlLabel
control={
<Switch
checked={filters.isCustomizable === true}
onChange={(e) => handleFilterChange('isCustomizable', e.target.checked ? true : null)}
/>
}
label="Customizable Only"
sx={{ mb: 0 }}
/>
</Box>
</Box>
);
return (
<Container maxWidth="xl" sx={{ py: { xs: 1, sm: 2, md: 4 } }}>
<Box sx={{
display: 'flex',
gap: { xs: 0, md: 3 },
minHeight: { xs: 'auto', md: `calc(100vh - ${heightOffset}px)` }
}}>
{!isMobile && (
<Box sx={{ flexShrink: 0 }}>
<Paper
elevation={1}
sx={{
height: `calc(100vh - ${heightOffset}px)`,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}
>
{categoriesLoading ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={40} />
</Box>
) : (
<CategorySidebar />
)}
</Paper>
</Box>
)}
<Box sx={{
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
maxHeight: { xs: 'none', md: `calc(100vh - ${heightOffset}px)` }
}}>
<Box sx={{
flex: 1,
overflowY: { xs: 'visible', md: 'auto' },
minHeight: 0,
pr: { xs: 0, md: 1 }
}}>
<Box sx={{ mb: { xs: 2, sm: 2, md: 3 } }}>
<Typography variant="h3" gutterBottom sx={{
fontWeight: 'bold',
fontSize: { xs: '2rem', sm: '2rem', md: '2rem' }
}}>
Product Gallery
</Typography>
<Typography variant="h6" color="text.secondary" sx={{
fontSize: { xs: '1rem', sm: '1rem' }
}}>
Explore our complete collection of customizable products
</Typography>
</Box>
<Paper elevation={1} sx={{
p: { xs: 1, sm: 1, md: 1.5 },
mb: { xs: 1, sm: 2 }
}}>
<Grid container spacing={{ xs: 1, sm: 2 }} alignItems="center">
{isMobile && (
<Grid item>
<IconButton
onClick={() => setMobileDrawerOpen(true)}
sx={{ mr: 1 }}
size="small"
>
<TuneIcon />
</IconButton>
</Grid>
)}
<Grid item xs={12} sm={6} md={4}>
<TextField
fullWidth
placeholder="Search products..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
size={isMobile ? "small" : "medium"}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search fontSize={isMobile ? "small" : "medium"} />
</InputAdornment>
),
endAdornment: searchInput && (
<InputAdornment position="end">
<IconButton
size="small"
onClick={handleSearch}
edge="end"
>
<Search fontSize="small" />
</IconButton>
</InputAdornment>
)
}}
/>
</Grid>
<Grid item xs={6} sm={3} md={3}>
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
<InputLabel>Sort By</InputLabel>
<Select
value={`${filters.sortBy}-${filters.sortDirection}`}
label="Sort By"
onChange={(e) => handleSortChange(e.target.value)}
>
{SORT_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={6} sm={3} md={2}>
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
<InputLabel>Per Page</InputLabel>
<Select
value={filters.pageSize}
label="Per Page"
onChange={(e) => handleFilterChange('pageSize', e.target.value)}
>
{PAGE_SIZE_OPTIONS.map((size) => (
<MenuItem key={size} value={size}>
{size}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={3} sx={{ textAlign: { xs: 'center', md: 'right' } }}>
<Typography variant="body2" color="text.secondary" sx={{
fontSize: { xs: '0.75rem', sm: '0.875rem' }
}}>
Showing {products.length} of {totalCount} products
</Typography>
</Grid>
</Grid>
</Paper>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 4, md: 8 } }}>
<CircularProgress size={isMobile ? 40 : 60} />
</Box>
)}
{error && (
<Alert severity="error" sx={{ mb: { xs: 2, md: 4 } }}>
{error}
</Alert>
)}
{!loading && !error && products.length === 0 && (
<Paper sx={{ p: { xs: 3, md: 6 }, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
No products found
</Typography>
<Typography variant="body2" color="text.secondary">
Try adjusting your search criteria or filters
</Typography>
</Paper>
)}
{!loading && !error && products.length > 0 && (
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: { xs: 1, sm: 1.5, md: 2 },
mb: { xs: 2, md: 4 }
}}>
{products.map((product) => (
<Card
key={product.id}
sx={{
width: {
xs: 'calc(50% - 4px)',
sm: 'calc(50% - 12px)',
lg: 'calc(33.333% - 16px)'
},
maxWidth: { xs: 'none', sm: 350, lg: 370 },
height: { xs: 300, sm: 380, lg: 420 },
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
'&:hover': {
transform: { xs: 'none', sm: 'translateY(-4px)', md: 'translateY(-8px)' },
boxShadow: { xs: 2, sm: 4, md: 6 }
}
}}
>
<CardMedia
component="img"
image={product.imageUrl || '/placeholder-product.jpg'}
alt={product.name}
sx={{
objectFit: 'cover',
height: { xs: 120, sm: 160, lg: 180 }
}}
/>
<CardContent sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
p: { xs: 1, sm: 1.5, lg: 2 },
'&:last-child': { pb: { xs: 1, sm: 1.5, lg: 2 } }
}}>
<Typography variant="h6" gutterBottom sx={{
fontWeight: 'bold',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: { xs: '0.9rem', sm: '1rem', md: '1.1rem' },
mb: { xs: 0.5, sm: 1 }
}}>
{product.name}
</Typography>
{product.category && (
<Chip
label={product.category.name}
size="small"
color="secondary"
sx={{
alignSelf: 'flex-start',
mb: { xs: 0.5, sm: 1 },
fontSize: { xs: '0.65rem', sm: '0.75rem' },
height: { xs: 20, sm: 24 }
}}
/>
)}
<Typography variant="body2" color="text.secondary" sx={{
flexGrow: 1,
mb: { xs: 1, sm: 1.5 },
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: { xs: 2, sm: 2, md: 3 },
WebkitBoxOrient: 'vertical',
minHeight: { xs: 28, sm: 32, md: 48 },
fontSize: { xs: '0.75rem', sm: '0.8rem', md: '0.875rem' }
}}>
{product.description}
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: { xs: 1, sm: 1.5 }
}}>
<Typography variant="subtitle1" color="primary" sx={{
fontWeight: 'bold',
fontSize: { xs: '0.85rem', sm: '0.95rem', md: '1rem' }
}}>
From ${product.basePrice?.toFixed(2)}
</Typography>
{product.isCustomizable && (
<Chip
label="Custom"
color="primary"
size="small"
sx={{
fontSize: { xs: '0.65rem', sm: '0.75rem' },
height: { xs: 20, sm: 24 }
}}
/>
)}
</Box>
<Button
variant="contained"
fullWidth
size={isMobile ? "small" : "medium"}
sx={{
fontSize: { xs: '0.75rem', sm: '0.875rem', md: '1rem' },
py: { xs: 0.5, sm: 1, md: 1.5 }
}}
>
Customize
</Button>
</CardContent>
</Card>
))}
</Box>
)}
{!loading && !error && totalPages > 1 && (
<Box sx={{
display: 'flex',
justifyContent: 'center',
pb: { xs: 2, md: 4 }
}}>
<Pagination
count={totalPages}
page={filters.pageNumber}
onChange={(e, page) => handleFilterChange('pageNumber', page)}
color="primary"
size={isMobile ? 'small' : 'large'}
showFirstButton={!isMobile}
showLastButton={!isMobile}
/>
</Box>
)}
</Box>
</Box>
</Box>
<Drawer
anchor="left"
open={mobileDrawerOpen}
onClose={() => setMobileDrawerOpen(false)}
ModalProps={{ keepMounted: true }}
PaperProps={{
sx: {
width: 280,
display: 'flex',
flexDirection: 'column'
}
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2, borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
Filters
</Typography>
<IconButton onClick={() => setMobileDrawerOpen(false)}>
<CloseIcon />
</IconButton>
</Box>
{categoriesLoading ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={40} />
</Box>
) : (
<CategorySidebar />
)}
</Drawer>
</Container>
);
}

View File

@@ -1,14 +1,31 @@
export const metadata = { import { Inter } from 'next/font/google';
title: 'Stripe Payment Demo', import MuiThemeProvider from './components/theme/MuiThemeProvider';
description: 'Stripe payment integration demo with Next.js App Router', import { ThemeContextProvider } from './components/theme/ThemeContext';
} import { AppRouterCacheProvider } from "@mui/material-nextjs/v13-appRouter";
import ImprinkAppBar from "@/app/components/ImprinkAppBar";
import ClientLayoutEffect from "@/app/components/ClientLayoutEffect";
export default function RootLayout({ children }) { const inter = Inter({ subsets: ['latin'] });
export const metadata = {
title: 'Imprink',
description: 'Turn your dreams into colorful realities!',
};
export default function RootLayout({children}) {
return ( return (
<html lang="en"> <html lang="en">
<body> <body className={inter.className}>
{children} <AppRouterCacheProvider>
</body> <ThemeContextProvider>
<MuiThemeProvider>
<ClientLayoutEffect/>
<ImprinkAppBar />
{children}
</MuiThemeProvider>
</ThemeContextProvider>
</AppRouterCacheProvider>
</body>
</html> </html>
) );
} }

View File

@@ -1,143 +1,426 @@
'use client'; 'use client';
import { useState } from 'react'; import {
import { loadStripe } from '@stripe/stripe-js'; Box,
import { Elements } from '@stripe/react-stripe-js'; Container,
import PaymentForm from './components/PaymentForm'; Typography,
import './globals.css'; Button,
Card,
CardContent,
CardMedia,
Grid,
Chip,
CircularProgress,
Alert
} from '@mui/material';
import { useState, useEffect } from 'react';
import { ShoppingCart, Palette, ImageOutlined, CreditCard, LocalShipping, CheckCircle } from '@mui/icons-material';
import clientApi from "@/lib/clientApi";
const stripePromise = loadStripe(''); export default function HomePage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const products = [ useEffect(() => {
{ id: '1', name: 'Premium Widget', price: 2999, description: 'High-quality widget for professionals' }, const fetchProducts = async () => {
{ id: '2', name: 'Standard Widget', price: 1999, description: 'Reliable widget for everyday use' }, try {
{ id: '3', name: 'Basic Widget', price: 999, description: 'Entry-level widget for beginners' } const response = await clientApi.get('/products/', {
]; params: {
PageSize: 3,
export default function Home() { PageNumber: 1,
const [selectedProduct, setSelectedProduct] = useState(null); IsActive: true,
const [clientSecret, setClientSecret] = useState(''); SortBy: 'Price',
const [orderId, setOrderId] = useState(''); SortDirection: 'DESC'
const [loading, setLoading] = useState(false); }
});
const handleProductSelect = async (product) => { setProducts(response.data.items);
setLoading(true); } catch (err) {
setSelectedProduct(product); setError('Failed to load products');
console.error('Error fetching products:', err);
const newOrderId = Math.floor(Math.random() * 10000).toString(); } finally {
setOrderId(newOrderId); setLoading(false);
try {
const response = await fetch('https://impr.ink/api/stripe/create-payment-intent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: product.price,
orderId: newOrderId
}),
});
const data = await response.json();
if (data.clientSecret) {
setClientSecret(data.clientSecret);
} else {
console.error('Error creating payment intent:', data.error);
} }
} catch (error) { };
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handlePaymentSuccess = () => { fetchProducts();
setSelectedProduct(null); }, []);
setClientSecret('');
setOrderId('');
};
const handleBackToProducts = () => { const steps = [
setSelectedProduct(null); {
setClientSecret(''); number: 1,
setOrderId(''); label: 'Pick an Item',
}; description: 'Browse our extensive collection of customizable products and select the perfect base for your design. From premium t-shirts and hoodies to mugs, phone cases, and more - we have everything you need to bring your vision to life.',
icon: <ShoppingCart sx={{ fontSize: 80 }} />,
const appearance = { details: 'Explore hundreds of high-quality products across multiple categories. Filter by material, size, color, and price to find exactly what you\'re looking for.'
theme: 'stripe',
variables: {
colorPrimary: '#0570de',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'Ideal Sans, system-ui, sans-serif',
spacingUnit: '2px',
borderRadius: '4px',
}, },
}; {
number: 2,
const options = { label: 'Choose Variant',
clientSecret, description: 'Select from available sizes, colors, and material options that match your preferences and needs. Each product comes with detailed specifications and sizing guides.',
appearance, icon: <Palette sx={{ fontSize: 80 }} />,
}; details: 'View real-time previews of different variants. Check material quality, durability ratings, and care instructions for each option.'
},
{
number: 3,
label: 'Customize with Images',
description: 'Upload your own designs, add custom text, or use our intuitive design tools to create something truly unique. Our editor supports various file formats and offers professional design features.',
icon: <ImageOutlined sx={{ fontSize: 80 }} />,
details: 'Drag and drop images, adjust positioning, add filters, create text overlays, and preview your design in real-time on the selected product.'
},
{
number: 4,
label: 'Pay',
description: 'Complete your order with our secure checkout process. We accept multiple payment methods and provide instant order confirmation with detailed receipts.',
icon: <CreditCard sx={{ fontSize: 80 }} />,
details: 'Review your design, confirm quantities, apply discount codes, and choose from various secure payment options including cards, PayPal, and more.'
},
{
number: 5,
label: 'Wait for Order',
description: 'Sit back and relax while we handle the rest. Our professional printing team will carefully produce your custom item and ship it directly to your door.',
icon: <LocalShipping sx={{ fontSize: 80 }} />,
details: 'Track your order status in real-time, from printing to packaging to shipping. Receive updates via email and SMS throughout the process.'
}
];
return ( return (
<div className="container"> <Container maxWidth="lg" sx={{ py: 4 }}>
<header> <Grid container spacing={6} alignItems="center" sx={{ mb: 6, minHeight: '70vh' }}>
<h1>🛍 Stripe Payment Demo</h1> <Grid item xs={12} md={7}>
<p>Select a product to purchase</p> <Box sx={{ textAlign: { xs: 'center', md: 'left' } }}>
</header> <Typography
variant="h1"
gutterBottom
sx={{
fontWeight: 'bold',
mb: 3,
fontSize: { xs: '2.5rem', md: '3.5rem', lg: '4rem' },
lineHeight: 1.2
}}
>
Custom Printing<br />
<Box component="span" sx={{ color: 'primary.main' }}>Made Simple</Box>
</Typography>
<Typography
variant="h5"
color="text.secondary"
sx={{
mb: 4,
lineHeight: 1.6,
fontSize: { xs: '1.2rem', md: '1.4rem' }
}}
>
Transform your ideas into reality with our premium custom printing services.
From t-shirts to mugs, we bring your designs to life with professional quality
and lightning-fast turnaround times.
</Typography>
{!selectedProduct ? ( <Box sx={{ mb: 4 }}>
<div className="products"> <Grid container spacing={3}>
<h2>Products</h2> <Grid item xs={12} sm={6}>
<div className="product-grid"> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckCircle sx={{ color: 'primary.main', mr: 2 }} />
<Typography variant="body1" fontWeight="medium">
Professional Quality Guaranteed
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckCircle sx={{ color: 'primary.main', mr: 2 }} />
<Typography variant="body1" fontWeight="medium">
Fast 24-48 Hour Turnaround
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckCircle sx={{ color: 'primary.main', mr: 2 }} />
<Typography variant="body1" fontWeight="medium">
Free Design Support
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckCircle sx={{ color: 'primary.main', mr: 2 }} />
<Typography variant="body1" fontWeight="medium">
100% Satisfaction Promise
</Typography>
</Box>
</Grid>
</Grid>
</Box>
<Box sx={{ display: 'flex', gap: 2, justifyContent: { xs: 'center', md: 'flex-start' }, flexWrap: 'wrap', mb: 4 }}>
<Button
variant="contained"
size="large"
href="/gallery"
sx={{ px: 5, py: 2, fontSize: '1.2rem' }}
>
View Gallery
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
Trusted by 10,000+ customers 4.9/5 rating
</Typography>
</Box>
</Grid>
</Grid>
<Box sx={{ mb: 12 }}>
<Box sx={{ mb: 6, textAlign: { xs: 'center', md: 'left' } }}>
<Typography variant="h2" gutterBottom sx={{ fontWeight: 'bold', mb: 2 }}>
Featured Products
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: 600 }}>
Discover our most popular customizable products. Each item is carefully selected
for quality and perfect for personalization.
</Typography>
</Box>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress size={60} />
</Box>
)}
{error && (
<Alert severity="error" sx={{ mb: 4 }}>
{error}
</Alert>
)}
{!loading && !error && (
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 3,
justifyContent: { xs: 'center', lg: 'flex-start' }
}}>
{products.map((product) => ( {products.map((product) => (
<div key={product.id} className="product-card"> <Card
<h3>{product.name}</h3> key={product.id}
<p className="description">{product.description}</p> sx={{
<p className="price">${(product.price / 100).toFixed(2)}</p> width: { xs: '100%', sm: 'calc(50% - 12px)', lg: 'calc(33.333% - 16px)' },
<button maxWidth: { xs: 400, sm: 350, lg: 370 },
onClick={() => handleProductSelect(product)} height: { xs: 450, sm: 480, lg: 500 },
disabled={loading} display: 'flex',
className="select-btn" flexDirection: 'column',
> transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
{loading ? 'Loading...' : 'Select'} '&:hover': {
</button> transform: 'translateY(-8px)',
</div> boxShadow: 6
}
}}
>
<CardMedia
component="img"
height="200"
image={product.imageUrl || '/placeholder-product.jpg'}
alt={product.name}
sx={{
objectFit: 'cover',
height: { xs: 180, sm: 200, lg: 220 }
}}
/>
<CardContent sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
p: { xs: 2, sm: 2.5, lg: 3 },
'&:last-child': { pb: { xs: 2, sm: 2.5, lg: 3 } }
}}>
<Typography variant="h6" gutterBottom sx={{
fontWeight: 'bold',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: { xs: '1.1rem', sm: '1.25rem' }
}}>
{product.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{
flexGrow: 1,
mb: 2,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: { xs: 2, sm: 3 },
WebkitBoxOrient: 'vertical',
minHeight: { xs: 36, sm: 54 },
fontSize: { xs: '0.875rem', sm: '0.875rem' }
}}>
{product.description}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" color="primary" sx={{
fontWeight: 'bold',
fontSize: { xs: '1rem', sm: '1.1rem' }
}}>
From ${product.basePrice?.toFixed(2)}
</Typography>
{product.isCustomizable && (
<Chip
label="Custom"
color="primary"
size="small"
/>
)}
</Box>
<Button
variant="contained"
fullWidth
size="medium"
sx={{
fontSize: { xs: '0.875rem', sm: '1rem' },
py: { xs: 1, sm: 1.5 }
}}
>
Customize
</Button>
</CardContent>
</Card>
))} ))}
</div> </Box>
</div> )}
) : ( </Box>
<div className="checkout">
<div className="order-summary">
<h2>Order Summary</h2>
<div className="order-details">
<p><strong>Product:</strong> {selectedProduct.name}</p>
<p><strong>Order ID:</strong> {orderId}</p>
<p><strong>Amount:</strong> ${(selectedProduct.price / 100).toFixed(2)}</p>
</div>
</div>
{clientSecret && ( <Box sx={{ mb: 12, py: 8 }}>
<Elements options={options} stripe={stripePromise}> <Container maxWidth="lg">
<PaymentForm <Box sx={{ mb: 8 }}>
onSuccess={handlePaymentSuccess} <Typography variant="h2" gutterBottom sx={{ fontWeight: 'bold', mb: 2 }}>
orderId={orderId} How It Works
/> </Typography>
</Elements> <Typography variant="h5" color="text.secondary" sx={{ maxWidth: 600 }}>
)} Our streamlined process makes custom printing simple and stress-free.
Follow these five easy steps to get your perfect custom products.
</Typography>
</Box>
<button <Grid container spacing={6}>
onClick={handleBackToProducts} {steps.map((step, index) => (
className="back-btn" <Grid item xs={12} key={index}>
> <Card
Back to Products sx={{
</button> p: 4,
</div> boxShadow: 2,
)} borderRadius: 3,
</div> transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 8,
transform: 'translateY(-4px)'
}
}}
>
<Grid container spacing={4} alignItems="center">
<Grid item xs={12} md={2}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: { xs: 'center', md: 'flex-start' },
mb: { xs: 2, md: 0 }
}}
>
<Box
sx={{
width: 100,
height: 100,
borderRadius: 2,
bgcolor: 'primary.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
mr: 2
}}
>
{step.icon}
</Box>
<Box
sx={{
width: 60,
height: 60,
borderRadius: '50%',
bgcolor: 'primary.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '1.5rem',
fontWeight: 'bold'
}}
>
{step.number}
</Box>
</Box>
</Grid>
<Grid item xs={12} md={10}>
<Box sx={{ textAlign: { xs: 'center', md: 'left' } }}>
<Typography
variant="h4"
gutterBottom
sx={{
fontWeight: 'bold',
color: 'primary.main',
mb: 2
}}
>
{step.label}
</Typography>
<Typography
variant="h6"
paragraph
sx={{
mb: 3,
lineHeight: 1.6,
color: 'text.primary',
fontWeight: 400
}}
>
{step.description}
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{
lineHeight: 1.7,
fontSize: '1.1rem'
}}
>
{step.details}
</Typography>
</Box>
</Grid>
</Grid>
</Card>
</Grid>
))}
</Grid>
</Container>
</Box>
<Box sx={{ textAlign: 'center', py: 8, bgcolor: 'background.paper', borderRadius: 2, boxShadow: 1 }}>
<Typography variant="h3" gutterBottom sx={{ fontWeight: 'bold', mb: 3 }}>
Ready to Get Started?
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
Join thousands of satisfied customers who trust us with their custom printing needs.
Quality guaranteed, fast turnaround, and competitive prices.
</Typography>
<Button
variant="contained"
size="large"
sx={{ px: 6, py: 2, fontSize: '1.2rem' }}
startIcon={<CheckCircle />}
>
Start Your Order Today
</Button>
</Box>
</Container>
); );
} }

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import {auth0} from "@/lib/auth0"; import {auth0} from "@/lib/auth0";
import api from "@/lib/api"; import serverApi from "@/lib/serverApi";
export async function GET() { export async function GET() {
try { try {
@@ -8,13 +8,13 @@ export async function GET() {
if (!token) { return NextResponse.json({ error: 'No access token found' }, { status: 401 }); } if (!token) { return NextResponse.json({ error: 'No access token found' }, { status: 401 }); }
await api.post('/users/sync', {}, { await serverApi.post('/users/me/sync', null, {
headers: { Cookie: `access_token=${token}` } headers: { Authorization: `Bearer ${token}`}
}); });
return NextResponse.json({ access_token: token }); return NextResponse.json("Ok");
} catch (error) { } catch (error) {
console.error('Error in /api/token:', error); console.error('Error in /serverApi/token:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
} }
} }

View File

@@ -0,0 +1,31 @@
import axios from "axios";
const clientApi = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true,
});
clientApi.interceptors.request.use(async (config) => {
if (typeof window === 'undefined') return config;
try {
const res = await fetch('/auth/access-token');
if (!res.ok)
throw new Error('Failed to fetch token');
const data = await res.json();
if (data.token) {
config.headers.Authorization = `Bearer ${data.token}`;
} else {
console.warn('No token received from /auth/access-token');
}
} catch (err) {
console.error('Error fetching token:', err);
}
return config;
}, error => {
return Promise.reject(error);
});
export default clientApi;

View File

@@ -1,8 +1,8 @@
import axios from "axios"; import axios from "axios";
const api = axios.create({ const serverApi = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL, baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true, withCredentials: true,
}); });
export default api; export default serverApi;