@lodashventure/medusa-brand 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/dist/admin/components/brand-form.d.ts +19 -0
- package/dist/admin/components/brand-form.js +182 -0
- package/dist/admin/components/brand-image-uploader.d.ts +14 -0
- package/dist/admin/components/brand-image-uploader.js +217 -0
- package/dist/admin/lib/sdk.d.ts +1 -0
- package/dist/admin/lib/sdk.js +14 -0
- package/dist/admin/routes/brands/page.d.ts +4 -0
- package/dist/admin/routes/brands/page.js +253 -0
- package/dist/admin/widgets/product-brand-widget.d.ts +8 -0
- package/dist/admin/widgets/product-brand-widget.js +207 -0
- package/dist/api/admin/brands/[id]/image/route.d.ts +5 -0
- package/dist/api/admin/brands/[id]/image/route.js +118 -0
- package/dist/api/admin/brands/[id]/logo/route.d.ts +5 -0
- package/dist/api/admin/brands/[id]/logo/route.js +118 -0
- package/dist/api/admin/brands/[id]/products/route.d.ts +2 -0
- package/dist/api/admin/brands/[id]/products/route.js +51 -0
- package/dist/api/admin/brands/[id]/route.d.ts +5 -0
- package/dist/api/admin/brands/[id]/route.js +111 -0
- package/dist/api/admin/brands/route.d.ts +4 -0
- package/dist/api/admin/brands/route.js +75 -0
- package/dist/api/admin/products/[id]/brand/route.d.ts +5 -0
- package/dist/api/admin/products/[id]/brand/route.js +116 -0
- package/dist/api/middlewares/attach-brand-to-products.d.ts +2 -0
- package/dist/api/middlewares/attach-brand-to-products.js +104 -0
- package/dist/api/middlewares.d.ts +6 -0
- package/dist/api/middlewares.js +26 -0
- package/dist/api/store/brands/route.d.ts +2 -0
- package/dist/api/store/brands/route.js +50 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/modules/brand/index.d.ts +35 -0
- package/dist/modules/brand/index.js +12 -0
- package/dist/modules/brand/migrations/Migration20251021070648.d.ts +5 -0
- package/dist/modules/brand/migrations/Migration20251021070648.js +27 -0
- package/dist/modules/brand/models/brand.d.ts +16 -0
- package/dist/modules/brand/models/brand.js +42 -0
- package/dist/modules/brand/service.d.ts +21 -0
- package/dist/modules/brand/service.js +10 -0
- package/dist/services/gcs-direct-upload.d.ts +8 -0
- package/dist/services/gcs-direct-upload.js +54 -0
- package/dist/workflows/upload-brand-image.d.ts +15 -0
- package/dist/workflows/upload-brand-image.js +56 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Medusa Brand Plugin
|
|
2
|
+
|
|
3
|
+
A comprehensive brand management plugin for Medusa v2 that allows you to manage brands, associate them with products, and provide brand-based filtering for your e-commerce store.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Brand Management**: Full CRUD operations for managing brands
|
|
8
|
+
- **Product Association**: Associate brands with products
|
|
9
|
+
- **Image Management**: Upload and manage brand images and logos
|
|
10
|
+
- **Admin UI**: Complete admin interface for brand management
|
|
11
|
+
- **Store API**: Public API for displaying brands in your storefront
|
|
12
|
+
- **Middleware**: Automatic brand data enrichment for product responses
|
|
13
|
+
- **GCS Integration**: Google Cloud Storage support for image uploads
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @lodashventure/medusa-brand
|
|
19
|
+
# or
|
|
20
|
+
yarn add @lodashventure/medusa-brand
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
Add the plugin to your `medusa-config.ts`:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
module.exports = defineConfig({
|
|
29
|
+
plugins: [
|
|
30
|
+
{
|
|
31
|
+
resolve: "@lodashventure/medusa-brand",
|
|
32
|
+
options: {},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
modules: [
|
|
36
|
+
{
|
|
37
|
+
resolve: "@lodashventure/medusa-brand/modules/brand",
|
|
38
|
+
options: {},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Environment Variables
|
|
45
|
+
|
|
46
|
+
The plugin requires the following environment variables for GCS integration:
|
|
47
|
+
|
|
48
|
+
```env
|
|
49
|
+
CLIENT_EMAIL=your-gcs-client-email
|
|
50
|
+
PRIVATE_KEY=your-gcs-private-key
|
|
51
|
+
BUCKET_NAME=your-gcs-bucket-name
|
|
52
|
+
GCP_STORAGE_BASE_PUBLIC_URL=https://storage.googleapis.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## API Routes
|
|
56
|
+
|
|
57
|
+
### Admin Routes
|
|
58
|
+
|
|
59
|
+
- `GET /admin/brands` - List all brands
|
|
60
|
+
- `POST /admin/brands` - Create a new brand
|
|
61
|
+
- `GET /admin/brands/:id` - Get a specific brand
|
|
62
|
+
- `PUT /admin/brands/:id` - Update a brand
|
|
63
|
+
- `DELETE /admin/brands/:id` - Delete a brand
|
|
64
|
+
- `POST /admin/brands/:id/image` - Upload brand image
|
|
65
|
+
- `DELETE /admin/brands/:id/image` - Delete brand image
|
|
66
|
+
- `POST /admin/brands/:id/logo` - Upload brand logo
|
|
67
|
+
- `DELETE /admin/brands/:id/logo` - Delete brand logo
|
|
68
|
+
- `GET /admin/brands/:id/products` - Get products for a brand
|
|
69
|
+
- `GET /admin/products/:id/brand` - Get brand for a product
|
|
70
|
+
- `POST /admin/products/:id/brand` - Assign brand to product
|
|
71
|
+
- `DELETE /admin/products/:id/brand` - Remove brand from product
|
|
72
|
+
|
|
73
|
+
### Store Routes
|
|
74
|
+
|
|
75
|
+
- `GET /store/brands` - List active brands
|
|
76
|
+
|
|
77
|
+
## Admin UI
|
|
78
|
+
|
|
79
|
+
The plugin includes a complete admin interface with:
|
|
80
|
+
|
|
81
|
+
- Brand management page at `/app/brands`
|
|
82
|
+
- Product brand widget in product details
|
|
83
|
+
- Image and logo upload capabilities
|
|
84
|
+
- Search and filtering
|
|
85
|
+
|
|
86
|
+
## Database Schema
|
|
87
|
+
|
|
88
|
+
The plugin creates two tables:
|
|
89
|
+
|
|
90
|
+
- `brand` - Stores brand information
|
|
91
|
+
- `product_brand` - Junction table for product-brand associations
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
interface Brand {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
image?: string;
|
|
8
|
+
logo?: string;
|
|
9
|
+
website?: string;
|
|
10
|
+
is_active: boolean;
|
|
11
|
+
metadata?: any;
|
|
12
|
+
}
|
|
13
|
+
interface BrandFormProps {
|
|
14
|
+
brand?: Brand | null;
|
|
15
|
+
isCreating: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}
|
|
18
|
+
export declare const BrandForm: ({ brand, isCreating, onClose }: BrandFormProps) => React.JSX.Element;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.BrandForm = void 0;
|
|
37
|
+
const react_1 = __importStar(require("react"));
|
|
38
|
+
const sdk_1 = require("../lib/sdk");
|
|
39
|
+
if (["$(basename $file)" = "page.tsx"] || "../lib/sdk")
|
|
40
|
+
;
|
|
41
|
+
const ui_1 = require("@medusajs/ui");
|
|
42
|
+
const icons_1 = require("@medusajs/icons");
|
|
43
|
+
const BrandForm = ({ brand, isCreating, onClose }) => {
|
|
44
|
+
const [formData, setFormData] = (0, react_1.useState)({
|
|
45
|
+
name: "",
|
|
46
|
+
slug: "",
|
|
47
|
+
description: "",
|
|
48
|
+
website: "",
|
|
49
|
+
is_active: true,
|
|
50
|
+
metadata: {},
|
|
51
|
+
});
|
|
52
|
+
const [loading, setLoading] = (0, react_1.useState)(false);
|
|
53
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
54
|
+
const [slugError, setSlugError] = (0, react_1.useState)(null);
|
|
55
|
+
(0, react_1.useEffect)(() => {
|
|
56
|
+
if (brand) {
|
|
57
|
+
setFormData({
|
|
58
|
+
name: brand.name || "",
|
|
59
|
+
slug: brand.slug || "",
|
|
60
|
+
description: brand.description || "",
|
|
61
|
+
website: brand.website || "",
|
|
62
|
+
is_active: brand.is_active !== false,
|
|
63
|
+
metadata: brand.metadata || {},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}, [brand]);
|
|
67
|
+
const generateSlug = (name) => {
|
|
68
|
+
return name
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
+
.replace(/^-+|-+$/g, "");
|
|
72
|
+
};
|
|
73
|
+
const handleNameChange = (value) => {
|
|
74
|
+
setFormData(prev => ({
|
|
75
|
+
...prev,
|
|
76
|
+
name: value,
|
|
77
|
+
slug: isCreating ? generateSlug(value) : prev.slug,
|
|
78
|
+
}));
|
|
79
|
+
};
|
|
80
|
+
const handleSlugChange = (value) => {
|
|
81
|
+
const cleanSlug = value
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
84
|
+
.replace(/--+/g, "-");
|
|
85
|
+
setFormData(prev => ({ ...prev, slug: cleanSlug }));
|
|
86
|
+
// Clear slug error when user types
|
|
87
|
+
if (slugError)
|
|
88
|
+
setSlugError(null);
|
|
89
|
+
};
|
|
90
|
+
const validateForm = () => {
|
|
91
|
+
if (!formData.name.trim()) {
|
|
92
|
+
setError("Brand name is required");
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (!formData.slug.trim()) {
|
|
96
|
+
setError("Brand slug is required");
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(formData.slug)) {
|
|
100
|
+
setSlugError("Slug must contain only lowercase letters, numbers, and hyphens");
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (formData.website && !formData.website.match(/^https?:\/\/.+/)) {
|
|
104
|
+
setError("Website must be a valid URL (starting with http:// or https://)");
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
const handleSubmit = async (e) => {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
if (!validateForm())
|
|
112
|
+
return;
|
|
113
|
+
setLoading(true);
|
|
114
|
+
setError(null);
|
|
115
|
+
try {
|
|
116
|
+
const url = isCreating
|
|
117
|
+
? "/admin/brands"
|
|
118
|
+
: `/admin/brands/${brand?.id}`;
|
|
119
|
+
const method = isCreating ? "POST" : "PUT";
|
|
120
|
+
const response = await sdk_1.sdk.client.fetch(url, {
|
|
121
|
+
method,
|
|
122
|
+
headers: {
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify(formData),
|
|
126
|
+
});
|
|
127
|
+
const data = await response.json();
|
|
128
|
+
if (response.ok) {
|
|
129
|
+
onClose();
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
setError(data.error || "Failed to save brand");
|
|
133
|
+
if (data.error?.includes("slug")) {
|
|
134
|
+
setSlugError("This slug is already in use");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
setError("An error occurred while saving the brand");
|
|
140
|
+
console.error("Error saving brand:", err);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
setLoading(false);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
return (react_1.default.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50" },
|
|
147
|
+
react_1.default.createElement("div", { className: "w-full max-w-2xl rounded-lg bg-ui-bg-base p-6 max-h-[90vh] overflow-y-auto" },
|
|
148
|
+
react_1.default.createElement("div", { className: "mb-6 flex items-center justify-between" },
|
|
149
|
+
react_1.default.createElement("div", null,
|
|
150
|
+
react_1.default.createElement(ui_1.Heading, { level: "h2" }, isCreating ? "Create New Brand" : "Edit Brand"),
|
|
151
|
+
!isCreating && brand && (react_1.default.createElement(ui_1.Text, { className: "text-ui-fg-subtle" },
|
|
152
|
+
"Editing: ",
|
|
153
|
+
brand.name))),
|
|
154
|
+
react_1.default.createElement(ui_1.Button, { variant: "secondary", size: "small", onClick: onClose },
|
|
155
|
+
react_1.default.createElement(icons_1.X, null))),
|
|
156
|
+
error && (react_1.default.createElement(ui_1.Alert, { variant: "error", dismissible: true, className: "mb-4" }, error)),
|
|
157
|
+
react_1.default.createElement("form", { onSubmit: handleSubmit, className: "space-y-4" },
|
|
158
|
+
react_1.default.createElement("div", null,
|
|
159
|
+
react_1.default.createElement(ui_1.Label, { htmlFor: "name", className: "mb-2" }, "Brand Name *"),
|
|
160
|
+
react_1.default.createElement(ui_1.Input, { id: "name", placeholder: "e.g., Nike", value: formData.name, onChange: (e) => handleNameChange(e.target.value), required: true })),
|
|
161
|
+
react_1.default.createElement("div", null,
|
|
162
|
+
react_1.default.createElement(ui_1.Label, { htmlFor: "slug", className: "mb-2" }, "Slug *"),
|
|
163
|
+
react_1.default.createElement(ui_1.Input, { id: "slug", placeholder: "e.g., nike", value: formData.slug, onChange: (e) => handleSlugChange(e.target.value), required: true }),
|
|
164
|
+
slugError && (react_1.default.createElement(ui_1.Text, { className: "mt-1 text-sm text-ui-fg-error" }, slugError)),
|
|
165
|
+
react_1.default.createElement(ui_1.Text, { className: "mt-1 text-xs text-ui-fg-subtle" }, "Used in URLs and must be unique")),
|
|
166
|
+
react_1.default.createElement("div", null,
|
|
167
|
+
react_1.default.createElement(ui_1.Label, { htmlFor: "description", className: "mb-2" }, "Description"),
|
|
168
|
+
react_1.default.createElement(ui_1.Textarea, { id: "description", placeholder: "Enter brand description...", value: formData.description, onChange: (e) => setFormData(prev => ({ ...prev, description: e.target.value })), rows: 4 })),
|
|
169
|
+
react_1.default.createElement("div", null,
|
|
170
|
+
react_1.default.createElement(ui_1.Label, { htmlFor: "website", className: "mb-2" }, "Website"),
|
|
171
|
+
react_1.default.createElement(ui_1.Input, { id: "website", type: "url", placeholder: "https://example.com", value: formData.website, onChange: (e) => setFormData(prev => ({ ...prev, website: e.target.value })) }),
|
|
172
|
+
react_1.default.createElement(ui_1.Text, { className: "mt-1 text-xs text-ui-fg-subtle" }, "Include the full URL with http:// or https://")),
|
|
173
|
+
react_1.default.createElement("div", { className: "flex items-center justify-between rounded-lg border p-4" },
|
|
174
|
+
react_1.default.createElement("div", null,
|
|
175
|
+
react_1.default.createElement(ui_1.Label, { htmlFor: "is_active", className: "font-medium" }, "Active Status"),
|
|
176
|
+
react_1.default.createElement(ui_1.Text, { className: "text-sm text-ui-fg-subtle" }, "Active brands are visible in your store")),
|
|
177
|
+
react_1.default.createElement(ui_1.Switch, { id: "is_active", checked: formData.is_active, onCheckedChange: (checked) => setFormData(prev => ({ ...prev, is_active: checked })) })),
|
|
178
|
+
react_1.default.createElement("div", { className: "flex items-center justify-end gap-3 pt-4 border-t" },
|
|
179
|
+
react_1.default.createElement(ui_1.Button, { type: "button", variant: "secondary", onClick: onClose }, "Cancel"),
|
|
180
|
+
react_1.default.createElement(ui_1.Button, { type: "submit", disabled: loading }, loading ? "Saving..." : isCreating ? "Create Brand" : "Update Brand"))))));
|
|
181
|
+
};
|
|
182
|
+
exports.BrandForm = BrandForm;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
interface Brand {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
image?: string;
|
|
6
|
+
logo?: string;
|
|
7
|
+
}
|
|
8
|
+
interface BrandImageUploaderProps {
|
|
9
|
+
brand: Brand;
|
|
10
|
+
imageType: "image" | "logo";
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
}
|
|
13
|
+
export declare const BrandImageUploader: ({ brand, imageType, onClose, }: BrandImageUploaderProps) => React.JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.BrandImageUploader = void 0;
|
|
37
|
+
const react_1 = __importStar(require("react"));
|
|
38
|
+
const sdk_1 = require("../lib/sdk");
|
|
39
|
+
if (["$(basename $file)" = "page.tsx"] || "../lib/sdk")
|
|
40
|
+
;
|
|
41
|
+
const ui_1 = require("@medusajs/ui");
|
|
42
|
+
const icons_1 = require("@medusajs/icons");
|
|
43
|
+
const BrandImageUploader = ({ brand, imageType, onClose, }) => {
|
|
44
|
+
const currentImage = imageType === "image" ? brand.image : brand.logo;
|
|
45
|
+
const [displayImage, setDisplayImage] = (0, react_1.useState)(currentImage || null);
|
|
46
|
+
const [isUploading, setIsUploading] = (0, react_1.useState)(false);
|
|
47
|
+
const [isDragging, setIsDragging] = (0, react_1.useState)(false);
|
|
48
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
49
|
+
const [previewUrl, setPreviewUrl] = (0, react_1.useState)(null);
|
|
50
|
+
const isLogo = imageType === "logo";
|
|
51
|
+
const maxSize = isLogo ? 2 : 5; // 2MB for logos, 5MB for images
|
|
52
|
+
const allowedTypes = isLogo
|
|
53
|
+
? ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"]
|
|
54
|
+
: ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
55
|
+
const formatFileTypes = () => {
|
|
56
|
+
return isLogo
|
|
57
|
+
? "JPEG, PNG, GIF, WebP, or SVG"
|
|
58
|
+
: "JPEG, PNG, GIF, or WebP";
|
|
59
|
+
};
|
|
60
|
+
const validateFile = (file) => {
|
|
61
|
+
if (!allowedTypes.includes(file.type)) {
|
|
62
|
+
setError(`Please upload a valid image file (${formatFileTypes()})`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (file.size > maxSize * 1024 * 1024) {
|
|
66
|
+
setError(`File size must be less than ${maxSize}MB`);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
const handleFileUpload = async (file) => {
|
|
72
|
+
if (!validateFile(file))
|
|
73
|
+
return;
|
|
74
|
+
setError(null);
|
|
75
|
+
// Show preview immediately
|
|
76
|
+
const reader = new FileReader();
|
|
77
|
+
reader.onloadend = () => {
|
|
78
|
+
setPreviewUrl(reader.result);
|
|
79
|
+
};
|
|
80
|
+
reader.readAsDataURL(file);
|
|
81
|
+
// Upload to server
|
|
82
|
+
setIsUploading(true);
|
|
83
|
+
const formData = new FormData();
|
|
84
|
+
formData.append("file", file);
|
|
85
|
+
try {
|
|
86
|
+
const response = await sdk_1.sdk.client.fetch(`/admin/brands/${brand.id}/${imageType}`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
body: formData,
|
|
89
|
+
});
|
|
90
|
+
if (response.ok) {
|
|
91
|
+
const result = await response.json();
|
|
92
|
+
const newImageUrl = result.brand[imageType];
|
|
93
|
+
setDisplayImage(newImageUrl);
|
|
94
|
+
setPreviewUrl(null);
|
|
95
|
+
// Show success for a moment before closing
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
onClose();
|
|
98
|
+
}, 1000);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const errorData = await response.json();
|
|
102
|
+
setError(errorData.error || `Failed to upload ${imageType}`);
|
|
103
|
+
setPreviewUrl(null);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
setError(`Error uploading ${imageType}`);
|
|
108
|
+
setPreviewUrl(null);
|
|
109
|
+
console.error(`Error uploading ${imageType}:`, err);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
setIsUploading(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const handleDelete = async () => {
|
|
116
|
+
if (!confirm(`Are you sure you want to delete the ${imageType}?`))
|
|
117
|
+
return;
|
|
118
|
+
setIsUploading(true);
|
|
119
|
+
setError(null);
|
|
120
|
+
try {
|
|
121
|
+
const response = await sdk_1.sdk.client.fetch(`/admin/brands/${brand.id}/${imageType}`, {
|
|
122
|
+
method: "DELETE",
|
|
123
|
+
});
|
|
124
|
+
if (response.ok) {
|
|
125
|
+
setDisplayImage(null);
|
|
126
|
+
setPreviewUrl(null);
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
onClose();
|
|
129
|
+
}, 500);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const errorData = await response.json();
|
|
133
|
+
setError(errorData.error || `Failed to delete ${imageType}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
setError(`Error deleting ${imageType}`);
|
|
138
|
+
console.error(`Error deleting ${imageType}:`, err);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
setIsUploading(false);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const handleDragEnter = (0, react_1.useCallback)((e) => {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
setIsDragging(true);
|
|
148
|
+
}, []);
|
|
149
|
+
const handleDragLeave = (0, react_1.useCallback)((e) => {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
e.stopPropagation();
|
|
152
|
+
setIsDragging(false);
|
|
153
|
+
}, []);
|
|
154
|
+
const handleDragOver = (0, react_1.useCallback)((e) => {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
e.stopPropagation();
|
|
157
|
+
}, []);
|
|
158
|
+
const handleDrop = (0, react_1.useCallback)((e) => {
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
e.stopPropagation();
|
|
161
|
+
setIsDragging(false);
|
|
162
|
+
const file = e.dataTransfer.files?.[0];
|
|
163
|
+
if (file) {
|
|
164
|
+
handleFileUpload(file);
|
|
165
|
+
}
|
|
166
|
+
}, []);
|
|
167
|
+
const imageToDisplay = previewUrl || displayImage;
|
|
168
|
+
return (react_1.default.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50" },
|
|
169
|
+
react_1.default.createElement("div", { className: "w-full max-w-2xl rounded-lg bg-ui-bg-base p-6" },
|
|
170
|
+
react_1.default.createElement("div", { className: "mb-6 flex items-center justify-between" },
|
|
171
|
+
react_1.default.createElement("div", null,
|
|
172
|
+
react_1.default.createElement(ui_1.Heading, { level: "h2" },
|
|
173
|
+
"Upload Brand ",
|
|
174
|
+
isLogo ? "Logo" : "Image"),
|
|
175
|
+
react_1.default.createElement(ui_1.Text, { className: "text-ui-fg-subtle" }, brand.name)),
|
|
176
|
+
react_1.default.createElement(ui_1.Button, { variant: "secondary", size: "small", onClick: onClose },
|
|
177
|
+
react_1.default.createElement(icons_1.X, null))),
|
|
178
|
+
error && (react_1.default.createElement(ui_1.Alert, { variant: "error", dismissible: true, className: "mb-4" }, error)),
|
|
179
|
+
react_1.default.createElement("div", { className: "space-y-4" },
|
|
180
|
+
imageToDisplay ? (react_1.default.createElement("div", { className: "space-y-4" },
|
|
181
|
+
react_1.default.createElement("div", { className: "relative overflow-hidden rounded-lg border border-ui-border-base bg-ui-bg-subtle" },
|
|
182
|
+
react_1.default.createElement("img", { src: imageToDisplay, alt: `Brand ${imageType}`, className: (0, ui_1.clx)("w-full object-contain", isLogo ? "h-32" : "h-64") }),
|
|
183
|
+
isUploading && (react_1.default.createElement("div", { className: "absolute inset-0 flex items-center justify-center bg-black/50" },
|
|
184
|
+
react_1.default.createElement("div", { className: "text-white" }, "Uploading...")))),
|
|
185
|
+
react_1.default.createElement("div", { className: "flex gap-2" },
|
|
186
|
+
react_1.default.createElement(ui_1.Button, { variant: "secondary", disabled: isUploading, onClick: () => document.getElementById(`${imageType}-file-input`)?.click() },
|
|
187
|
+
react_1.default.createElement(icons_1.CloudArrowUp, { className: "mr-2" }),
|
|
188
|
+
"Replace"),
|
|
189
|
+
react_1.default.createElement(ui_1.Button, { variant: "danger", disabled: isUploading, onClick: handleDelete },
|
|
190
|
+
react_1.default.createElement(icons_1.Trash, { className: "mr-2" }),
|
|
191
|
+
"Delete")))) : (react_1.default.createElement("div", { className: (0, ui_1.clx)("flex flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors", isLogo ? "h-48" : "h-64", isDragging
|
|
192
|
+
? "border-ui-border-interactive bg-ui-bg-highlight"
|
|
193
|
+
: "border-ui-border-base bg-ui-bg-subtle", !isUploading && "cursor-pointer"), onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, onClick: () => !isUploading && document.getElementById(`${imageType}-file-input`)?.click() },
|
|
194
|
+
isLogo ? (react_1.default.createElement(icons_1.BuildingStorefront, { className: "mb-4 h-12 w-12 text-ui-fg-subtle" })) : (react_1.default.createElement(icons_1.PhotoSolid, { className: "mb-4 h-12 w-12 text-ui-fg-subtle" })),
|
|
195
|
+
react_1.default.createElement(ui_1.Text, { className: "mb-2 text-lg font-medium" }, isDragging ? `Drop ${imageType} here` : `Upload ${isLogo ? "Logo" : "Image"}`),
|
|
196
|
+
react_1.default.createElement(ui_1.Text, { className: "mb-4 text-center text-ui-fg-subtle" }, "Drag and drop an image here, or click to select"),
|
|
197
|
+
react_1.default.createElement(ui_1.Text, { className: "text-sm text-ui-fg-subtle" },
|
|
198
|
+
formatFileTypes(),
|
|
199
|
+
" \u2022 Max ",
|
|
200
|
+
maxSize,
|
|
201
|
+
"MB"),
|
|
202
|
+
isLogo && (react_1.default.createElement(ui_1.Text, { className: "mt-2 text-xs text-ui-fg-subtle" }, "Recommended: Square image, minimum 200x200px")),
|
|
203
|
+
isUploading && (react_1.default.createElement("div", { className: "mt-4" },
|
|
204
|
+
react_1.default.createElement(ui_1.Text, null, "Uploading..."))))),
|
|
205
|
+
react_1.default.createElement("input", { id: `${imageType}-file-input`, type: "file", accept: allowedTypes.join(","), onChange: (e) => {
|
|
206
|
+
const file = e.target.files?.[0];
|
|
207
|
+
if (file)
|
|
208
|
+
handleFileUpload(file);
|
|
209
|
+
e.target.value = ""; // Reset input
|
|
210
|
+
}, className: "hidden" })),
|
|
211
|
+
react_1.default.createElement("div", { className: "mt-6 flex items-center justify-between border-t pt-4" },
|
|
212
|
+
react_1.default.createElement(ui_1.Text, { className: "text-sm text-ui-fg-subtle" }, isLogo
|
|
213
|
+
? "Logo will be displayed in brand lists and product pages"
|
|
214
|
+
: "Image can be used for brand pages and marketing"),
|
|
215
|
+
react_1.default.createElement(ui_1.Button, { variant: "secondary", onClick: onClose }, "Close")))));
|
|
216
|
+
};
|
|
217
|
+
exports.BrandImageUploader = BrandImageUploader;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const sdk: any;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.sdk = void 0;
|
|
7
|
+
const js_sdk_1 = __importDefault(require("@medusajs/js-sdk"));
|
|
8
|
+
exports.sdk = new js_sdk_1.default({
|
|
9
|
+
baseUrl: import.meta.env.VITE_BACKEND_URL || "/",
|
|
10
|
+
debug: import.meta.env.DEV,
|
|
11
|
+
auth: {
|
|
12
|
+
type: "session",
|
|
13
|
+
},
|
|
14
|
+
});
|