@ikas/code-components-mcp 0.33.0 → 0.35.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/data/framework.json +15 -15
- package/data/section-templates.json +145 -149
- package/data/storefront-api.json +166 -85
- package/data/storefront-types.json +1 -1
- package/dist/index.js +58 -29
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,217 +1,213 @@
|
|
|
1
1
|
{
|
|
2
2
|
"templates": {
|
|
3
|
-
"
|
|
3
|
+
"not-found": {
|
|
4
4
|
"title": "404 Page Section",
|
|
5
|
-
"description": "Page not found section with message and navigation back to home",
|
|
5
|
+
"description": "Page not found section with message and navigation back to home.",
|
|
6
6
|
"files": {
|
|
7
|
-
"index.tsx": "import { Router } from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport
|
|
8
|
-
"types.ts": "export interface Props {\n heading?: string;\n message?: string;\n buttonText?: string;\n}\n",
|
|
7
|
+
"index.tsx": "import { Router } from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport function NotFoundSection({\n heading = \"Page Not Found\",\n message = \"The page you're looking for doesn't exist or has been moved.\",\n buttonText = \"Back to Home\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n return (\n <section className=\"not-found-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"not-found-inner\">\n <span className=\"not-found-code\">404</span>\n <h1 className=\"not-found-heading\">{heading}</h1>\n <p className=\"not-found-message\">{message}</p>\n <button\n className=\"not-found-btn\"\n onClick={() => Router.navigate(\"/\")}\n >\n {buttonText}\n </button>\n </div>\n </section>\n );\n}\n\nexport default NotFoundSection;\n",
|
|
8
|
+
"types.ts": "export interface Props {\n heading?: string;\n message?: string;\n buttonText?: string;\n backgroundColor?: string;\n}\n",
|
|
9
9
|
"styles.css": ".not-found-section {\n width: 100%;\n padding: 80px 24px;\n text-align: center;\n}\n\n.not-found-inner {\n max-width: 480px;\n margin: 0 auto;\n}\n\n.not-found-code {\n font-size: 96px;\n font-weight: 800;\n color: #eee;\n line-height: 1;\n display: block;\n margin-bottom: 16px;\n}\n\n.not-found-heading {\n font-size: 28px;\n font-weight: 700;\n color: #111;\n margin: 0 0 12px 0;\n}\n\n.not-found-message {\n font-size: 16px;\n color: #666;\n margin: 0 0 32px 0;\n line-height: 1.5;\n}\n\n.not-found-btn {\n display: inline-block;\n padding: 14px 32px;\n font-size: 16px;\n font-weight: 600;\n color: #fff;\n background: #111;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n}\n\n.not-found-btn:hover {\n background: #333;\n}\n",
|
|
10
|
-
"ikas-config-snippet.json": "{\n \"id\": \"not-found\",\n \"name\": \"404 Page\",\n \"type\": \"section\",\n \"props\": [\n {
|
|
10
|
+
"ikas-config-snippet.json": "{\n \"id\": \"not-found\",\n \"name\": \"404 Page\",\n \"type\": \"section\",\n \"props\": [\n {\n \"name\": \"heading\",\n \"displayName\": \"Heading\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Page Not Found\"\n },\n {\n \"name\": \"message\",\n \"displayName\": \"Message\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"The page you're looking for doesn't exist or has been moved.\"\n },\n {\n \"name\": \"buttonText\",\n \"displayName\": \"Button Text\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Back to Home\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
|
-
"
|
|
14
|
-
"title": "
|
|
15
|
-
"description": "
|
|
13
|
+
"account-addresses": {
|
|
14
|
+
"title": "Account Addresses Section",
|
|
15
|
+
"description": "Complete address management with list display, add/edit form (getIkasCustomerAddressForm/initAddressForm/setAddressForm*/submitAddressForm), and delete (deleteCustomerAddress). Uses isEmpty/isNotEmpty.",
|
|
16
16
|
"files": {
|
|
17
|
-
"index.tsx": "import { useState } from \"preact/hooks\";\nimport {\n
|
|
18
|
-
"types.ts": "
|
|
19
|
-
"styles.css": ".
|
|
20
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
17
|
+
"index.tsx": "import { useState } from \"preact/hooks\";\nimport {\n customerStore,\n getIkasCustomerAddressForm,\n initAddressForm,\n submitAddressForm,\n deleteCustomerAddress,\n setAddressFormFirstName,\n setAddressFormLastName,\n setAddressFormPhone,\n setAddressFormAddressLine1,\n setAddressFormAddressLine2,\n setAddressFormCity,\n setAddressFormDistrict,\n setAddressFormCountry,\n setAddressFormPostalCode,\n setAddressFormTitle,\n isEmpty,\n isNotEmpty,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function AccountAddressesSection({ backgroundColor = \"#ffffff\" }: Props) {\n const [showForm, setShowForm] = useState(false);\n const [editingAddress, setEditingAddress] = useState<any>(null);\n const addresses = customerStore.customer?.addresses ?? [];\n\n const handleAddNew = () => {\n setEditingAddress(null);\n setShowForm(true);\n };\n\n const handleEdit = (address: any) => {\n setEditingAddress(address);\n setShowForm(true);\n };\n\n const handleDelete = async (address: any) => {\n await deleteCustomerAddress(customerStore, address);\n };\n\n return (\n <section className=\"addresses-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"addresses-inner\">\n <div className=\"addresses-header\">\n <h1 className=\"addresses-title\">My Addresses</h1>\n <button className=\"addresses-add-btn\" onClick={handleAddNew}>Add Address</button>\n </div>\n\n {showForm && <AddressFormComponent address={editingAddress} onDone={() => setShowForm(false)} />}\n\n {isEmpty(addresses) && !showForm && (\n <p className=\"addresses-empty\">No addresses saved yet.</p>\n )}\n\n {isNotEmpty(addresses) && (\n <div className=\"addresses-grid\">\n {addresses.map((addr: any) => (\n <div key={addr.id} className=\"address-card\">\n {addr.title && <h3 className=\"address-card-title\">{addr.title}</h3>}\n <p className=\"address-name\">{addr.firstName} {addr.lastName}</p>\n <p className=\"address-line\">{addr.addressLine1}</p>\n {addr.addressLine2 && <p className=\"address-line\">{addr.addressLine2}</p>}\n <p className=\"address-line\">{addr.city} {addr.postalCode}</p>\n <p className=\"address-phone\">{addr.phone}</p>\n <div className=\"address-actions\">\n <button onClick={() => handleEdit(addr)}>Edit</button>\n <button className=\"address-delete-btn\" onClick={() => handleDelete(addr)}>Delete</button>\n </div>\n </div>\n ))}\n </div>\n )}\n </div>\n </section>\n );\n}\n\nfunction AddressFormComponent({ address, onDone }: { address?: any; onDone: () => void }) {\n const addressForm = address\n ? getIkasCustomerAddressForm(address)\n : getIkasCustomerAddressForm({} as any);\n\n const handleInit = async () => {\n await initAddressForm(addressForm, address);\n };\n\n useState(() => { handleInit(); });\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n const success = await submitAddressForm(addressForm);\n if (success) onDone();\n };\n\n return (\n <form className=\"address-form\" onSubmit={handleSubmit}>\n <div className=\"address-form-field\">\n <label>Title</label>\n <input value={addressForm.title?.value ?? \"\"} onInput={(e) => setAddressFormTitle(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n <div className=\"address-form-row\">\n <div className=\"address-form-field\">\n <label>First Name</label>\n <input value={addressForm.firstName?.value ?? \"\"} onInput={(e) => setAddressFormFirstName(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n <div className=\"address-form-field\">\n <label>Last Name</label>\n <input value={addressForm.lastName?.value ?? \"\"} onInput={(e) => setAddressFormLastName(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n </div>\n <div className=\"address-form-field\">\n <label>Phone</label>\n <input value={addressForm.phone?.value ?? \"\"} onInput={(e) => setAddressFormPhone(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n <div className=\"address-form-field\">\n <label>Address Line 1</label>\n <input value={addressForm.addressLine1?.value ?? \"\"} onInput={(e) => setAddressFormAddressLine1(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n <div className=\"address-form-field\">\n <label>Address Line 2</label>\n <input value={addressForm.addressLine2?.value ?? \"\"} onInput={(e) => setAddressFormAddressLine2(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n <div className=\"address-form-row\">\n <div className=\"address-form-field\">\n <label>City</label>\n <input value={addressForm.city?.value ?? \"\"} onInput={(e) => setAddressFormCity(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n <div className=\"address-form-field\">\n <label>District</label>\n <input value={addressForm.district?.value ?? \"\"} onInput={(e) => setAddressFormDistrict(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n </div>\n <div className=\"address-form-row\">\n <div className=\"address-form-field\">\n <label>Postal Code</label>\n <input value={addressForm.postalCode?.value ?? \"\"} onInput={(e) => setAddressFormPostalCode(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n <div className=\"address-form-field\">\n <label>Country</label>\n <input value={addressForm.country?.value ?? \"\"} onInput={(e) => setAddressFormCountry(addressForm, (e.target as HTMLInputElement).value)} />\n </div>\n </div>\n <div className=\"address-form-actions\">\n <button type=\"submit\" disabled={addressForm.isSubmitting}>\n {addressForm.isSubmitting ? \"Saving...\" : \"Save Address\"}\n </button>\n <button type=\"button\" onClick={onDone}>Cancel</button>\n </div>\n </form>\n );\n}\n\n",
|
|
18
|
+
"types.ts": "export interface Props {\n title?: string;\n backgroundColor?: string;\n}\n",
|
|
19
|
+
"styles.css": ".addresses-section { width: 100%; padding: 40px 24px; }\n.addresses-inner { max-width: 800px; margin: 0 auto; }\n.addresses-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }\n.addresses-title { font-size: 24px; font-weight: 700; color: #111; margin: 0; }\n.addresses-add-btn { padding: 10px 20px; font-size: 14px; font-weight: 600; color: #fff; background: #111; border: none; border-radius: 8px; cursor: pointer; }\n.addresses-empty { font-size: 16px; color: #666; text-align: center; padding: 48px 0; }\n.addresses-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }\n.address-card { padding: 20px; border: 1px solid #eee; border-radius: 8px; }\n.address-card-title { font-size: 14px; font-weight: 700; color: #111; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px; }\n.address-name { font-size: 15px; font-weight: 600; color: #111; margin: 0 0 8px 0; }\n.address-line { font-size: 14px; color: #555; margin: 0 0 4px 0; }\n.address-phone { font-size: 14px; color: #555; margin: 8px 0; }\n.address-actions { display: flex; gap: 12px; margin-top: 12px; }\n.address-actions button { font-size: 13px; background: none; border: none; cursor: pointer; padding: 0; color: #111; font-weight: 600; }\n.address-delete-btn { color: #e53935 !important; }\n.address-form { display: flex; flex-direction: column; gap: 12px; padding: 20px; border: 1px solid #eee; border-radius: 8px; margin-bottom: 24px; }\n.address-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }\n.address-form-field { display: flex; flex-direction: column; gap: 4px; }\n.address-form-field label { font-size: 13px; font-weight: 600; color: #333; }\n.address-form-field input { padding: 10px 12px; font-size: 14px; border: 1.5px solid #ddd; border-radius: 6px; outline: none; }\n.address-form-field input:focus { border-color: #111; }\n.address-form-actions { display: flex; gap: 12px; margin-top: 8px; }\n.address-form-actions button { padding: 10px 20px; font-size: 14px; font-weight: 600; border-radius: 6px; cursor: pointer; }\n.address-form-actions button[type=\"submit\"] { color: #fff; background: #111; border: none; }\n.address-form-actions button[type=\"button\"] { color: #333; background: #fff; border: 1.5px solid #ddd; }\n@media (max-width: 768px) { .addresses-grid { grid-template-columns: 1fr; } .address-form-row { grid-template-columns: 1fr; } }\n",
|
|
20
|
+
"ikas-config-snippet.json": "{\n \"id\": \"account-addresses\",\n \"name\": \"Account Addresses\",\n \"type\": \"section\",\n \"props\": [\n {\n \"name\": \"title\",\n \"displayName\": \"Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"My Addresses\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
|
-
"
|
|
24
|
-
"title": "
|
|
25
|
-
"description": "
|
|
23
|
+
"account-info": {
|
|
24
|
+
"title": "Account Info Section",
|
|
25
|
+
"description": "Complete account info section with first name, last name, and phone fields. Uses getAccountInfoForm/initAccountInfoForm/setAccountInfoForm*/submitAccountInfoForm pattern.",
|
|
26
26
|
"files": {
|
|
27
|
-
"index.tsx": "import {
|
|
28
|
-
"types.ts": "
|
|
29
|
-
"styles.css": ".
|
|
30
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
27
|
+
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getAccountInfoForm,\n initAccountInfoForm,\n setAccountInfoFormFirstName,\n setAccountInfoFormLastName,\n setAccountInfoFormPhone,\n submitAccountInfoForm,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function AccountInfoSection({ backgroundColor = \"#ffffff\" }: Props) {\n const accountForm = getAccountInfoForm(customerStore);\n\n useEffect(() => {\n initAccountInfoForm(accountForm);\n }, []);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n await submitAccountInfoForm(accountForm);\n };\n\n return (\n <section className=\"account-info-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"account-info-inner\">\n <h1 className=\"account-info-title\">Account Information</h1>\n\n {accountForm.isSuccess && (\n <div className=\"account-info-success\">Your information has been updated.</div>\n )}\n {accountForm.isFailure && accountForm.responseMessage && (\n <div className=\"account-info-error\">{accountForm.responseMessage}</div>\n )}\n\n <form className=\"account-info-form\" onSubmit={handleSubmit}>\n <div className=\"account-info-row\">\n <div className=\"account-info-field\">\n <label className=\"account-info-label\">{accountForm.firstName.label}</label>\n <input\n className={`account-info-input ${accountForm.firstName.hasError ? \"has-error\" : \"\"}`}\n type=\"text\"\n value={accountForm.firstName.value}\n onInput={(e) => setAccountInfoFormFirstName(accountForm, (e.target as HTMLInputElement).value)}\n />\n {accountForm.firstName.hasError && accountForm.firstName.message && (\n <span className=\"account-info-field-error\">{accountForm.firstName.message}</span>\n )}\n </div>\n <div className=\"account-info-field\">\n <label className=\"account-info-label\">{accountForm.lastName.label}</label>\n <input\n className={`account-info-input ${accountForm.lastName.hasError ? \"has-error\" : \"\"}`}\n type=\"text\"\n value={accountForm.lastName.value}\n onInput={(e) => setAccountInfoFormLastName(accountForm, (e.target as HTMLInputElement).value)}\n />\n {accountForm.lastName.hasError && accountForm.lastName.message && (\n <span className=\"account-info-field-error\">{accountForm.lastName.message}</span>\n )}\n </div>\n </div>\n <div className=\"account-info-field\">\n <label className=\"account-info-label\">{accountForm.phone.label}</label>\n <input\n className={`account-info-input ${accountForm.phone.hasError ? \"has-error\" : \"\"}`}\n type=\"tel\"\n value={accountForm.phone.value}\n onInput={(e) => setAccountInfoFormPhone(accountForm, (e.target as HTMLInputElement).value)}\n />\n {accountForm.phone.hasError && accountForm.phone.message && (\n <span className=\"account-info-field-error\">{accountForm.phone.message}</span>\n )}\n </div>\n <button className=\"account-info-submit\" type=\"submit\" disabled={accountForm.isSubmitting}>\n {accountForm.isSubmitting ? \"Saving...\" : \"Save Changes\"}\n </button>\n </form>\n </div>\n </section>\n );\n}\n\n",
|
|
28
|
+
"types.ts": "export interface Props {\n backgroundColor?: string;\n}\n",
|
|
29
|
+
"styles.css": ".account-info-section { width: 100%; padding: 40px 24px; }\n.account-info-inner { max-width: 480px; margin: 0 auto; }\n.account-info-title { font-size: 24px; font-weight: 700; color: #111; margin: 0 0 24px 0; }\n.account-info-success { padding: 12px 16px; font-size: 14px; color: #1b5e20; background: #e8f5e9; border-radius: 8px; margin-bottom: 20px; }\n.account-info-error { padding: 12px 16px; font-size: 14px; color: #b71c1c; background: #ffebee; border-radius: 8px; margin-bottom: 20px; }\n.account-info-form { display: flex; flex-direction: column; gap: 16px; }\n.account-info-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }\n.account-info-field { display: flex; flex-direction: column; gap: 6px; }\n.account-info-label { font-size: 14px; font-weight: 600; color: #333; }\n.account-info-input { padding: 12px 14px; font-size: 15px; border: 1.5px solid #ddd; border-radius: 8px; outline: none; }\n.account-info-input:focus { border-color: #111; }\n.account-info-input.has-error { border-color: #e53935; }\n.account-info-field-error { font-size: 12px; color: #e53935; }\n.account-info-submit { padding: 14px 24px; font-size: 16px; font-weight: 600; color: #fff; background: #111; border: none; border-radius: 8px; cursor: pointer; margin-top: 8px; }\n.account-info-submit:disabled { background: #ccc; cursor: not-allowed; }\n@media (max-width: 480px) { .account-info-row { grid-template-columns: 1fr; } }\n",
|
|
30
|
+
"ikas-config-snippet.json": "{\n \"id\": \"account-info\",\n \"name\": \"Account Info\",\n \"type\": \"section\",\n \"props\": [\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
-
"
|
|
34
|
-
"title": "
|
|
35
|
-
"description": "
|
|
33
|
+
"account-orders": {
|
|
34
|
+
"title": "Account Orders Section",
|
|
35
|
+
"description": "Complete account orders section showing order history with order number, date, distinct item count (getIkasOrderDistinctItemCount), total price, package status, and order line item thumbnails (getIkasOrderLineVariantMainImage). Uses isEmpty/isNotEmpty utilities.",
|
|
36
36
|
"files": {
|
|
37
|
-
"index.tsx": "import {
|
|
38
|
-
"types.ts": "
|
|
39
|
-
"styles.css": ".
|
|
40
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
37
|
+
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getOrders,\n getIkasOrderFormattedTotalFinalPrice,\n getIkasOrderDistinctItemCount,\n getIkasOrderFormattedOrderedAt,\n getIkasOrderPackageStatusTranslation,\n getIkasOrderHref,\n getIkasOrderLineVariantMainImage,\n getDefaultSrc,\n isEmpty,\n isNotEmpty,\n Router,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function AccountOrdersSection({\n title = \"My Orders\",\n emptyMessage = \"You have no orders yet.\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n useEffect(() => {\n getOrders(customerStore);\n }, []);\n\n const orders = customerStore.orders ?? [];\n\n return (\n <section className=\"orders-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"orders-inner\">\n <h1 className=\"orders-title\">{title}</h1>\n\n {isEmpty(orders) && (\n <div className=\"orders-empty\">\n <p>{emptyMessage}</p>\n <button\n className=\"orders-shop-btn\"\n onClick={() => Router.navigate(\"/\")}\n >\n Start Shopping\n </button>\n </div>\n )}\n\n {isNotEmpty(orders) && (\n <div className=\"orders-list\">\n {orders.map((order) => {\n // Show first line item thumbnail\n const firstItem = order.orderLineItems?.[0];\n const thumbnail = firstItem?.variant\n ? getIkasOrderLineVariantMainImage(firstItem.variant)\n : null;\n\n return (\n <a\n key={order.id}\n href={getIkasOrderHref(order)}\n className=\"order-card\"\n >\n <div className=\"order-card-header\">\n <span className=\"order-number\">Order #{order.orderNumber}</span>\n <span className=\"order-status\">\n {getIkasOrderPackageStatusTranslation(order)}\n </span>\n </div>\n <div className=\"order-card-body\">\n {thumbnail && (\n <img\n className=\"order-thumbnail\"\n src={getDefaultSrc(thumbnail)}\n alt=\"\"\n />\n )}\n <div className=\"order-card-details\">\n <span className=\"order-date\">\n {getIkasOrderFormattedOrderedAt(order)}\n </span>\n <span className=\"order-items\">\n {getIkasOrderDistinctItemCount(order)} items\n </span>\n <span className=\"order-total\">\n {getIkasOrderFormattedTotalFinalPrice(order)}\n </span>\n </div>\n </div>\n </a>\n );\n })}\n </div>\n )}\n </div>\n </section>\n );\n}\n\n",
|
|
38
|
+
"types.ts": "export interface Props {\n title?: string;\n emptyMessage?: string;\n backgroundColor?: string;\n}\n",
|
|
39
|
+
"styles.css": ".orders-section {\n width: 100%;\n padding: 40px 24px;\n}\n\n.orders-inner {\n max-width: 800px;\n margin: 0 auto;\n}\n\n.orders-title {\n font-size: 24px;\n font-weight: 700;\n color: #111;\n margin: 0 0 24px 0;\n}\n\n.orders-empty {\n text-align: center;\n padding: 48px 0;\n}\n\n.orders-empty p {\n font-size: 16px;\n color: #666;\n margin: 0 0 16px 0;\n}\n\n.orders-shop-btn {\n padding: 12px 24px;\n font-size: 14px;\n font-weight: 600;\n color: #111;\n background: #fff;\n border: 1.5px solid #111;\n border-radius: 8px;\n cursor: pointer;\n}\n\n.orders-list {\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.order-card {\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 20px;\n border: 1px solid #eee;\n border-radius: 10px;\n text-decoration: none;\n color: inherit;\n transition: border-color 0.15s ease;\n}\n\n.order-card:hover {\n border-color: #ccc;\n}\n\n.order-card-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.order-number {\n font-size: 15px;\n font-weight: 600;\n color: #111;\n}\n\n.order-status {\n font-size: 12px;\n font-weight: 600;\n color: #fff;\n background: #111;\n padding: 4px 10px;\n border-radius: 12px;\n}\n\n.order-card-details {\n display: flex;\n gap: 24px;\n font-size: 14px;\n color: #666;\n}\n\n.order-total {\n font-weight: 600;\n color: #111;\n margin-left: auto;\n}\n\n@media (max-width: 480px) {\n .order-card-details {\n flex-direction: column;\n gap: 4px;\n }\n\n .order-total {\n margin-left: 0;\n }\n}\n",
|
|
40
|
+
"ikas-config-snippet.json": "{\n \"id\": \"account-orders\",\n \"name\": \"Account Orders\",\n \"type\": \"section\",\n \"entry\": \"./src/components/AccountOrders/index.tsx\",\n \"styles\": \"./src/components/AccountOrders/styles.css\",\n \"props\": [\n {\n \"name\": \"title\",\n \"displayName\": \"Section Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"My Orders\"\n },\n {\n \"name\": \"emptyMessage\",\n \"displayName\": \"Empty State Message\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"You have no orders yet.\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
|
-
"
|
|
44
|
-
"title": "
|
|
45
|
-
"description": "
|
|
43
|
+
"blog-list": {
|
|
44
|
+
"title": "Blog List Section",
|
|
45
|
+
"description": "Complete blog list section with blog card grid, featured images, formatted dates, and forward-only pagination (infinite scroll pattern with IkasThemeInfiniteScroller). Uses hasBlogListNextPage/getBlogListNextPage only — no prev page in production.",
|
|
46
46
|
"files": {
|
|
47
|
-
"index.tsx": "import {\n
|
|
48
|
-
"types.ts": "import {
|
|
49
|
-
"styles.css": "
|
|
50
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
51
|
-
"src/sub-components/ProductCard/index.tsx": "import {\n IkasProduct,\n getSelectedProductVariant,\n getProductVariantFormattedFinalPrice,\n getProductVariantMainImage,\n getSelectedProductVariantHref,\n getDefaultSrc,\n} from \"@ikas/bp-storefront\";\n\n// Sub-component: Props defined inline (no types.ts needed)\ninterface Props {\n product: IkasProduct;\n}\n\n// No observer() needed — this component only uses props from parent\nexport default function ProductCard({ product }: Props) {\n const variant = getSelectedProductVariant(product);\n const productImage = getProductVariantMainImage(variant);\n const image = productImage?.image ?? null;\n const price = getProductVariantFormattedFinalPrice(variant) as unknown as string;\n\n return (\n <a href={getSelectedProductVariantHref(product)} className=\"product-card\">\n {image && <img src={getDefaultSrc(image)} alt={product.name} className=\"product-card-image\" />}\n <h3 className=\"product-card-name\">{product.name}</h3>\n <span className=\"product-card-price\">{price}</span>\n </a>\n );\n}\n",
|
|
52
|
-
"src/sub-components/ProductCard/styles.css": ".product-card { text-decoration: none; color: inherit; }\n.product-card-image { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; background: #f5f5f5; }\n.product-card-name { font-size: 14px; font-weight: 500; color: #111; margin: 10px 0 4px; }\n.product-card-price { font-size: 14px; font-weight: 600; color: #111; }\n",
|
|
53
|
-
"src/sub-components/FilterSidebar/index.tsx": "import {\n IkasProductList,\n getFilterDisplayedValues,\n handleFilterValueClick,\n handleNumberRangeOptionClick,\n isSwatchFilter,\n getIkasFilterThumbnailImage,\n getDefaultSrc,\n} from \"@ikas/bp-storefront\";\n\n// Sub-component: Props defined inline (no types.ts needed)\ninterface Props {\n productList: IkasProductList;\n}\n\n// No observer() needed — this component only uses props from parent\nexport default function FilterSidebar({ productList }: Props) {\n const filters = productList.filters ?? [];\n if (filters.length === 0) return null;\n\n return (\n <aside className=\"product-list-filters\">\n {filters.map((filter) => {\n const values = getFilterDisplayedValues(filter);\n if (values.length === 0 && !filter.numberRangeListOptions?.length) return null;\n\n return (\n <div key={filter.id} className=\"filter-group\">\n <h3 className=\"filter-group-title\">{filter.name}</h3>\n {isSwatchFilter(filter) ? (\n <div className=\"filter-swatches\">\n {values.map((fv) => {\n const thumbnail = getIkasFilterThumbnailImage(fv);\n return (\n <button key={fv.name} className={`filter-swatch${fv.isSelected === true ? \" selected\" : \"\"}`} onClick={() => handleFilterValueClick(productList, filter, fv)} title={fv.name}>\n {thumbnail ? <img src={getDefaultSrc(thumbnail)} alt={fv.name} /> : <span className=\"filter-swatch-color\" style={{ backgroundColor: fv.colorCode ?? \"#ccc\" }} />}\n </button>\n );\n })}\n </div>\n ) : (\n values.map((fv) => (\n <label key={fv.name} className=\"filter-value\">\n <input type=\"checkbox\" checked={fv.isSelected === true} onChange={() => handleFilterValueClick(productList, filter, fv)} />\n <span>{fv.name}</span>\n {fv.resultCount != null && <span className=\"filter-count\">({fv.resultCount})</span>}\n </label>\n ))\n )}\n {filter.numberRangeListOptions?.map((option) => (\n <button key={`${option.from}-${option.to}`} className={`filter-range-btn${option.isSelected ? \" selected\" : \"\"}`} onClick={() => handleNumberRangeOptionClick(productList, filter, option)}>\n {option.from} - {option.to ?? \"+\"}\n </button>\n ))}\n </div>\n );\n })}\n </aside>\n );\n}\n",
|
|
54
|
-
"src/sub-components/FilterSidebar/styles.css": ".product-list-filters { width: 220px; flex-shrink: 0; }\n.filter-group { margin-bottom: 24px; }\n.filter-group-title { font-size: 14px; font-weight: 600; margin: 0 0 12px 0; }\n.filter-value { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #555; cursor: pointer; padding: 4px 0; }\n.filter-count { color: #999; font-size: 12px; }\n.filter-swatches { display: flex; flex-wrap: wrap; gap: 8px; }\n.filter-swatch { width: 32px; height: 32px; border-radius: 50%; border: 2px solid #ddd; padding: 0; cursor: pointer; overflow: hidden; background: none; }\n.filter-swatch.selected { border-color: #000; }\n.filter-swatch img { width: 100%; height: 100%; object-fit: cover; }\n.filter-swatch-color { display: block; width: 100%; height: 100%; border-radius: 50%; }\n.filter-range-btn { padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px; background: #fff; cursor: pointer; font-size: 13px; margin: 4px 4px 4px 0; }\n.filter-range-btn.selected { border-color: #000; font-weight: 600; }\n"
|
|
47
|
+
"index.tsx": "import {\n IkasBlogList,\n hasBlogListNextPage,\n getBlogListNextPage,\n getIkasBlogFormattedDate,\n getIkasBlogHref,\n getDefaultSrc,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function BlogListSection({\n blogList,\n title = \"Blog\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n if (!blogList) return null;\n\n const blogs = blogList.data ?? [];\n const hasNext = hasBlogListNextPage(blogList);\n // Note: dynavit uses only forward pagination (infinite scroll with IkasThemeInfiniteScroller)\n // No hasBlogListPrevPage/getBlogListPrevPage — scroll-only pattern\n\n return (\n <section className=\"blog-list-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"blog-list-inner\">\n <h1 className=\"blog-list-title\">{title}</h1>\n\n {blogs.length === 0 && (\n <p className=\"blog-list-empty\">No blog posts found.</p>\n )}\n\n {/* Blog Grid — production uses IkasThemeInfiniteScroller for infinite scroll */}\n <div className=\"blog-grid\">\n {blogs.map((blog) => (\n <a\n key={blog.id}\n href={getIkasBlogHref(blog)}\n className=\"blog-card\"\n >\n {blog.image && (\n <div className=\"blog-card-image-wrap\">\n <img\n src={getDefaultSrc(blog.image)}\n alt={blog.title}\n className=\"blog-card-image\"\n />\n </div>\n )}\n <div className=\"blog-card-content\">\n <span className=\"blog-card-date\">\n {getIkasBlogFormattedDate(blog)}\n </span>\n <h3 className=\"blog-card-title\">{blog.title}</h3>\n {blog.summary && (\n <p className=\"blog-card-summary\">{blog.summary}</p>\n )}\n <span className=\"blog-card-read-more\">Read more</span>\n </div>\n </a>\n ))}\n </div>\n\n {/* Load More — infinite scroll pattern */}\n {hasNext && (\n <div className=\"blog-load-more\">\n <button\n className=\"blog-load-more-btn\"\n onClick={() => getBlogListNextPage(blogList)}\n >\n Load More\n </button>\n </div>\n )}\n </div>\n </section>\n );\n}\n\n",
|
|
48
|
+
"types.ts": "import { IkasBlogList } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n blogList: IkasBlogList;\n title?: string;\n backgroundColor?: string;\n}\n",
|
|
49
|
+
"styles.css": ".blog-list-section {\n width: 100%;\n padding: 48px 24px;\n}\n\n.blog-list-inner {\n max-width: 1200px;\n margin: 0 auto;\n}\n\n.blog-list-title {\n font-size: 28px;\n font-weight: 700;\n color: #111;\n margin: 0 0 32px 0;\n}\n\n.blog-list-empty {\n font-size: 16px;\n color: #666;\n text-align: center;\n padding: 48px 0;\n}\n\n.blog-grid {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 32px;\n}\n\n.blog-card {\n text-decoration: none;\n color: inherit;\n display: flex;\n flex-direction: column;\n border-radius: 12px;\n overflow: hidden;\n border: 1px solid #eee;\n transition: box-shadow 0.2s ease;\n}\n\n.blog-card:hover {\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);\n}\n\n.blog-card-image-wrap {\n aspect-ratio: 16 / 10;\n overflow: hidden;\n background: #f5f5f5;\n}\n\n.blog-card-image {\n width: 100%;\n height: 100%;\n object-fit: cover;\n transition: transform 0.2s ease;\n}\n\n.blog-card:hover .blog-card-image {\n transform: scale(1.03);\n}\n\n.blog-card-content {\n padding: 20px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n flex: 1;\n}\n\n.blog-card-date {\n font-size: 12px;\n color: #999;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n.blog-card-title {\n font-size: 18px;\n font-weight: 600;\n color: #111;\n margin: 0;\n line-height: 1.4;\n}\n\n.blog-card-summary {\n font-size: 14px;\n color: #666;\n line-height: 1.6;\n margin: 0;\n display: -webkit-box;\n -webkit-line-clamp: 3;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n\n.blog-card-read-more {\n font-size: 14px;\n font-weight: 600;\n color: #111;\n margin-top: auto;\n padding-top: 8px;\n}\n\n/* Pagination */\n.blog-pagination {\n display: flex;\n justify-content: center;\n gap: 12px;\n margin-top: 40px;\n}\n\n.blog-pagination-btn {\n padding: 10px 24px;\n font-size: 14px;\n font-weight: 500;\n background: #fff;\n border: 1px solid #ddd;\n border-radius: 6px;\n cursor: pointer;\n}\n\n.blog-pagination-btn:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n\n.blog-pagination-btn:hover:not(:disabled) {\n border-color: #111;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n .blog-grid {\n grid-template-columns: 1fr;\n gap: 24px;\n }\n}\n",
|
|
50
|
+
"ikas-config-snippet.json": "{\n \"id\": \"blog-list\",\n \"name\": \"Blog List\",\n \"type\": \"section\",\n \"entry\": \"./src/components/BlogList/index.tsx\",\n \"styles\": \"./src/components/BlogList/styles.css\",\n \"props\": [\n {\n \"name\": \"blogList\",\n \"displayName\": \"Blog List\",\n \"type\": \"BLOG_POST_LIST\",\n \"required\": true\n },\n {\n \"name\": \"title\",\n \"displayName\": \"Section Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Blog\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
55
51
|
}
|
|
56
52
|
},
|
|
57
|
-
"
|
|
58
|
-
"title": "
|
|
59
|
-
"description": "
|
|
53
|
+
"blog-post": {
|
|
54
|
+
"title": "Blog Post Section",
|
|
55
|
+
"description": "Single blog post detail page with image, title, date, and HTML content.",
|
|
60
56
|
"files": {
|
|
61
|
-
"index.tsx": "import {\n
|
|
62
|
-
"types.ts": "
|
|
63
|
-
"styles.css": ".
|
|
64
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
57
|
+
"index.tsx": "import {\n getIkasBlogFormattedDate,\n getDefaultSrc,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport function BlogPostSection({\n blogPost,\n backgroundColor = \"#ffffff\",\n}: Props) {\n if (!blogPost) return null;\n\n return (\n <section className=\"blog-post-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"blog-post-inner\">\n {blogPost.image && (\n <img src={getDefaultSrc(blogPost.image)} alt={blogPost.title} className=\"blog-post-hero\" />\n )}\n <h1 className=\"blog-post-title\">{blogPost.title}</h1>\n <span className=\"blog-post-date\">{getIkasBlogFormattedDate(blogPost)}</span>\n <div className=\"blog-post-content\" dangerouslySetInnerHTML={{ __html: blogPost.blogContent.content }} />\n </div>\n </section>\n );\n}\n\nexport default BlogPostSection;\n",
|
|
58
|
+
"types.ts": "import { IkasBlog } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n blogPost: IkasBlog;\n backgroundColor?: string;\n}\n",
|
|
59
|
+
"styles.css": ".blog-post-section {\n width: 100%;\n padding: 40px 24px;\n}\n\n.blog-post-inner {\n max-width: 720px;\n margin: 0 auto;\n}\n\n.blog-post-hero { width: 100%; aspect-ratio: 16/9; object-fit: cover; border-radius: 8px; margin-bottom: 32px; }\n.blog-post-title { font-size: 32px; font-weight: 700; color: #111; margin: 0 0 8px 0; line-height: 1.3; }\n.blog-post-date { font-size: 14px; color: #999; display: block; margin-bottom: 32px; }\n.blog-post-content { font-size: 16px; line-height: 1.8; color: #333; }\n.blog-post-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 24px 0; }\n.blog-post-content h2 { font-size: 24px; font-weight: 700; margin: 32px 0 16px; }\n.blog-post-content h3 { font-size: 20px; font-weight: 600; margin: 24px 0 12px; }\n.blog-post-content p { margin: 0 0 16px 0; }\n",
|
|
60
|
+
"ikas-config-snippet.json": "{\n \"id\": \"blog-post\",\n \"name\": \"Blog Post\",\n \"type\": \"section\",\n \"props\": [\n {\n \"name\": \"blogPost\",\n \"displayName\": \"Blog Post\",\n \"type\": \"BLOG\",\n \"required\": true\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
65
61
|
}
|
|
66
62
|
},
|
|
67
|
-
"
|
|
68
|
-
"title": "
|
|
69
|
-
"description": "
|
|
63
|
+
"cart-page": {
|
|
64
|
+
"title": "Cart Section",
|
|
65
|
+
"description": "Complete cart section with loading state, empty cart handling, line items with variant images/links/discount detection, quantity controls, order adjustments (coupons/discounts), subtotal/total, and checkout via getCheckoutUrlFromCartStore. Cart data is automatically reactive in root components via autorun().",
|
|
70
66
|
"files": {
|
|
71
|
-
"index.tsx": "import {
|
|
72
|
-
"types.ts": "export interface Props {\n
|
|
73
|
-
"styles.css": ".
|
|
74
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
67
|
+
"index.tsx": "import {\n cartStore,\n hasCart,\n changeItemQuantity,\n removeItem,\n getIkasOrderFormattedTotalFinalPrice,\n getIkasOrderFormattedTotalPrice,\n getIkasOrderTotalItemCount,\n getOrderLineItemFormattedFinalPriceWithQuantity,\n getOrderLineItemFormattedPriceWithQuantity,\n hasOrderLineItemDiscount,\n getIkasOrderLineVariantMainImage,\n getIkasOrderLineVariantHref,\n getOrderAdjustmentDisplayName,\n getOrderAdjustmentFormattedAmount,\n getCheckoutUrlFromCartStore,\n getDefaultSrc,\n Router,\n IkasOrderLineItem,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function CartSection({ emptyCartMessage = \"Your cart is empty\" ,\nbackgroundColor = \"#ffffff\",\n}: Props) {\n const cart = cartStore.cart;\n const isLoading = cartStore.isCartLoading;\n const cartHasItems = hasCart(cartStore) as unknown as boolean;\n const lineItems = cart?.orderLineItems ?? [];\n const adjustments = cart?.orderAdjustments ?? [];\n const totalItemCount = cart ? (getIkasOrderTotalItemCount(cart) as unknown as number) : 0;\n\n if (isLoading) {\n return (\n <section className=\"cart-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"cart-inner\">\n <p className=\"cart-loading\">Loading cart...</p>\n </div>\n </section>\n );\n }\n\n if (!cartHasItems) {\n return (\n <section className=\"cart-section\">\n <div className=\"cart-inner\">\n <p className=\"cart-empty\">{emptyCartMessage}</p>\n <button className=\"cart-continue-btn\" onClick={() => Router.navigate(\"/\")}>\n Continue Shopping\n </button>\n </div>\n </section>\n );\n }\n\n const handleQuantityChange = async (item: IkasOrderLineItem, delta: number) => {\n const newQty = item.quantity + delta;\n if (newQty < 1) return;\n await changeItemQuantity(item, newQty);\n };\n\n const handleRemove = async (item: IkasOrderLineItem) => {\n await removeItem(item);\n };\n\n return (\n <section className=\"cart-section\">\n <div className=\"cart-inner\">\n <h1 className=\"cart-title\">Shopping Cart ({totalItemCount} items)</h1>\n\n <div className=\"cart-items\">\n {lineItems.map((item) => {\n const image = item.variant ? getIkasOrderLineVariantMainImage(item.variant) : null;\n const href = item.variant ? getIkasOrderLineVariantHref(item.variant) : undefined;\n const hasDiscount = hasOrderLineItemDiscount(item) as unknown as boolean;\n return (\n <div key={item.id} className=\"cart-item\">\n {image && (\n <a href={href}>\n <img\n className=\"cart-item-image\"\n src={getDefaultSrc(image)}\n alt={item.variant?.name || \"Product\"}\n />\n </a>\n )}\n <div className=\"cart-item-info\">\n <a href={href} className=\"cart-item-name\">{item.variant?.name}</a>\n {hasDiscount && (\n <span className=\"cart-item-original-price\">\n {getOrderLineItemFormattedPriceWithQuantity(item)}\n </span>\n )}\n <span className={`cart-item-total ${hasDiscount ? \"has-discount\" : \"\"}`}>\n {getOrderLineItemFormattedFinalPriceWithQuantity(item)}\n </span>\n </div>\n <div className=\"cart-item-quantity\">\n <button onClick={() => handleQuantityChange(item, -1)}>-</button>\n <span>{item.quantity}</span>\n <button onClick={() => handleQuantityChange(item, 1)}>+</button>\n </div>\n <button className=\"cart-item-remove\" onClick={() => handleRemove(item)}>\n Remove\n </button>\n </div>\n );\n })}\n </div>\n\n {/* Order Adjustments (discounts, coupons, shipping fees) */}\n {adjustments.length > 0 && (\n <div className=\"cart-adjustments\">\n {adjustments.map((adj: any, i: number) => (\n <div key={i} className=\"cart-adjustment-row\">\n <span>{getOrderAdjustmentDisplayName(adj)}</span>\n <span>{getOrderAdjustmentFormattedAmount(adj)}</span>\n </div>\n ))}\n </div>\n )}\n\n <div className=\"cart-summary\">\n <div className=\"cart-summary-row\">\n <span>Subtotal</span>\n <span>{getIkasOrderFormattedTotalPrice(cart!)}</span>\n </div>\n <div className=\"cart-summary-row cart-summary-total\">\n <span>Total</span>\n <span>{getIkasOrderFormattedTotalFinalPrice(cart!)}</span>\n </div>\n <a\n className=\"cart-checkout-btn\"\n href={getCheckoutUrlFromCartStore(cartStore)}\n >\n Proceed to Checkout\n </a>\n </div>\n </div>\n </section>\n );\n}\n\n",
|
|
68
|
+
"types.ts": "export interface Props {\n emptyCartMessage?: string;\n backgroundColor?: string;\n}\n",
|
|
69
|
+
"styles.css": ".cart-section {\n width: 100%;\n padding: 40px 24px;\n}\n\n.cart-inner {\n max-width: 960px;\n margin: 0 auto;\n}\n\n.cart-title {\n font-size: 24px;\n font-weight: 700;\n color: #111;\n margin: 0 0 24px 0;\n}\n\n.cart-empty {\n font-size: 16px;\n color: #666;\n text-align: center;\n padding: 48px 0;\n}\n\n.cart-loading {\n font-size: 16px;\n color: #666;\n text-align: center;\n padding: 48px 0;\n}\n\n.cart-continue-btn {\n display: block;\n margin: 0 auto;\n padding: 12px 24px;\n font-size: 14px;\n font-weight: 600;\n color: #111;\n background: #fff;\n border: 1.5px solid #111;\n border-radius: 8px;\n cursor: pointer;\n}\n\n/* Cart Items */\n.cart-items {\n display: flex;\n flex-direction: column;\n gap: 16px;\n margin-bottom: 32px;\n}\n\n.cart-item {\n display: flex;\n align-items: center;\n gap: 16px;\n padding: 16px;\n border: 1px solid #eee;\n border-radius: 8px;\n}\n\n.cart-item-image {\n width: 80px;\n height: 80px;\n object-fit: cover;\n border-radius: 6px;\n background-color: #f5f5f5;\n}\n\n.cart-item-info {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.cart-item-name {\n font-size: 14px;\n font-weight: 600;\n color: #111;\n text-decoration: none;\n}\n\n.cart-item-name:hover {\n text-decoration: underline;\n}\n\n.cart-item-original-price {\n font-size: 13px;\n color: #999;\n text-decoration: line-through;\n}\n\n.cart-item-total {\n font-size: 15px;\n font-weight: 600;\n color: #111;\n min-width: 80px;\n}\n\n.cart-item-total.has-discount {\n color: #e53935;\n}\n\n.cart-item-quantity {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.cart-item-quantity button {\n width: 32px;\n height: 32px;\n border: 1px solid #ddd;\n border-radius: 4px;\n background: #fff;\n cursor: pointer;\n font-size: 16px;\n}\n\n.cart-item-remove {\n padding: 4px 8px;\n font-size: 12px;\n color: #e53935;\n background: none;\n border: none;\n cursor: pointer;\n}\n\n/* Order Adjustments */\n.cart-adjustments {\n border-top: 1px solid #eee;\n padding: 16px 0;\n margin-bottom: 8px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.cart-adjustment-row {\n display: flex;\n justify-content: space-between;\n font-size: 14px;\n color: #555;\n}\n\n/* Cart Summary */\n.cart-summary {\n border-top: 1px solid #eee;\n padding-top: 24px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n align-items: flex-end;\n}\n\n.cart-summary-row {\n display: flex;\n justify-content: space-between;\n width: 280px;\n font-size: 14px;\n color: #555;\n}\n\n.cart-summary-total {\n font-size: 18px;\n font-weight: 700;\n color: #111;\n}\n\n.cart-checkout-btn {\n display: block;\n width: 280px;\n padding: 14px 24px;\n font-size: 16px;\n font-weight: 600;\n color: #fff;\n background-color: #111;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n margin-top: 8px;\n text-align: center;\n text-decoration: none;\n}\n\n.cart-checkout-btn:hover {\n background-color: #333;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n .cart-item {\n flex-wrap: wrap;\n }\n\n .cart-summary {\n align-items: stretch;\n }\n\n .cart-summary-row,\n .cart-checkout-btn {\n width: 100%;\n }\n}\n",
|
|
70
|
+
"ikas-config-snippet.json": "{\n \"id\": \"cart-page\",\n \"name\": \"Cart Page\",\n \"type\": \"section\",\n \"entry\": \"./src/components/CartSection/index.tsx\",\n \"styles\": \"./src/components/CartSection/styles.css\",\n \"props\": [\n {\n \"name\": \"emptyCartMessage\",\n \"displayName\": \"Empty Cart Message\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Your cart is empty\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
75
71
|
}
|
|
76
72
|
},
|
|
77
|
-
"
|
|
78
|
-
"title": "
|
|
79
|
-
"description": "
|
|
73
|
+
"contact-form": {
|
|
74
|
+
"title": "Contact Form Section",
|
|
75
|
+
"description": "Complete contact form section with name, email, phone, and message fields. Uses the initContactForm/setContactForm*/submitContactForm pattern with validation and success state.",
|
|
80
76
|
"files": {
|
|
81
|
-
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n
|
|
82
|
-
"types.ts": "export interface Props {\n
|
|
83
|
-
"styles.css": ".
|
|
84
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
77
|
+
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getContactForm,\n initContactForm,\n setContactFormEmail,\n setContactFormFirstName,\n setContactFormLastName,\n setContactFormPhone,\n setContactFormMessage,\n submitContactForm,\n clearContactForm,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function ContactFormSection({\n title = \"Contact Us\",\n successMessage = \"Thank you! Your message has been sent.\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n const contactForm = getContactForm(customerStore);\n\n useEffect(() => {\n initContactForm(contactForm);\n }, []);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n const success = await submitContactForm(contactForm);\n if (success) {\n clearContactForm(contactForm);\n }\n };\n\n return (\n <section className=\"contact-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"contact-inner\">\n <h1 className=\"contact-title\">{title}</h1>\n\n {contactForm.isSuccess && (\n <div className=\"contact-success-banner\">{successMessage}</div>\n )}\n\n {contactForm.isFailure && contactForm.responseMessage && (\n <div className=\"contact-error-banner\">{contactForm.responseMessage}</div>\n )}\n\n <form className=\"contact-form\" onSubmit={handleSubmit}>\n <div className=\"contact-row\">\n <div className=\"contact-field\">\n <label className=\"contact-label\">{contactForm.firstName.label}</label>\n <input\n className={`contact-input ${contactForm.firstName.hasError ? \"has-error\" : \"\"}`}\n type=\"text\"\n placeholder={contactForm.firstName.placeholder}\n value={contactForm.firstName.value}\n onInput={(e) =>\n setContactFormFirstName(contactForm, (e.target as HTMLInputElement).value)\n }\n />\n {contactForm.firstName.hasError && contactForm.firstName.message && (\n <span className=\"contact-field-error\">{contactForm.firstName.message}</span>\n )}\n </div>\n\n <div className=\"contact-field\">\n <label className=\"contact-label\">{contactForm.lastName.label}</label>\n <input\n className={`contact-input ${contactForm.lastName.hasError ? \"has-error\" : \"\"}`}\n type=\"text\"\n placeholder={contactForm.lastName.placeholder}\n value={contactForm.lastName.value}\n onInput={(e) =>\n setContactFormLastName(contactForm, (e.target as HTMLInputElement).value)\n }\n />\n {contactForm.lastName.hasError && contactForm.lastName.message && (\n <span className=\"contact-field-error\">{contactForm.lastName.message}</span>\n )}\n </div>\n </div>\n\n <div className=\"contact-row\">\n <div className=\"contact-field\">\n <label className=\"contact-label\">{contactForm.email.label}</label>\n <input\n className={`contact-input ${contactForm.email.hasError ? \"has-error\" : \"\"}`}\n type=\"email\"\n placeholder={contactForm.email.placeholder}\n value={contactForm.email.value}\n onInput={(e) =>\n setContactFormEmail(contactForm, (e.target as HTMLInputElement).value)\n }\n />\n {contactForm.email.hasError && contactForm.email.message && (\n <span className=\"contact-field-error\">{contactForm.email.message}</span>\n )}\n </div>\n\n <div className=\"contact-field\">\n <label className=\"contact-label\">{contactForm.phone.label}</label>\n <input\n className={`contact-input ${contactForm.phone.hasError ? \"has-error\" : \"\"}`}\n type=\"tel\"\n placeholder={contactForm.phone.placeholder}\n value={contactForm.phone.value}\n onInput={(e) =>\n setContactFormPhone(contactForm, (e.target as HTMLInputElement).value)\n }\n />\n {contactForm.phone.hasError && contactForm.phone.message && (\n <span className=\"contact-field-error\">{contactForm.phone.message}</span>\n )}\n </div>\n </div>\n\n <div className=\"contact-field\">\n <label className=\"contact-label\">{contactForm.message.label}</label>\n <textarea\n className={`contact-textarea ${contactForm.message.hasError ? \"has-error\" : \"\"}`}\n placeholder={contactForm.message.placeholder}\n value={contactForm.message.value}\n rows={5}\n onInput={(e) =>\n setContactFormMessage(contactForm, (e.target as HTMLTextAreaElement).value)\n }\n />\n {contactForm.message.hasError && contactForm.message.message && (\n <span className=\"contact-field-error\">{contactForm.message.message}</span>\n )}\n </div>\n\n <button\n className=\"contact-submit-btn\"\n type=\"submit\"\n disabled={contactForm.isSubmitting}\n >\n {contactForm.isSubmitting ? \"Sending...\" : \"Send Message\"}\n </button>\n </form>\n </div>\n </section>\n );\n}\n\n",
|
|
78
|
+
"types.ts": "export interface Props {\n title?: string;\n successMessage?: string;\n backgroundColor?: string;\n}\n",
|
|
79
|
+
"styles.css": ".contact-section {\n width: 100%;\n padding: 64px 24px;\n}\n\n.contact-inner {\n max-width: 640px;\n margin: 0 auto;\n}\n\n.contact-title {\n font-size: 28px;\n font-weight: 700;\n color: #111;\n margin: 0 0 24px 0;\n text-align: center;\n}\n\n.contact-success-banner {\n padding: 12px 16px;\n font-size: 14px;\n color: #1b5e20;\n background-color: #e8f5e9;\n border-radius: 8px;\n margin-bottom: 20px;\n text-align: center;\n}\n\n.contact-error-banner {\n padding: 12px 16px;\n font-size: 14px;\n color: #b71c1c;\n background-color: #ffebee;\n border-radius: 8px;\n margin-bottom: 20px;\n}\n\n.contact-form {\n display: flex;\n flex-direction: column;\n gap: 16px;\n}\n\n.contact-row {\n display: flex;\n gap: 12px;\n}\n\n.contact-row .contact-field {\n flex: 1;\n}\n\n.contact-field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.contact-label {\n font-size: 14px;\n font-weight: 600;\n color: #333;\n}\n\n.contact-input,\n.contact-textarea {\n padding: 12px 14px;\n font-size: 15px;\n border: 1.5px solid #ddd;\n border-radius: 8px;\n outline: none;\n transition: border-color 0.15s ease;\n font-family: inherit;\n}\n\n.contact-input:focus,\n.contact-textarea:focus {\n border-color: #111;\n}\n\n.contact-input.has-error,\n.contact-textarea.has-error {\n border-color: #e53935;\n}\n\n.contact-textarea {\n resize: vertical;\n min-height: 120px;\n}\n\n.contact-field-error {\n font-size: 12px;\n color: #e53935;\n}\n\n.contact-submit-btn {\n padding: 14px 24px;\n font-size: 16px;\n font-weight: 600;\n color: #fff;\n background-color: #111;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: background-color 0.15s ease;\n margin-top: 8px;\n}\n\n.contact-submit-btn:hover:not(:disabled) {\n background-color: #333;\n}\n\n.contact-submit-btn:disabled {\n background-color: #ccc;\n cursor: not-allowed;\n}\n\n@media (max-width: 480px) {\n .contact-row {\n flex-direction: column;\n }\n}\n",
|
|
80
|
+
"ikas-config-snippet.json": "{\n \"id\": \"contact-form\",\n \"name\": \"Contact Form\",\n \"type\": \"section\",\n \"entry\": \"./src/components/ContactForm/index.tsx\",\n \"styles\": \"./src/components/ContactForm/styles.css\",\n \"props\": [\n {\n \"name\": \"title\",\n \"displayName\": \"Section Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Contact Us\"\n },\n {\n \"name\": \"successMessage\",\n \"displayName\": \"Success Message\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Thank you! Your message has been sent.\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
85
81
|
}
|
|
86
82
|
},
|
|
87
|
-
"
|
|
88
|
-
"title": "
|
|
89
|
-
"description": "
|
|
83
|
+
"faq": {
|
|
84
|
+
"title": "FAQ Accordion Section",
|
|
85
|
+
"description": "Complete FAQ section with accordion-style question/answer pairs. Uses static TEXT props for questions and answers with toggle state management. Commonly reused across multiple pages (about, contact, product detail).",
|
|
90
86
|
"files": {
|
|
91
|
-
"index.tsx": "import {
|
|
92
|
-
"types.ts": "export interface Props {\n
|
|
93
|
-
"styles.css": ".
|
|
94
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
87
|
+
"index.tsx": "import { useState } from \"preact/hooks\";\nimport { Props, FaqItem } from \"./types\";\n\nexport default function FaqSection({\n title = \"Frequently Asked Questions\",\n items = [],\n backgroundColor = \"#ffffff\",\n}: Props) {\n const [openIndex, setOpenIndex] = useState<number | null>(null);\n\n const handleToggle = (index: number) => {\n setOpenIndex(openIndex === index ? null : index);\n };\n\n return (\n <section className=\"faq-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"faq-inner\">\n <h2 className=\"faq-title\">{title}</h2>\n\n {items.length === 0 && (\n <p className=\"faq-empty\">No questions added yet.</p>\n )}\n\n <div className=\"faq-list\">\n {items.map((item: FaqItem, index: number) => {\n const isOpen = openIndex === index;\n return (\n <div\n key={index}\n className={`faq-item ${isOpen ? \"faq-item-open\" : \"\"}`}\n >\n <button\n className=\"faq-question\"\n onClick={() => handleToggle(index)}\n aria-expanded={isOpen}\n >\n <span>{item.question}</span>\n <span className=\"faq-icon\">{isOpen ? \"−\" : \"+\"}</span>\n </button>\n {isOpen && (\n <div className=\"faq-answer\">\n <p>{item.answer}</p>\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n </section>\n );\n}\n",
|
|
88
|
+
"types.ts": "export interface FaqItem {\n question: string;\n answer: string;\n}\n\nexport interface Props {\n title?: string;\n items?: FaqItem[];\n backgroundColor?: string;\n}\n",
|
|
89
|
+
"styles.css": ".faq-section {\n width: 100%;\n padding: 64px 24px;\n}\n\n.faq-inner {\n max-width: 720px;\n margin: 0 auto;\n}\n\n.faq-title {\n font-size: 28px;\n font-weight: 700;\n color: #111;\n margin: 0 0 32px 0;\n text-align: center;\n}\n\n.faq-empty {\n font-size: 16px;\n color: #666;\n text-align: center;\n}\n\n.faq-list {\n display: flex;\n flex-direction: column;\n}\n\n.faq-item {\n border-bottom: 1px solid #eee;\n}\n\n.faq-question {\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 20px 0;\n font-size: 16px;\n font-weight: 500;\n color: #111;\n background: none;\n border: none;\n cursor: pointer;\n text-align: left;\n gap: 16px;\n}\n\n.faq-question:hover {\n color: #333;\n}\n\n.faq-icon {\n font-size: 20px;\n font-weight: 300;\n flex-shrink: 0;\n color: #666;\n}\n\n.faq-answer {\n padding: 0 0 20px 0;\n}\n\n.faq-answer p {\n font-size: 15px;\n line-height: 1.7;\n color: #555;\n margin: 0;\n}\n\n.faq-item-open .faq-question {\n font-weight: 600;\n}\n",
|
|
90
|
+
"ikas-config-snippet.json": "{\n \"id\": \"faq\",\n \"name\": \"FAQ\",\n \"type\": \"section\",\n \"entry\": \"./src/components/FAQ/index.tsx\",\n \"styles\": \"./src/components/FAQ/styles.css\",\n \"props\": [\n {\n \"name\": \"title\",\n \"displayName\": \"Section Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Frequently Asked Questions\"\n },\n {\n \"name\": \"items\",\n \"displayName\": \"FAQ Items\",\n \"type\": \"COMPONENT_LIST\",\n \"componentProps\": [\n {\n \"name\": \"question\",\n \"displayName\": \"Question\",\n \"type\": \"TEXT\",\n \"required\": true\n },\n {\n \"name\": \"answer\",\n \"displayName\": \"Answer\",\n \"type\": \"TEXT\",\n \"required\": true\n }\n ]\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
95
91
|
}
|
|
96
92
|
},
|
|
97
|
-
"
|
|
98
|
-
"title": "
|
|
99
|
-
"description": "
|
|
93
|
+
"favorites-page": {
|
|
94
|
+
"title": "Favorites Page Section",
|
|
95
|
+
"description": "Complete favorites page with getFavoriteProducts loading, product cards with pricing/images/links, and remove functionality.",
|
|
100
96
|
"files": {
|
|
101
|
-
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n
|
|
102
|
-
"types.ts": "export interface Props {\n
|
|
103
|
-
"styles.css": ".
|
|
104
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
97
|
+
"index.tsx": "import { useEffect, useState } from \"preact/hooks\";\nimport {\n customerStore,\n getFavoriteProducts,\n removeIkasProductFromFavorites,\n getSelectedProductVariant,\n getSelectedProductVariantHref,\n getProductVariantFormattedFinalPrice,\n getProductVariantFormattedSellPrice,\n hasProductVariantDiscount,\n getProductVariantMainImage,\n getDefaultSrc,\n IkasProduct,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function FavoritesPageSection({ backgroundColor = \"#ffffff\" }: Props) {\n const [favorites, setFavorites] = useState<IkasProduct[]>([]);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n getFavoriteProducts(customerStore).then((products) => {\n setFavorites(products ?? []);\n setLoading(false);\n });\n }, []);\n\n const handleRemove = async (product: IkasProduct) => {\n await removeIkasProductFromFavorites(product);\n setFavorites(favorites.filter((f) => f.id !== product.id));\n };\n\n if (loading) {\n return <section className=\"favorites-page\"><div className=\"favorites-inner\"><p>Loading...</p></div></section>;\n }\n\n return (\n <section className=\"favorites-page\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"favorites-inner\">\n <h1 className=\"favorites-title\">My Favorites</h1>\n\n {favorites.length === 0 && <p className=\"favorites-empty\">No favorites yet.</p>}\n\n <div className=\"favorites-grid\">\n {favorites.map((product) => {\n const variant = getSelectedProductVariant(product);\n const href = getSelectedProductVariantHref(product);\n const productImage = getProductVariantMainImage(variant);\n const image = productImage?.image ?? null;\n const hasDiscount = hasProductVariantDiscount(variant) as unknown as boolean;\n const finalPrice = getProductVariantFormattedFinalPrice(variant) as unknown as string;\n const sellPrice = hasDiscount ? (getProductVariantFormattedSellPrice(variant) as unknown as string) : null;\n\n return (\n <div key={product.id} className=\"favorites-card\">\n {image && (\n <a href={href}><img className=\"favorites-card-img\" src={getDefaultSrc(image)} alt={product.name} /></a>\n )}\n <div className=\"favorites-card-info\">\n <a href={href} className=\"favorites-card-name\">{product.name}</a>\n <div className=\"favorites-card-price\">\n {sellPrice && <span className=\"favorites-card-old-price\">{sellPrice}</span>}\n <span>{finalPrice}</span>\n </div>\n <button className=\"favorites-card-remove\" onClick={() => handleRemove(product)}>Remove</button>\n </div>\n </div>\n );\n })}\n </div>\n </div>\n </section>\n );\n}\n\n",
|
|
98
|
+
"types.ts": "export interface Props {\n backgroundColor?: string;\n}\n",
|
|
99
|
+
"styles.css": ".favorites-page { width: 100%; padding: 40px 24px; }\n.favorites-inner { max-width: 1000px; margin: 0 auto; }\n.favorites-title { font-size: 24px; font-weight: 700; color: #111; margin: 0 0 24px 0; }\n.favorites-empty { font-size: 16px; color: #666; text-align: center; padding: 48px 0; }\n.favorites-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }\n.favorites-card { border: 1px solid #eee; border-radius: 8px; overflow: hidden; }\n.favorites-card-img { width: 100%; aspect-ratio: 1; object-fit: cover; background: #f5f5f5; display: block; }\n.favorites-card-info { padding: 12px; }\n.favorites-card-name { font-size: 14px; font-weight: 600; color: #111; text-decoration: none; display: block; margin-bottom: 4px; }\n.favorites-card-price { font-size: 15px; font-weight: 700; }\n.favorites-card-old-price { text-decoration: line-through; color: #999; margin-right: 8px; font-weight: 400; font-size: 13px; }\n.favorites-card-remove { font-size: 13px; color: #e53935; background: none; border: none; cursor: pointer; padding: 0; margin-top: 8px; }\n@media (max-width: 768px) { .favorites-grid { grid-template-columns: repeat(2, 1fr); } }\n@media (max-width: 480px) { .favorites-grid { grid-template-columns: 1fr; } }\n",
|
|
100
|
+
"ikas-config-snippet.json": "{\n \"id\": \"favorites-page\",\n \"name\": \"Favorites Page\",\n \"type\": \"section\",\n \"props\": [\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
105
101
|
}
|
|
106
102
|
},
|
|
107
|
-
"
|
|
108
|
-
"title": "
|
|
109
|
-
"description": "
|
|
103
|
+
"footer": {
|
|
104
|
+
"title": "Footer Section",
|
|
105
|
+
"description": "Complete footer section with logo, navigation link columns, contact info, social media links, and copyright. Uses IkasNavigationLink for editable links.",
|
|
110
106
|
"files": {
|
|
111
|
-
"index.tsx": "import {
|
|
112
|
-
"types.ts": "
|
|
113
|
-
"styles.css": ".
|
|
114
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
107
|
+
"index.tsx": "import { IkasNavigationLink, getDefaultSrc } from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function FooterSection({\n logo,\n description,\n linkColumn1Title = \"Shop\",\n linkColumn1,\n linkColumn2Title = \"Company\",\n linkColumn2,\n copyright = \"All rights reserved.\",\n backgroundColor = \"#f9fafb\",\n}: Props) {\n return (\n <footer className=\"footer-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"footer-inner\">\n <div className=\"footer-grid\">\n {/* Brand Column */}\n <div className=\"footer-brand\">\n {logo ? (\n <img src={getDefaultSrc(logo)} alt={logo.altText || \"Logo\"} className=\"footer-logo\" />\n ) : (\n <span className=\"footer-logo-text\">Store</span>\n )}\n {description && <p className=\"footer-description\">{description}</p>}\n </div>\n\n {/* Link Column 1 */}\n <div className=\"footer-link-column\">\n <h4 className=\"footer-column-title\">{linkColumn1Title}</h4>\n <nav className=\"footer-links\">\n {(linkColumn1?.links ?? []).map((link: IkasNavigationLink, i: number) => (\n <a\n key={i}\n href={link.href}\n className=\"footer-link\"\n target={link.openInNewTab ? \"_blank\" : undefined}\n >\n {link.label}\n </a>\n ))}\n </nav>\n </div>\n\n {/* Link Column 2 */}\n <div className=\"footer-link-column\">\n <h4 className=\"footer-column-title\">{linkColumn2Title}</h4>\n <nav className=\"footer-links\">\n {(linkColumn2?.links ?? []).map((link: IkasNavigationLink, i: number) => (\n <a\n key={i}\n href={link.href}\n className=\"footer-link\"\n target={link.openInNewTab ? \"_blank\" : undefined}\n >\n {link.label}\n </a>\n ))}\n </nav>\n </div>\n </div>\n\n {/* Copyright */}\n <div className=\"footer-bottom\">\n <p className=\"footer-copyright\">{copyright}</p>\n </div>\n </div>\n </footer>\n );\n}\n",
|
|
108
|
+
"types.ts": "import { IkasNavigationLinkList, IkasImage } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n logo?: IkasImage | null;\n description?: string;\n linkColumn1Title?: string;\n linkColumn1?: IkasNavigationLinkList;\n linkColumn2Title?: string;\n linkColumn2?: IkasNavigationLinkList;\n copyright?: string;\n backgroundColor?: string;\n}\n",
|
|
109
|
+
"styles.css": ".footer-section {\n width: 100%;\n border-top: 1px solid #eee;\n padding: 48px 24px 24px;\n}\n\n.footer-inner {\n max-width: 1200px;\n margin: 0 auto;\n}\n\n.footer-grid {\n display: grid;\n grid-template-columns: 2fr 1fr 1fr;\n gap: 48px;\n margin-bottom: 48px;\n}\n\n/* Brand */\n.footer-brand {\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.footer-logo {\n height: 36px;\n width: auto;\n}\n\n.footer-logo-text {\n font-size: 20px;\n font-weight: 700;\n color: #111;\n}\n\n.footer-description {\n font-size: 14px;\n color: #666;\n line-height: 1.6;\n max-width: 300px;\n margin: 0;\n}\n\n/* Link Columns */\n.footer-link-column {\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.footer-column-title {\n font-size: 14px;\n font-weight: 700;\n color: #111;\n margin: 0;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n.footer-links {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.footer-link {\n font-size: 14px;\n color: #666;\n text-decoration: none;\n transition: color 0.15s ease;\n}\n\n.footer-link:hover {\n color: #111;\n}\n\n/* Bottom */\n.footer-bottom {\n border-top: 1px solid #eee;\n padding-top: 24px;\n}\n\n.footer-copyright {\n font-size: 13px;\n color: #999;\n margin: 0;\n text-align: center;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n .footer-grid {\n grid-template-columns: 1fr;\n gap: 32px;\n }\n}\n",
|
|
110
|
+
"ikas-config-snippet.json": "{\n \"id\": \"footer\",\n \"name\": \"Footer\",\n \"type\": \"section\",\n \"entry\": \"./src/components/Footer/index.tsx\",\n \"styles\": \"./src/components/Footer/styles.css\",\n \"props\": [\n {\n \"name\": \"logo\",\n \"displayName\": \"Logo\",\n \"type\": \"IMAGE\"\n },\n {\n \"name\": \"description\",\n \"displayName\": \"Description\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Your one-stop shop for quality products.\"\n },\n {\n \"name\": \"linkColumn1Title\",\n \"displayName\": \"Column 1 Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Shop\"\n },\n {\n \"name\": \"linkColumn1\",\n \"displayName\": \"Column 1 Links\",\n \"type\": \"LIST_OF_LINK\"\n },\n {\n \"name\": \"linkColumn2Title\",\n \"displayName\": \"Column 2 Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Company\"\n },\n {\n \"name\": \"linkColumn2\",\n \"displayName\": \"Column 2 Links\",\n \"type\": \"LIST_OF_LINK\"\n },\n {\n \"name\": \"copyright\",\n \"displayName\": \"Copyright Text\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"All rights reserved.\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#f9fafb\"\n }\n ]\n}\n"
|
|
115
111
|
}
|
|
116
112
|
},
|
|
117
|
-
"
|
|
118
|
-
"title": "
|
|
119
|
-
"description": "
|
|
113
|
+
"forgot-password": {
|
|
114
|
+
"title": "Forgot Password Section",
|
|
115
|
+
"description": "Complete forgot password section with email input for password recovery. Uses the initForgotPasswordForm/setForgotPasswordFormEmail/submitForgotPasswordForm pattern with success and error states.",
|
|
120
116
|
"files": {
|
|
121
|
-
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n
|
|
122
|
-
"types.ts": "export interface Props {\n
|
|
123
|
-
"styles.css": ".
|
|
124
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
117
|
+
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getForgotPasswordForm,\n initForgotPasswordForm,\n setForgotPasswordFormEmail,\n submitForgotPasswordForm,\n Router,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function ForgotPasswordSection({\n successMessage = \"Password reset link has been sent to your email.\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n const forgotForm = getForgotPasswordForm(customerStore);\n\n useEffect(() => {\n initForgotPasswordForm(forgotForm);\n }, []);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n await submitForgotPasswordForm(forgotForm);\n };\n\n return (\n <section className=\"forgot-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"forgot-inner\">\n <h1 className=\"forgot-title\">Forgot Password</h1>\n <p className=\"forgot-subtitle\">\n Enter your email address and we'll send you a link to reset your password.\n </p>\n\n {forgotForm.isSuccess && (\n <div className=\"forgot-success-banner\">{successMessage}</div>\n )}\n\n {forgotForm.isFailure && forgotForm.responseMessage && (\n <div className=\"forgot-error-banner\">{forgotForm.responseMessage}</div>\n )}\n\n {!forgotForm.isSuccess && (\n <form className=\"forgot-form\" onSubmit={handleSubmit}>\n <div className=\"forgot-field\">\n <label className=\"forgot-label\">{forgotForm.email.label}</label>\n <input\n className={`forgot-input ${forgotForm.email.hasError ? \"has-error\" : \"\"}`}\n type=\"email\"\n placeholder={forgotForm.email.placeholder}\n value={forgotForm.email.value}\n onInput={(e) =>\n setForgotPasswordFormEmail(forgotForm, (e.target as HTMLInputElement).value)\n }\n />\n {forgotForm.email.hasError && forgotForm.email.message && (\n <span className=\"forgot-field-error\">{forgotForm.email.message}</span>\n )}\n </div>\n\n <button\n className=\"forgot-submit-btn\"\n type=\"submit\"\n disabled={forgotForm.isSubmitting}\n >\n {forgotForm.isSubmitting ? \"Sending...\" : \"Send Reset Link\"}\n </button>\n </form>\n )}\n\n <p className=\"forgot-back-link\">\n <a\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n Router.navigateToPage(\"LOGIN\");\n }}\n >\n Back to Sign In\n </a>\n </p>\n </div>\n </section>\n );\n}\n\n",
|
|
118
|
+
"types.ts": "export interface Props {\n successMessage?: string;\n backgroundColor?: string;\n}\n",
|
|
119
|
+
"styles.css": ".forgot-section {\n width: 100%;\n padding: 64px 24px;\n min-height: 60vh;\n display: flex;\n align-items: center;\n}\n\n.forgot-inner {\n max-width: 400px;\n margin: 0 auto;\n width: 100%;\n}\n\n.forgot-title {\n font-size: 28px;\n font-weight: 700;\n color: #111;\n margin: 0 0 8px 0;\n text-align: center;\n}\n\n.forgot-subtitle {\n font-size: 14px;\n color: #666;\n text-align: center;\n margin: 0 0 24px 0;\n line-height: 1.5;\n}\n\n.forgot-success-banner {\n padding: 12px 16px;\n font-size: 14px;\n color: #1b5e20;\n background-color: #e8f5e9;\n border-radius: 8px;\n margin-bottom: 20px;\n text-align: center;\n}\n\n.forgot-error-banner {\n padding: 12px 16px;\n font-size: 14px;\n color: #b71c1c;\n background-color: #ffebee;\n border-radius: 8px;\n margin-bottom: 20px;\n}\n\n.forgot-form {\n display: flex;\n flex-direction: column;\n gap: 16px;\n}\n\n.forgot-field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.forgot-label {\n font-size: 14px;\n font-weight: 600;\n color: #333;\n}\n\n.forgot-input {\n padding: 12px 14px;\n font-size: 15px;\n border: 1.5px solid #ddd;\n border-radius: 8px;\n outline: none;\n transition: border-color 0.15s ease;\n}\n\n.forgot-input:focus {\n border-color: #111;\n}\n\n.forgot-input.has-error {\n border-color: #e53935;\n}\n\n.forgot-field-error {\n font-size: 12px;\n color: #e53935;\n}\n\n.forgot-submit-btn {\n padding: 14px 24px;\n font-size: 16px;\n font-weight: 600;\n color: #fff;\n background-color: #111;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: background-color 0.15s ease;\n margin-top: 8px;\n}\n\n.forgot-submit-btn:hover:not(:disabled) {\n background-color: #333;\n}\n\n.forgot-submit-btn:disabled {\n background-color: #ccc;\n cursor: not-allowed;\n}\n\n.forgot-back-link {\n font-size: 14px;\n text-align: center;\n margin-top: 24px;\n}\n\n.forgot-back-link a {\n color: #111;\n font-weight: 600;\n text-decoration: none;\n}\n\n.forgot-back-link a:hover {\n text-decoration: underline;\n}\n",
|
|
120
|
+
"ikas-config-snippet.json": "{\n \"id\": \"forgot-password\",\n \"name\": \"Forgot Password\",\n \"type\": \"section\",\n \"entry\": \"./src/components/ForgotPassword/index.tsx\",\n \"styles\": \"./src/components/ForgotPassword/styles.css\",\n \"props\": [\n {\n \"name\": \"successMessage\",\n \"displayName\": \"Success Message\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Password reset link has been sent to your email.\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
125
121
|
}
|
|
126
122
|
},
|
|
127
|
-
"
|
|
128
|
-
"title": "
|
|
129
|
-
"description": "
|
|
123
|
+
"header": {
|
|
124
|
+
"title": "Header Section",
|
|
125
|
+
"description": "Complete header section with announcement bar, navigation with subLinks (mega-menu), logo, customer icon, mini-cart sidebar with item images/prices/quantity controls/adjustments/checkout, and mobile menu overlay. Cart badge and customer state are automatically reactive in root components via autorun().",
|
|
130
126
|
"files": {
|
|
131
|
-
"index.tsx": "import {
|
|
132
|
-
"types.ts": "
|
|
133
|
-
"styles.css": ".
|
|
134
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
127
|
+
"index.tsx": "import { useState } from \"preact/hooks\";\nimport {\n cartStore,\n customerStore,\n hasCustomer,\n hasCart,\n getIkasOrderTotalItemCount,\n getIkasOrderFormattedTotalPrice,\n getIkasOrderLineVariantMainImage,\n getIkasOrderLineVariantHref,\n getOrderLineItemFormattedFinalPrice,\n getOrderAdjustmentDisplayName,\n getOrderAdjustmentFormattedAmount,\n changeItemQuantity,\n removeItem,\n getCheckoutUrlFromCartStore,\n getDefaultSrc,\n Router,\n IkasNavigationLink,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function HeaderSection({\n logo,\n navigationLinks,\n announcementText,\n announcementBgColor = \"#111\",\n announcementTextColor = \"#fff\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n const [mobileMenuOpen, setMobileMenuOpen] = useState(false);\n const [cartSidebarOpen, setCartSidebarOpen] = useState(false);\n\n const cart = cartStore.cart;\n const isCartLoading = cartStore.isCartLoading;\n const cartHasItems = hasCart(cartStore) as unknown as boolean;\n const itemCount = cart ? (getIkasOrderTotalItemCount(cart) as unknown as number) : 0;\n const isLoggedIn = hasCustomer(customerStore) as unknown as boolean;\n const lineItems = cart?.orderLineItems ?? [];\n const adjustments = cart?.orderAdjustments ?? [];\n\n return (\n <section className=\"header-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n {/* Announcement Bar */}\n {announcementText && (\n <div\n className=\"header-announcement\"\n style={{ backgroundColor: announcementBgColor, color: announcementTextColor }}\n >\n <span>{announcementText}</span>\n </div>\n )}\n\n {/* Main Header */}\n <div className=\"header-main\">\n <div className=\"header-inner\">\n {/* Mobile Hamburger */}\n <button\n className=\"header-hamburger\"\n onClick={() => setMobileMenuOpen(true)}\n aria-label=\"Open menu\"\n >\n <span /><span /><span />\n </button>\n\n {/* Logo */}\n <a className=\"header-logo\" href=\"/\" onClick={(e) => { e.preventDefault(); Router.navigate(\"/\"); }}>\n {logo ? (\n <img src={getDefaultSrc(logo)} alt={logo.altText || \"Store Logo\"} className=\"header-logo-img\" />\n ) : (\n <span className=\"header-logo-text\">Store</span>\n )}\n </a>\n\n {/* Desktop Navigation with subLinks */}\n <nav className=\"header-nav\">\n {(navigationLinks?.links ?? []).map((link: IkasNavigationLink, i: number) => (\n <div key={i} className=\"header-nav-item\">\n <a\n href={link.href}\n className=\"header-nav-link\"\n target={link.openInNewTab ? \"_blank\" : undefined}\n >\n {link.label}\n </a>\n {/* Mega-menu for subLinks */}\n {link.subLinks && link.subLinks.length > 0 && (\n <div className=\"header-submenu\">\n {link.subLinks.map((sub: IkasNavigationLink, j: number) => (\n <a\n key={j}\n href={sub.href}\n className=\"header-submenu-link\"\n target={sub.openInNewTab ? \"_blank\" : undefined}\n >\n {sub.label}\n </a>\n ))}\n </div>\n )}\n </div>\n ))}\n </nav>\n\n {/* Utility Icons */}\n <div className=\"header-icons\">\n <button\n className=\"header-icon-btn\"\n onClick={() => Router.navigateToPage(isLoggedIn ? \"ACCOUNT\" : \"LOGIN\")}\n aria-label=\"Account\"\n >\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\">\n <path d=\"M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2\" />\n <circle cx=\"12\" cy=\"7\" r=\"4\" />\n </svg>\n </button>\n <button\n className=\"header-icon-btn header-cart-btn\"\n onClick={() => setCartSidebarOpen(true)}\n aria-label=\"Cart\"\n >\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\">\n <path d=\"M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z\" />\n <line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\" />\n <path d=\"M16 10a4 4 0 01-8 0\" />\n </svg>\n {itemCount > 0 && <span className=\"header-cart-badge\">{itemCount}</span>}\n </button>\n </div>\n </div>\n </div>\n\n {/* Mini-Cart Sidebar — production uses IkasThemeOverlay for visibility */}\n {cartSidebarOpen && (\n <div className=\"cart-sidebar-overlay\">\n <div className=\"cart-sidebar-backdrop\" onClick={() => setCartSidebarOpen(false)} />\n <div className=\"cart-sidebar\">\n <div className=\"cart-sidebar-header\">\n <h3>Cart ({itemCount})</h3>\n <button className=\"cart-sidebar-close\" onClick={() => setCartSidebarOpen(false)}>×</button>\n </div>\n\n {isCartLoading && <div className=\"cart-sidebar-loading\">Loading...</div>}\n\n {!cartHasItems && !isCartLoading && (\n <p className=\"cart-sidebar-empty\">Your cart is empty</p>\n )}\n\n <div className=\"cart-sidebar-items\">\n {lineItems.map((item) => {\n const image = item.variant ? getIkasOrderLineVariantMainImage(item.variant) : null;\n const href = item.variant ? getIkasOrderLineVariantHref(item.variant) : undefined;\n return (\n <div key={item.id} className=\"cart-sidebar-item\">\n {image && (\n <a href={href}>\n <img className=\"cart-sidebar-item-img\" src={getDefaultSrc(image)} alt={item.variant?.name || \"\"} />\n </a>\n )}\n <div className=\"cart-sidebar-item-info\">\n <a href={href} className=\"cart-sidebar-item-name\">{item.variant?.name}</a>\n <span className=\"cart-sidebar-item-price\">{getOrderLineItemFormattedFinalPrice(item)}</span>\n <div className=\"cart-sidebar-item-qty\">\n <button onClick={() => changeItemQuantity(item, Math.max(1, item.quantity - 1))}>-</button>\n <span>{item.quantity}</span>\n <button onClick={() => changeItemQuantity(item, item.quantity + 1)}>+</button>\n </div>\n </div>\n <button className=\"cart-sidebar-item-remove\" onClick={() => removeItem(item)}>×</button>\n </div>\n );\n })}\n </div>\n\n {/* Order Adjustments (discounts, shipping, etc.) */}\n {adjustments.length > 0 && (\n <div className=\"cart-sidebar-adjustments\">\n {adjustments.map((adj: any, i: number) => (\n <div key={i} className=\"cart-sidebar-adj-row\">\n <span>{getOrderAdjustmentDisplayName(adj)}</span>\n <span>{getOrderAdjustmentFormattedAmount(adj)}</span>\n </div>\n ))}\n </div>\n )}\n\n {cartHasItems && cart && (\n <div className=\"cart-sidebar-footer\">\n <div className=\"cart-sidebar-total\">\n <span>Total</span>\n <span>{getIkasOrderFormattedTotalPrice(cart)}</span>\n </div>\n <a\n href={getCheckoutUrlFromCartStore(cartStore)}\n className=\"cart-sidebar-checkout-btn\"\n >\n Checkout\n </a>\n </div>\n )}\n </div>\n </div>\n )}\n\n {/* Mobile Menu — production uses IkasThemeOverlay visible property */}\n {mobileMenuOpen && (\n <div className=\"header-mobile-overlay\">\n <div className=\"header-mobile-backdrop\" onClick={() => setMobileMenuOpen(false)} />\n <div className=\"header-mobile-menu\">\n <button className=\"header-mobile-close\" onClick={() => setMobileMenuOpen(false)}>\n ×\n </button>\n <nav className=\"header-mobile-nav\">\n {(navigationLinks?.links ?? []).map((link: IkasNavigationLink, i: number) => (\n <div key={i}>\n <a\n href={link.href}\n className=\"header-mobile-link\"\n onClick={() => setMobileMenuOpen(false)}\n >\n {link.label}\n </a>\n {link.subLinks?.map((sub: IkasNavigationLink, j: number) => (\n <a\n key={j}\n href={sub.href}\n className=\"header-mobile-sublink\"\n onClick={() => setMobileMenuOpen(false)}\n >\n {sub.label}\n </a>\n ))}\n </div>\n ))}\n </nav>\n </div>\n </div>\n )}\n </section>\n );\n}\n\n",
|
|
128
|
+
"types.ts": "import { IkasImage, IkasNavigationLinkList } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n logo?: IkasImage | null;\n navigationLinks?: IkasNavigationLinkList;\n announcementText?: string;\n announcementBgColor?: string;\n announcementTextColor?: string;\n backgroundColor?: string;\n}\n",
|
|
129
|
+
"styles.css": ".header-section {\n width: 100%;\n position: sticky;\n top: 0;\n z-index: 100;\n background: #fff;\n}\n\n.header-announcement {\n text-align: center;\n padding: 8px 16px;\n font-size: 13px;\n font-weight: 500;\n}\n\n.header-main {\n border-bottom: 1px solid #eee;\n}\n\n.header-inner {\n max-width: 1200px;\n margin: 0 auto;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px 24px;\n gap: 24px;\n}\n\n/* Logo */\n.header-logo {\n text-decoration: none;\n flex-shrink: 0;\n}\n\n.header-logo-img {\n height: 40px;\n width: auto;\n display: block;\n}\n\n.header-logo-text {\n font-size: 22px;\n font-weight: 700;\n color: #111;\n}\n\n/* Navigation */\n.header-nav {\n display: flex;\n gap: 24px;\n flex: 1;\n justify-content: center;\n}\n\n.header-nav-link {\n font-size: 14px;\n font-weight: 500;\n color: #333;\n text-decoration: none;\n white-space: nowrap;\n transition: color 0.15s ease;\n}\n\n.header-nav-link:hover {\n color: #111;\n}\n\n/* Icons */\n.header-icons {\n display: flex;\n gap: 12px;\n align-items: center;\n flex-shrink: 0;\n}\n\n.header-icon-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: #333;\n padding: 4px;\n position: relative;\n}\n\n.header-cart-badge {\n position: absolute;\n top: -4px;\n right: -6px;\n background: #111;\n color: #fff;\n font-size: 10px;\n font-weight: 700;\n width: 18px;\n height: 18px;\n border-radius: 50%;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n/* Hamburger */\n.header-hamburger {\n display: none;\n flex-direction: column;\n gap: 4px;\n background: none;\n border: none;\n cursor: pointer;\n padding: 4px;\n}\n\n.header-hamburger span {\n display: block;\n width: 20px;\n height: 2px;\n background: #333;\n}\n\n/* Mobile Menu */\n.header-mobile-overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 200;\n}\n\n.header-mobile-backdrop {\n position: absolute;\n inset: 0;\n background: rgba(0, 0, 0, 0.4);\n}\n\n.header-mobile-menu {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 280px;\n background: #fff;\n padding: 24px;\n overflow-y: auto;\n}\n\n.header-mobile-close {\n font-size: 28px;\n background: none;\n border: none;\n cursor: pointer;\n color: #333;\n margin-bottom: 16px;\n}\n\n.header-mobile-nav {\n display: flex;\n flex-direction: column;\n gap: 16px;\n}\n\n.header-mobile-link {\n font-size: 16px;\n font-weight: 500;\n color: #333;\n text-decoration: none;\n padding: 8px 0;\n border-bottom: 1px solid #f0f0f0;\n}\n\n/* SubMenu */\n.header-nav-item {\n position: relative;\n}\n\n.header-submenu {\n display: none;\n position: absolute;\n top: 100%;\n left: 0;\n background: #fff;\n box-shadow: 0 4px 12px rgba(0,0,0,0.1);\n border-radius: 8px;\n padding: 12px 0;\n min-width: 180px;\n z-index: 50;\n}\n\n.header-nav-item:hover .header-submenu {\n display: flex;\n flex-direction: column;\n}\n\n.header-submenu-link {\n padding: 8px 20px;\n font-size: 14px;\n color: #333;\n text-decoration: none;\n white-space: nowrap;\n}\n\n.header-submenu-link:hover {\n background: #f5f5f5;\n color: #111;\n}\n\n/* Cart Sidebar */\n.cart-sidebar-overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 300;\n}\n\n.cart-sidebar-backdrop {\n position: absolute;\n inset: 0;\n background: rgba(0, 0, 0, 0.4);\n}\n\n.cart-sidebar {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n width: 380px;\n max-width: 100%;\n background: #fff;\n display: flex;\n flex-direction: column;\n box-shadow: -4px 0 12px rgba(0,0,0,0.1);\n}\n\n.cart-sidebar-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 16px 20px;\n border-bottom: 1px solid #eee;\n}\n\n.cart-sidebar-header h3 {\n font-size: 18px;\n font-weight: 700;\n margin: 0;\n}\n\n.cart-sidebar-close {\n font-size: 28px;\n background: none;\n border: none;\n cursor: pointer;\n color: #333;\n}\n\n.cart-sidebar-loading {\n padding: 24px 20px;\n text-align: center;\n color: #666;\n font-size: 14px;\n}\n\n.cart-sidebar-empty {\n padding: 48px 20px;\n text-align: center;\n color: #666;\n font-size: 14px;\n}\n\n.cart-sidebar-items {\n flex: 1;\n overflow-y: auto;\n padding: 12px 20px;\n}\n\n.cart-sidebar-item {\n display: flex;\n gap: 12px;\n padding: 12px 0;\n border-bottom: 1px solid #f5f5f5;\n position: relative;\n}\n\n.cart-sidebar-item-img {\n width: 64px;\n height: 64px;\n object-fit: cover;\n border-radius: 6px;\n background: #f5f5f5;\n}\n\n.cart-sidebar-item-info {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.cart-sidebar-item-name {\n font-size: 13px;\n font-weight: 600;\n color: #111;\n text-decoration: none;\n}\n\n.cart-sidebar-item-price {\n font-size: 13px;\n color: #666;\n}\n\n.cart-sidebar-item-qty {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-top: 4px;\n}\n\n.cart-sidebar-item-qty button {\n width: 24px;\n height: 24px;\n border: 1px solid #ddd;\n border-radius: 4px;\n background: #fff;\n cursor: pointer;\n font-size: 14px;\n}\n\n.cart-sidebar-item-qty span {\n font-size: 13px;\n min-width: 20px;\n text-align: center;\n}\n\n.cart-sidebar-item-remove {\n position: absolute;\n top: 12px;\n right: 0;\n background: none;\n border: none;\n font-size: 18px;\n color: #999;\n cursor: pointer;\n}\n\n.cart-sidebar-adjustments {\n padding: 8px 20px;\n border-top: 1px solid #eee;\n}\n\n.cart-sidebar-adj-row {\n display: flex;\n justify-content: space-between;\n font-size: 13px;\n color: #666;\n padding: 4px 0;\n}\n\n.cart-sidebar-footer {\n padding: 16px 20px;\n border-top: 1px solid #eee;\n}\n\n.cart-sidebar-total {\n display: flex;\n justify-content: space-between;\n font-size: 16px;\n font-weight: 700;\n color: #111;\n margin-bottom: 12px;\n}\n\n.cart-sidebar-checkout-btn {\n display: block;\n width: 100%;\n padding: 14px;\n font-size: 15px;\n font-weight: 600;\n color: #fff;\n background: #111;\n border: none;\n border-radius: 8px;\n text-align: center;\n text-decoration: none;\n cursor: pointer;\n}\n\n/* Mobile SubLinks */\n.header-mobile-sublink {\n font-size: 14px;\n color: #666;\n text-decoration: none;\n padding: 6px 0 6px 16px;\n display: block;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n .header-hamburger {\n display: flex;\n }\n\n .header-nav {\n display: none;\n }\n}\n",
|
|
130
|
+
"ikas-config-snippet.json": "{\n \"id\": \"header\",\n \"name\": \"Header\",\n \"type\": \"section\",\n \"entry\": \"./src/components/Header/index.tsx\",\n \"styles\": \"./src/components/Header/styles.css\",\n \"props\": [\n {\n \"name\": \"logo\",\n \"displayName\": \"Logo\",\n \"type\": \"IMAGE\"\n },\n {\n \"name\": \"navigationLinks\",\n \"displayName\": \"Navigation Links\",\n \"type\": \"LIST_OF_LINK\"\n },\n {\n \"name\": \"announcementText\",\n \"displayName\": \"Announcement Text\",\n \"type\": \"TEXT\"\n },\n {\n \"name\": \"announcementBgColor\",\n \"displayName\": \"Announcement Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#111111\"\n },\n {\n \"name\": \"announcementTextColor\",\n \"displayName\": \"Announcement Text Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
135
131
|
}
|
|
136
132
|
},
|
|
137
|
-
"
|
|
138
|
-
"title": "
|
|
139
|
-
"description": "
|
|
133
|
+
"hero-banner": {
|
|
134
|
+
"title": "Hero Banner Section",
|
|
135
|
+
"description": "Full-width hero banner with heading, subtitle, CTA button, and background image.",
|
|
140
136
|
"files": {
|
|
141
|
-
"index.tsx": "import {
|
|
142
|
-
"types.ts": "
|
|
143
|
-
"styles.css": ".
|
|
144
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
137
|
+
"index.tsx": "import { getDefaultSrc, Router } from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport function HeroBannerSection({\n heading = \"Welcome to Our Store\",\n subtitle,\n buttonText = \"Shop Now\",\n buttonLink = \"/\",\n backgroundImage,\n backgroundColor = \"#111\",\n textColor = \"#fff\",\n}: Props) {\n const bgStyle: Record<string, string> = { backgroundColor, color: textColor };\n if (backgroundImage) {\n bgStyle.backgroundImage = `url(${getDefaultSrc(backgroundImage)})`;\n bgStyle.backgroundSize = \"cover\";\n bgStyle.backgroundPosition = \"center\";\n }\n\n return (\n <section className=\"hero-banner\" style={bgStyle}>\n <div className=\"hero-inner\">\n <h1 className=\"hero-heading\">{heading}</h1>\n {subtitle && <p className=\"hero-subtitle\">{subtitle}</p>}\n {buttonText && (\n <button\n className=\"hero-cta\"\n onClick={() => Router.navigate(buttonLink)}\n >\n {buttonText}\n </button>\n )}\n </div>\n </section>\n );\n}\n\nexport default HeroBannerSection;\n",
|
|
138
|
+
"types.ts": "import { IkasImage } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n heading?: string;\n subtitle?: string;\n buttonText?: string;\n buttonLink?: string;\n backgroundImage?: IkasImage | null;\n backgroundColor?: string;\n textColor?: string;\n}\n",
|
|
139
|
+
"styles.css": ".hero-banner {\n width: 100%;\n min-height: 480px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n padding: 64px 24px;\n}\n\n.hero-inner {\n max-width: 720px;\n}\n\n.hero-heading {\n font-size: 48px;\n font-weight: 800;\n margin: 0 0 16px 0;\n line-height: 1.1;\n}\n\n.hero-subtitle {\n font-size: 18px;\n opacity: 0.85;\n margin: 0 0 32px 0;\n line-height: 1.5;\n}\n\n.hero-cta {\n display: inline-block;\n padding: 14px 32px;\n font-size: 16px;\n font-weight: 600;\n color: #111;\n background: #fff;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: opacity 0.15s;\n}\n\n.hero-cta:hover {\n opacity: 0.9;\n}\n\n@media (max-width: 768px) {\n .hero-banner { min-height: 360px; padding: 48px 24px; }\n .hero-heading { font-size: 32px; }\n .hero-subtitle { font-size: 16px; }\n}\n",
|
|
140
|
+
"ikas-config-snippet.json": "{\n \"id\": \"hero-banner\",\n \"name\": \"Hero Banner\",\n \"type\": \"section\",\n \"props\": [\n { \"name\": \"heading\", \"displayName\": \"Heading\", \"type\": \"TEXT\", \"defaultValue\": \"Welcome to Our Store\" },\n { \"name\": \"subtitle\", \"displayName\": \"Subtitle\", \"type\": \"TEXT\" },\n { \"name\": \"buttonText\", \"displayName\": \"Button Text\", \"type\": \"TEXT\", \"defaultValue\": \"Shop Now\" },\n { \"name\": \"buttonLink\", \"displayName\": \"Button Link\", \"type\": \"TEXT\", \"defaultValue\": \"/\" },\n { \"name\": \"backgroundImage\", \"displayName\": \"Background Image\", \"type\": \"IMAGE\" },\n { \"name\": \"backgroundColor\", \"displayName\": \"Background Color\", \"type\": \"COLOR\", \"defaultValue\": \"#111\" },\n { \"name\": \"textColor\", \"displayName\": \"Text Color\", \"type\": \"COLOR\", \"defaultValue\": \"#fff\" }\n ]\n}\n"
|
|
145
141
|
}
|
|
146
142
|
},
|
|
147
|
-
"
|
|
148
|
-
"title": "
|
|
149
|
-
"description": "
|
|
143
|
+
"login-page": {
|
|
144
|
+
"title": "Login Section",
|
|
145
|
+
"description": "Complete login section with form validation, error display, loading state, forgot password link, register redirect, social login (socialLogin + SocialLoginProvider), and handleSocialLogin callback with HandleSocialLoginReturnType status handling.",
|
|
150
146
|
"files": {
|
|
151
|
-
"index.tsx": "import {\n
|
|
152
|
-
"types.ts": "
|
|
153
|
-
"styles.css": ".
|
|
154
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
147
|
+
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getLoginForm,\n initLoginForm,\n setLoginFormEmail,\n setLoginFormPassword,\n submitLoginForm,\n handleSocialLogin,\n socialLogin,\n Router,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function LoginSection({\n redirectAfterLogin = \"/account\",\n showForgotPassword = true,\n backgroundColor = \"#ffffff\",\n}: Props) {\n const loginForm = getLoginForm(customerStore);\n\n useEffect(() => {\n initLoginForm(loginForm);\n // Handle social login callback if returning from OAuth redirect\n handleSocialLogin(customerStore).then((result) => {\n // HandleSocialLoginReturnType: { status: \"success\" | \"fail\", message?: string }\n if (result.status === \"success\") {\n Router.navigate(redirectAfterLogin);\n }\n });\n }, []);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n const success = await submitLoginForm(loginForm);\n if (success) {\n Router.navigate(redirectAfterLogin);\n }\n };\n\n const handleSocialLoginClick = async (provider: \"GOOGLE\" | \"FACEBOOK\" | \"APPLE\") => {\n // SocialLoginProvider enum — redirects user to provider's OAuth page\n await socialLogin(customerStore, provider as any);\n };\n\n return (\n <section className=\"login-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"login-inner\">\n <h1 className=\"login-title\">Sign In</h1>\n\n {loginForm.isFailure && loginForm.responseMessage && (\n <div className=\"login-error-banner\">{loginForm.responseMessage}</div>\n )}\n\n {/* Social Login Buttons */}\n <div className=\"login-social\">\n <button className=\"login-social-btn\" onClick={() => handleSocialLoginClick(\"GOOGLE\")}>\n Continue with Google\n </button>\n <button className=\"login-social-btn\" onClick={() => handleSocialLoginClick(\"FACEBOOK\")}>\n Continue with Facebook\n </button>\n </div>\n\n <div className=\"login-divider\"><span>or</span></div>\n\n <form className=\"login-form\" onSubmit={handleSubmit}>\n <div className=\"login-field\">\n <label className=\"login-label\">{loginForm.email.label}</label>\n <input\n className={`login-input ${loginForm.email.hasError ? \"has-error\" : \"\"}`}\n type=\"email\"\n placeholder={loginForm.email.placeholder}\n value={loginForm.email.value}\n onInput={(e) => setLoginFormEmail(loginForm, (e.target as HTMLInputElement).value)}\n />\n {loginForm.email.hasError && loginForm.email.message && (\n <span className=\"login-field-error\">{loginForm.email.message}</span>\n )}\n </div>\n\n <div className=\"login-field\">\n <label className=\"login-label\">{loginForm.password.label}</label>\n <input\n className={`login-input ${loginForm.password.hasError ? \"has-error\" : \"\"}`}\n type=\"password\"\n placeholder={loginForm.password.placeholder}\n value={loginForm.password.value}\n onInput={(e) => setLoginFormPassword(loginForm, (e.target as HTMLInputElement).value)}\n />\n {loginForm.password.hasError && loginForm.password.message && (\n <span className=\"login-field-error\">{loginForm.password.message}</span>\n )}\n </div>\n\n {showForgotPassword && (\n <a\n className=\"login-forgot-link\"\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n Router.navigateToPage(\"FORGOT_PASSWORD\");\n }}\n >\n Forgot password?\n </a>\n )}\n\n <button\n className=\"login-submit-btn\"\n type=\"submit\"\n disabled={loginForm.isSubmitting}\n >\n {loginForm.isSubmitting ? \"Signing in...\" : \"Sign In\"}\n </button>\n </form>\n\n <p className=\"login-register-link\">\n Don't have an account?{\" \"}\n <a\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n Router.navigateToPage(\"REGISTER\");\n }}\n >\n Create one\n </a>\n </p>\n </div>\n </section>\n );\n}\n\n",
|
|
148
|
+
"types.ts": "export interface Props {\n redirectAfterLogin?: string;\n showForgotPassword?: boolean;\n backgroundColor?: string;\n}\n",
|
|
149
|
+
"styles.css": ".login-section {\n width: 100%;\n padding: 64px 24px;\n}\n\n.login-inner {\n max-width: 400px;\n margin: 0 auto;\n}\n\n.login-title {\n font-size: 28px;\n font-weight: 700;\n color: #111;\n margin: 0 0 24px 0;\n text-align: center;\n}\n\n.login-error-banner {\n padding: 12px 16px;\n font-size: 14px;\n color: #b71c1c;\n background-color: #ffebee;\n border-radius: 8px;\n margin-bottom: 20px;\n}\n\n.login-form {\n display: flex;\n flex-direction: column;\n gap: 16px;\n}\n\n.login-field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.login-label {\n font-size: 14px;\n font-weight: 600;\n color: #333;\n}\n\n.login-input {\n padding: 12px 14px;\n font-size: 15px;\n border: 1.5px solid #ddd;\n border-radius: 8px;\n outline: none;\n transition: border-color 0.15s ease;\n}\n\n.login-input:focus {\n border-color: #111;\n}\n\n.login-input.has-error {\n border-color: #e53935;\n}\n\n.login-field-error {\n font-size: 12px;\n color: #e53935;\n}\n\n.login-forgot-link {\n font-size: 13px;\n color: #666;\n text-decoration: none;\n align-self: flex-end;\n}\n\n.login-forgot-link:hover {\n color: #111;\n}\n\n.login-submit-btn {\n padding: 14px 24px;\n font-size: 16px;\n font-weight: 600;\n color: #fff;\n background-color: #111;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: background-color 0.15s ease;\n margin-top: 8px;\n}\n\n.login-submit-btn:hover:not(:disabled) {\n background-color: #333;\n}\n\n.login-submit-btn:disabled {\n background-color: #ccc;\n cursor: not-allowed;\n}\n\n.login-register-link {\n font-size: 14px;\n color: #666;\n text-align: center;\n margin-top: 24px;\n}\n\n.login-register-link a {\n color: #111;\n font-weight: 600;\n text-decoration: none;\n}\n\n.login-register-link a:hover {\n text-decoration: underline;\n}\n",
|
|
150
|
+
"ikas-config-snippet.json": "{\n \"id\": \"login-page\",\n \"name\": \"Login Page\",\n \"type\": \"section\",\n \"entry\": \"./src/components/LoginSection/index.tsx\",\n \"styles\": \"./src/components/LoginSection/styles.css\",\n \"props\": [\n {\n \"name\": \"redirectAfterLogin\",\n \"displayName\": \"Redirect After Login\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"/account\"\n },\n {\n \"name\": \"showForgotPassword\",\n \"displayName\": \"Show Forgot Password Link\",\n \"type\": \"BOOLEAN\",\n \"defaultValue\": true\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
155
151
|
}
|
|
156
152
|
},
|
|
157
|
-
"
|
|
158
|
-
"title": "
|
|
159
|
-
"description": "
|
|
153
|
+
"order-detail": {
|
|
154
|
+
"title": "Order Detail Section",
|
|
155
|
+
"description": "Complete order detail section with getOrder fetch, line items with variant images/links/discount, order adjustments, payment transactions, totals, package status, and refund functionality. Uses getPageParams for order ID from URL.",
|
|
160
156
|
"files": {
|
|
161
|
-
"index.tsx": "import {\n
|
|
162
|
-
"types.ts": "
|
|
163
|
-
"styles.css": ".
|
|
164
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
157
|
+
"index.tsx": "import { useEffect, useState } from \"preact/hooks\";\nimport {\n customerStore,\n getOrder,\n getIkasOrderFormattedTotalFinalPrice,\n getIkasOrderFormattedTotalPrice,\n getIkasOrderFormattedOrderedAt,\n getIkasOrderFormattedShippingTotal,\n getIkasOrderFormattedTotalTax,\n getIkasOrderDisplayedPackages,\n getIkasOrderPackageStatusTranslation,\n getIkasOrderLineVariantMainImage,\n getIkasOrderLineVariantHref,\n getOrderLineItemFormattedFinalPriceWithQuantity,\n hasOrderLineItemDiscount,\n getOrderAdjustmentDisplayName,\n getOrderAdjustmentFormattedAmount,\n getOrderAdjustmentIsDecrement,\n getOrderTransactionFormattedAmount,\n getOrderTransactionPaymentMethodTranslation,\n getIkasOrderRefundableItems,\n isIkasOrderRefundable,\n getOrderLineItemRefundQuantity,\n setOrderLineItemRefundQuantity,\n refundOrder,\n getDefaultSrc,\n Router,\n IkasOrder,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function OrderDetailSection({ backgroundColor = \"#ffffff\" }: Props) {\n const [order, setOrder] = useState<IkasOrder | null>(null);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n const params = Router.getPageParams();\n const orderId = params.id;\n if (orderId) {\n getOrder(customerStore, orderId).then((o) => {\n setOrder(o);\n setLoading(false);\n });\n }\n }, []);\n\n if (loading) return <div className=\"order-detail-section\"><div className=\"order-detail-inner\"><p>Loading...</p></div></div>;\n if (!order) return <div className=\"order-detail-section\"><div className=\"order-detail-inner\"><p>Order not found.</p></div></div>;\n\n const packages = getIkasOrderDisplayedPackages(order);\n const lineItems = order.orderLineItems ?? [];\n const adjustments = order.orderAdjustments ?? [];\n const transactions = order.orderTransactions ?? [];\n const canRefund = isIkasOrderRefundable(order) as unknown as boolean;\n const refundableItems = canRefund ? getIkasOrderRefundableItems(order) : [];\n\n return (\n <section className=\"order-detail-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"order-detail-inner\">\n <h1>Order #{order.orderNumber}</h1>\n <p>{getIkasOrderFormattedOrderedAt(order)}</p>\n\n {packages.map((pkg, i) => (\n <span key={i} className=\"order-status-badge\">{getIkasOrderPackageStatusTranslation(order)}</span>\n ))}\n\n <div className=\"order-items\">\n {lineItems.map((item) => {\n const image = item.variant ? getIkasOrderLineVariantMainImage(item.variant) : null;\n const href = item.variant ? getIkasOrderLineVariantHref(item.variant) : undefined;\n const hasDiscount = hasOrderLineItemDiscount(item) as unknown as boolean;\n return (\n <div key={item.id} className=\"order-item\">\n {image && <a href={href}><img src={getDefaultSrc(image)} width={64} height={64} style={{ objectFit: \"cover\", borderRadius: 4 }} alt=\"\" /></a>}\n <div>\n <a href={href}>{item.variant?.name}</a>\n <span> x{item.quantity}</span>\n <span> {getOrderLineItemFormattedFinalPriceWithQuantity(item)}</span>\n </div>\n </div>\n );\n })}\n </div>\n\n {adjustments.length > 0 && adjustments.map((adj: any, i: number) => (\n <div key={i}>\n <span>{getOrderAdjustmentDisplayName(adj)}: </span>\n <span style={{ color: getOrderAdjustmentIsDecrement(adj) ? \"green\" : \"inherit\" }}>{getOrderAdjustmentFormattedAmount(adj)}</span>\n </div>\n ))}\n\n {transactions.map((tx: any, i: number) => (\n <div key={i}>{getOrderTransactionPaymentMethodTranslation(tx)}: {getOrderTransactionFormattedAmount(tx)}</div>\n ))}\n\n <div style={{ marginTop: 16, borderTop: \"1px solid #eee\", paddingTop: 16 }}>\n <div>Shipping: {getIkasOrderFormattedShippingTotal(order)}</div>\n <div>Tax: {getIkasOrderFormattedTotalTax(order)}</div>\n <div>Subtotal: {getIkasOrderFormattedTotalPrice(order)}</div>\n <div style={{ fontWeight: 700, fontSize: 18 }}>Total: {getIkasOrderFormattedTotalFinalPrice(order)}</div>\n </div>\n\n {canRefund && (\n <div style={{ marginTop: 24 }}>\n <h3>Refund</h3>\n {refundableItems.map((item: any) => (\n <div key={item.id}>\n {item.variant?.name}: <input type=\"number\" min={0} max={item.quantity} value={getOrderLineItemRefundQuantity(item) ?? 0}\n onChange={(e) => setOrderLineItemRefundQuantity(Number((e.target as HTMLInputElement).value), item)} style={{ width: 60 }} />\n </div>\n ))}\n <button onClick={() => refundOrder(customerStore, order)} style={{ marginTop: 8 }}>Submit Refund</button>\n </div>\n )}\n </div>\n </section>\n );\n}\n\n",
|
|
158
|
+
"types.ts": "export interface Props {\n backgroundColor?: string;\n}\n",
|
|
159
|
+
"styles.css": ".order-detail-section { width: 100%; padding: 40px 24px; }\n.order-detail-inner { max-width: 800px; margin: 0 auto; }\n",
|
|
160
|
+
"ikas-config-snippet.json": "{\n \"id\": \"order-detail\",\n \"name\": \"Order Detail\",\n \"type\": \"section\",\n \"props\": [\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
165
161
|
}
|
|
166
162
|
},
|
|
167
|
-
"product-
|
|
168
|
-
"title": "Product
|
|
169
|
-
"description": "
|
|
163
|
+
"product-detail": {
|
|
164
|
+
"title": "Product Detail Section",
|
|
165
|
+
"description": "Complete product detail section with breadcrumb, image gallery (IkasThemeSlider pattern), variant selection, pricing with discount amount, add-to-cart with result handling, favorites, brand link, product attributes, and bundle products. Based on production dynavit.json patterns.",
|
|
170
166
|
"files": {
|
|
171
|
-
"index.tsx": "import { useState } from \"preact/hooks\";\nimport {\n getProductCustomerReviews,\n getIkasProductCustomerReviewForm,\n setCustomerReviewFormTitle,\n setCustomerReviewFormStar,\n setCustomerReviewFormComment,\n submitCustomerReviewForm,\n isCustomerReviewLoginRequired,\n getIkasCustomerReviewFormattedDate,\n customerStore,\n hasCustomer,\n Router,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\n// NOTE: Do NOT wrap this root export with observer(). The ikas runtime handles\n// root reactivity via autorun(). Only use observer() on extracted sub-components.\nexport default function ProductReviewsSection({\n product,\n title = \"Customer Reviews\",\n}: Props) {\n const [showForm, setShowForm] = useState(false);\n\n if (!product) return null;\n\n const reviews = getProductCustomerReviews(product) ?? [];\n const reviewForm = getIkasProductCustomerReviewForm(product);\n const loginRequired = isCustomerReviewLoginRequired() as unknown as boolean;\n const isLoggedIn = hasCustomer(customerStore) as unknown as boolean;\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n const success = await submitCustomerReviewForm(reviewForm);\n if (success) setShowForm(false);\n };\n\n return (\n <section className=\"reviews-section\">\n <div className=\"reviews-inner\">\n <div className=\"reviews-header\">\n <h2 className=\"reviews-title\">{title} ({reviews.length})</h2>\n {!showForm && (\n <button className=\"reviews-write-btn\" onClick={() => {\n if (loginRequired && !isLoggedIn) Router.navigateToPage(\"LOGIN\");\n else setShowForm(true);\n }}>Write a Review</button>\n )}\n </div>\n {showForm && (\n <form className=\"review-form\" onSubmit={handleSubmit}>\n <div className=\"review-form-stars\">\n <span className=\"review-form-label\">Rating</span>\n <div className=\"star-input\">\n {[1, 2, 3, 4, 5].map((star) => (\n <button key={star} type=\"button\" className={star <= reviewForm.star.value ? \"star-filled\" : \"star-empty\"} onClick={() => setCustomerReviewFormStar(reviewForm, star)}>\\u2605</button>\n ))}\n </div>\n </div>\n <div className=\"review-form-field\">\n <label className=\"review-form-label\">{reviewForm.title.label}</label>\n <input className=\"review-form-input\" type=\"text\" placeholder={reviewForm.title.placeholder} value={reviewForm.title.value} onInput={(e) => setCustomerReviewFormTitle(reviewForm, (e.target as HTMLInputElement).value)} />\n </div>\n <div className=\"review-form-field\">\n <label className=\"review-form-label\">{reviewForm.comment.label}</label>\n <textarea className=\"review-form-textarea\" placeholder={reviewForm.comment.placeholder} value={reviewForm.comment.value} rows={4} onInput={(e) => setCustomerReviewFormComment(reviewForm, (e.target as HTMLTextAreaElement).value)} />\n </div>\n <div className=\"review-form-actions\">\n <button type=\"submit\" className=\"review-form-submit\" disabled={reviewForm.isSubmitting}>{reviewForm.isSubmitting ? \"Submitting...\" : \"Submit Review\"}</button>\n <button type=\"button\" className=\"review-form-cancel\" onClick={() => setShowForm(false)}>Cancel</button>\n </div>\n </form>\n )}\n {reviews.length === 0 && !showForm && <p className=\"reviews-empty\">No reviews yet. Be the first to review!</p>}\n <div className=\"review-list\">\n {reviews.map((review) => (\n <div key={review.id} className=\"review-card\">\n <div className=\"review-card-header\">\n <div className=\"reviews-stars\">\n {[1, 2, 3, 4, 5].map((s) => <span key={s} className={s <= review.star ? \"star-filled\" : \"star-empty\"}>\\u2605</span>)}\n </div>\n <span className=\"review-card-date\">{getIkasCustomerReviewFormattedDate(review)}</span>\n </div>\n <h4 className=\"review-card-title\">{review.title}</h4>\n <p className=\"review-card-comment\">{review.comment}</p>\n <span className=\"review-card-author\">{review.customerName}</span>\n </div>\n ))}\n </div>\n </div>\n </section>\n );\n}\n",
|
|
172
|
-
"types.ts": "import { IkasProduct } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n product: IkasProduct;\n
|
|
173
|
-
"styles.css": ".
|
|
174
|
-
"ikas-config-snippet.json": "{\n \"id\": \"product-
|
|
167
|
+
"index.tsx": "import { useState, useEffect } from \"preact/hooks\";\nimport {\n getSelectedProductVariant,\n getDisplayedProductVariantTypes,\n selectVariantValue,\n hasProductVariantStock,\n hasProductStock,\n getProductVariantFormattedFinalPrice,\n getProductVariantFormattedSellPrice,\n hasProductVariantDiscount,\n getProductVariantDiscountPercentage,\n getProductVariantFormattedDiscountAmount,\n isAddToCartEnabled,\n addItemToCart,\n isFavoriteIkasProduct,\n addIkasProductToFavorites,\n removeIkasProductFromFavorites,\n getProductVariantMainImage,\n getDefaultSrc,\n getThumbnailSrc,\n getSrc,\n createMediaSrcset,\n getProductCategoryPath,\n getIkasCategoryPathItemHref,\n getIkasBrandHref,\n getAttributeListValues,\n hasBundleSettings,\n initBundleProducts,\n getDisplayedProductGroups,\n isNotEmpty,\n IkasImage,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function ProductDetail({\n product,\n showFavoriteButton = true,\n addToCartButtonText = \"Add to Cart\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n const [selectedImageIndex, setSelectedImageIndex] = useState(0);\n const [isAddingToCart, setIsAddingToCart] = useState(false);\n const [quantity, setQuantity] = useState(1);\n\n useEffect(() => {\n if (product) {\n const variant = getSelectedProductVariant(product);\n if (variant && hasBundleSettings(variant)) {\n initBundleProducts(product);\n }\n }\n }, [product]);\n\n if (!product) {\n return null;\n }\n\n const selectedVariant = getSelectedProductVariant(product) as any;\n const variantTypes = getDisplayedProductVariantTypes(product);\n const inStock = hasProductStock(product) as unknown as boolean;\n const variantInStock = hasProductVariantStock(selectedVariant) as unknown as boolean;\n const canAddToCart = isAddToCartEnabled(product) as unknown as boolean;\n\n // Pricing\n const hasDiscount = hasProductVariantDiscount(selectedVariant) as unknown as boolean;\n const finalPrice = getProductVariantFormattedFinalPrice(selectedVariant) as unknown as string;\n const originalPrice = hasDiscount\n ? (getProductVariantFormattedSellPrice(selectedVariant) as unknown as string)\n : null;\n const discountPercentage = hasDiscount\n ? (getProductVariantDiscountPercentage(selectedVariant) as unknown as number)\n : null;\n const discountAmount = hasDiscount\n ? (getProductVariantFormattedDiscountAmount(selectedVariant) as unknown as string)\n : null;\n\n // Breadcrumb\n const categoryPath = getProductCategoryPath(product);\n\n // Images — in production, dynavit uses IkasThemeSlider for image carousel\n const mainProductImage = getProductVariantMainImage(selectedVariant);\n const mainImage = mainProductImage?.image;\n const variantImages = selectedVariant?.images;\n const images: IkasImage[] = variantImages?.length\n ? variantImages\n .map((pi: any) => pi.image)\n .filter((img: any): img is IkasImage => img != null)\n : mainImage\n ? [mainImage]\n : [];\n const currentImage = images[selectedImageIndex] ?? images[0];\n\n // Favorites\n const isFavorite = isFavoriteIkasProduct(product);\n\n // Attributes\n const attributes = product.attributeList\n ? getAttributeListValues(product.attributeList)\n : [];\n\n // Bundle / Offer products\n const hasBundle = selectedVariant ? (hasBundleSettings(selectedVariant) as unknown as boolean) : false;\n const productGroups = hasBundle ? getDisplayedProductGroups(product) : [];\n\n const handleAddToCart = async () => {\n if (!canAddToCart || isAddingToCart) return;\n setIsAddingToCart(true);\n try {\n const result = await addItemToCart(selectedVariant, product, quantity);\n if (result.success) {\n setQuantity(1);\n }\n } finally {\n setIsAddingToCart(false);\n }\n };\n\n const handleToggleFavorite = async () => {\n if (isFavorite) {\n await removeIkasProductFromFavorites(product);\n } else {\n await addIkasProductToFavorites(product);\n }\n };\n\n return (\n <section className=\"product-detail\" style={backgroundColor ? { backgroundColor } : undefined}>\n {/* Breadcrumb */}\n {isNotEmpty(categoryPath) && (\n <nav className=\"product-breadcrumb\">\n <a href=\"/\">Home</a>\n {categoryPath.map((pathItem: any, i: number) => (\n <span key={i}>\n <span className=\"breadcrumb-sep\">/</span>\n <a href={getIkasCategoryPathItemHref(pathItem)}>{pathItem.name}</a>\n </span>\n ))}\n <span className=\"breadcrumb-sep\">/</span>\n <span className=\"breadcrumb-current\">{product.name}</span>\n </nav>\n )}\n\n <div className=\"product-detail-inner\">\n {/* Image Gallery — production uses IkasThemeSlider for carousel */}\n <div className=\"product-gallery\">\n {currentImage && (\n <img\n className=\"product-main-image\"\n src={getDefaultSrc(currentImage)}\n srcSet={createMediaSrcset(currentImage)}\n sizes=\"(max-width: 768px) 100vw, 50vw\"\n alt={currentImage.altText || product.name}\n />\n )}\n {images.length > 1 && (\n <div className=\"product-thumbnails\">\n {images.map((img, i) => (\n <button\n key={img.id || i}\n className={`product-thumbnail-btn ${i === selectedImageIndex ? \"active\" : \"\"}`}\n onClick={() => setSelectedImageIndex(i)}\n >\n <img src={getThumbnailSrc(img)} alt={`${product.name} ${i + 1}`} />\n </button>\n ))}\n </div>\n )}\n </div>\n\n {/* Product Info */}\n <div className=\"product-info\">\n {/* Brand with link */}\n {product.brand && (\n <a className=\"product-brand\" href={getIkasBrandHref(product.brand)}>\n {product.brand.name}\n </a>\n )}\n <h1 className=\"product-name\">{product.name}</h1>\n\n {/* Average Rating */}\n {product.averageRating > 0 && (\n <div className=\"product-rating\">\n {\"★\".repeat(Math.round(product.averageRating))}\n {\"☆\".repeat(5 - Math.round(product.averageRating))}\n <span className=\"product-rating-value\">({product.averageRating.toFixed(1)})</span>\n </div>\n )}\n\n {/* Campaign badges */}\n {isNotEmpty(product.campaigns) && (\n <div className=\"product-campaigns\">\n {product.campaigns.map((campaign: any) => (\n <span key={campaign.id} className=\"product-campaign-badge\">\n {campaign.name}\n </span>\n ))}\n </div>\n )}\n\n {/* Pricing */}\n <div className=\"product-pricing\">\n <span className=\"product-final-price\">{finalPrice}</span>\n {originalPrice && <span className=\"product-original-price\">{originalPrice}</span>}\n {discountPercentage != null && (\n <span className=\"product-discount-badge\">-{discountPercentage}%</span>\n )}\n {discountAmount && (\n <span className=\"product-savings\">Save {discountAmount}</span>\n )}\n </div>\n\n {/* Variant Selection */}\n {variantTypes.length > 0 && (\n <div className=\"product-variants\">\n {variantTypes.map((vt) => (\n <div key={vt.variantType.id} className=\"variant-group\">\n <span className=\"variant-group-label\">{vt.variantType.name}</span>\n <div className=\"variant-options\">\n {vt.displayedVariantValues.map((dvv) => (\n <button\n key={dvv.variantValue.id}\n className={`variant-option-btn ${dvv.isSelected ? \"selected\" : \"\"}`}\n disabled={!dvv.hasStock}\n onClick={() => selectVariantValue(product, dvv.variantValue)}\n >\n {dvv.variantValue.name}\n </button>\n ))}\n </div>\n </div>\n ))}\n </div>\n )}\n\n {/* Stock notice */}\n {!inStock && <span className=\"out-of-stock-notice\">Out of Stock</span>}\n\n {/* Quantity + Add to Cart */}\n <div className=\"product-actions\">\n <div className=\"product-quantity\">\n <button onClick={() => setQuantity(Math.max(1, quantity - 1))}>-</button>\n <span>{quantity}</span>\n <button onClick={() => setQuantity(quantity + 1)}>+</button>\n </div>\n <button\n className=\"add-to-cart-btn\"\n disabled={!canAddToCart || isAddingToCart}\n onClick={handleAddToCart}\n >\n {isAddingToCart ? \"Adding...\" : !variantInStock ? \"Out of Stock\" : addToCartButtonText}\n </button>\n {showFavoriteButton && (\n <button\n className={`favorite-btn ${isFavorite ? \"is-favorite\" : \"\"}`}\n onClick={handleToggleFavorite}\n aria-label={isFavorite ? \"Remove from favorites\" : \"Add to favorites\"}\n >\n {isFavorite ? \"\\u2665\" : \"\\u2661\"}\n </button>\n )}\n </div>\n\n {/* Description */}\n {product.description && (\n <div className=\"product-description\">\n <h3>Description</h3>\n <div dangerouslySetInnerHTML={{ __html: product.description }} />\n </div>\n )}\n\n {/* Product Attributes (e.g. ingredients, specs) */}\n {isNotEmpty(attributes) && (\n <div className=\"product-attributes\">\n <h3>Details</h3>\n {attributes.map((attr: any, i: number) => (\n <div key={i} className=\"attribute-row\">\n <span className=\"attribute-name\">{attr.name}</span>\n <span className=\"attribute-value\">{attr.value}</span>\n </div>\n ))}\n </div>\n )}\n\n {/* Bundle / Offer Products */}\n {isNotEmpty(productGroups) && (\n <div className=\"product-bundles\">\n <h3>Frequently Bought Together</h3>\n {productGroups.map((group: any, i: number) => (\n <div key={i} className=\"bundle-group\">\n {group.title && <h4>{group.title}</h4>}\n <div className=\"bundle-items\">\n {group.products?.map((bp: any) => (\n <div key={bp.product?.id} className=\"bundle-item\">\n <span>{bp.product?.name}</span>\n </div>\n ))}\n </div>\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n </section>\n );\n}\n\n",
|
|
168
|
+
"types.ts": "import { IkasProduct } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n product: IkasProduct;\n showFavoriteButton?: boolean;\n addToCartButtonText?: string;\n backgroundColor?: string;\n}\n",
|
|
169
|
+
"styles.css": ".product-detail {\n width: 100%;\n padding: 40px 24px;\n}\n\n.product-detail-inner {\n max-width: 1200px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 48px;\n align-items: start;\n}\n\n/* Image Gallery */\n.product-gallery {\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.product-main-image {\n width: 100%;\n aspect-ratio: 1;\n object-fit: cover;\n border-radius: 8px;\n background-color: #f5f5f5;\n}\n\n.product-thumbnails {\n display: flex;\n gap: 8px;\n overflow-x: auto;\n}\n\n.product-thumbnail-btn {\n flex-shrink: 0;\n width: 72px;\n height: 72px;\n padding: 0;\n border: 2px solid transparent;\n border-radius: 6px;\n cursor: pointer;\n overflow: hidden;\n background: none;\n}\n\n.product-thumbnail-btn.active {\n border-color: #111;\n}\n\n.product-thumbnail-btn img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n/* Product Info */\n.product-info {\n display: flex;\n flex-direction: column;\n gap: 20px;\n}\n\n.product-brand {\n font-size: 13px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: #666;\n text-decoration: none;\n}\n\n.product-brand:hover {\n color: #111;\n}\n\n.product-name {\n font-size: 28px;\n font-weight: 700;\n line-height: 1.2;\n color: #111;\n margin: 0;\n}\n\n/* Pricing */\n.product-pricing {\n display: flex;\n align-items: baseline;\n gap: 12px;\n flex-wrap: wrap;\n}\n\n.product-final-price {\n font-size: 26px;\n font-weight: 700;\n color: #111;\n}\n\n.product-original-price {\n font-size: 18px;\n color: #999;\n text-decoration: line-through;\n}\n\n.product-discount-badge {\n font-size: 13px;\n font-weight: 600;\n color: #fff;\n background-color: #e53935;\n padding: 2px 8px;\n border-radius: 4px;\n}\n\n/* Variants */\n.product-variants {\n display: flex;\n flex-direction: column;\n gap: 16px;\n}\n\n.variant-group {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.variant-group-label {\n font-size: 14px;\n font-weight: 600;\n color: #333;\n}\n\n.variant-options {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.variant-option-btn {\n padding: 8px 16px;\n border: 1.5px solid #ddd;\n border-radius: 6px;\n background: #fff;\n font-size: 14px;\n cursor: pointer;\n transition: all 0.15s ease;\n color: #333;\n}\n\n.variant-option-btn:hover:not(:disabled) {\n border-color: #111;\n}\n\n.variant-option-btn.selected {\n border-color: #111;\n background-color: #111;\n color: #fff;\n}\n\n.variant-option-btn:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n text-decoration: line-through;\n}\n\n/* Actions */\n.product-actions {\n display: flex;\n gap: 12px;\n align-items: stretch;\n}\n\n.add-to-cart-btn {\n flex: 1;\n padding: 14px 24px;\n font-size: 16px;\n font-weight: 600;\n color: #fff;\n background-color: #111;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: background-color 0.15s ease;\n}\n\n.add-to-cart-btn:hover:not(:disabled) {\n background-color: #333;\n}\n\n.add-to-cart-btn:disabled {\n background-color: #ccc;\n cursor: not-allowed;\n}\n\n.favorite-btn {\n width: 48px;\n height: 48px;\n display: flex;\n align-items: center;\n justify-content: center;\n border: 1.5px solid #ddd;\n border-radius: 8px;\n background: #fff;\n cursor: pointer;\n font-size: 20px;\n transition: all 0.15s ease;\n flex-shrink: 0;\n}\n\n.favorite-btn:hover {\n border-color: #e53935;\n}\n\n.favorite-btn.is-favorite {\n color: #e53935;\n border-color: #e53935;\n}\n\n/* Description */\n.product-description {\n padding-top: 20px;\n border-top: 1px solid #eee;\n}\n\n.out-of-stock-notice {\n font-size: 14px;\n font-weight: 600;\n color: #e53935;\n}\n\n/* Breadcrumb */\n.product-breadcrumb {\n max-width: 1200px;\n margin: 0 auto 16px;\n padding: 0 24px;\n font-size: 13px;\n color: #666;\n}\n\n.product-breadcrumb a {\n color: #666;\n text-decoration: none;\n}\n\n.product-breadcrumb a:hover {\n color: #111;\n}\n\n.breadcrumb-sep {\n margin: 0 8px;\n color: #ccc;\n}\n\n.breadcrumb-current {\n color: #111;\n}\n\n/* Rating */\n.product-rating {\n color: #f5a623;\n font-size: 16px;\n}\n\n.product-rating-value {\n font-size: 14px;\n color: #666;\n margin-left: 8px;\n}\n\n/* Campaigns */\n.product-campaigns {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.product-campaign-badge {\n font-size: 12px;\n font-weight: 600;\n color: #fff;\n background: #1976d2;\n padding: 4px 10px;\n border-radius: 4px;\n}\n\n/* Savings */\n.product-savings {\n font-size: 14px;\n font-weight: 600;\n color: #2e7d32;\n}\n\n/* Quantity */\n.product-quantity {\n display: flex;\n align-items: center;\n gap: 8px;\n border: 1.5px solid #ddd;\n border-radius: 8px;\n overflow: hidden;\n}\n\n.product-quantity button {\n width: 40px;\n height: 48px;\n border: none;\n background: #fff;\n font-size: 18px;\n cursor: pointer;\n}\n\n.product-quantity span {\n width: 32px;\n text-align: center;\n font-size: 15px;\n font-weight: 600;\n}\n\n/* Attributes */\n.product-attributes {\n padding-top: 20px;\n border-top: 1px solid #eee;\n}\n\n.attribute-row {\n display: flex;\n justify-content: space-between;\n padding: 8px 0;\n font-size: 14px;\n border-bottom: 1px solid #f5f5f5;\n}\n\n.attribute-name {\n color: #666;\n font-weight: 500;\n}\n\n.attribute-value {\n color: #111;\n}\n\n/* Bundle Products */\n.product-bundles {\n padding-top: 20px;\n border-top: 1px solid #eee;\n}\n\n.bundle-group {\n margin-top: 12px;\n}\n\n.bundle-items {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.bundle-item {\n padding: 12px;\n border: 1px solid #eee;\n border-radius: 6px;\n font-size: 14px;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n .product-detail-inner {\n grid-template-columns: 1fr;\n gap: 32px;\n }\n\n .product-name {\n font-size: 22px;\n }\n\n .product-final-price {\n font-size: 22px;\n }\n\n .product-actions {\n flex-wrap: wrap;\n }\n}\n",
|
|
170
|
+
"ikas-config-snippet.json": "{\n \"id\": \"product-detail\",\n \"name\": \"Product Detail\",\n \"type\": \"section\",\n \"entry\": \"./src/components/ProductDetail/index.tsx\",\n \"styles\": \"./src/components/ProductDetail/styles.css\",\n \"props\": [\n {\n \"name\": \"product\",\n \"displayName\": \"Product\",\n \"type\": \"PRODUCT\",\n \"required\": true\n },\n {\n \"name\": \"showFavoriteButton\",\n \"displayName\": \"Show Favorite Button\",\n \"type\": \"BOOLEAN\",\n \"defaultValue\": true\n },\n {\n \"name\": \"addToCartButtonText\",\n \"displayName\": \"Add to Cart Button Text\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Add to Cart\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
175
171
|
}
|
|
176
172
|
},
|
|
177
|
-
"
|
|
178
|
-
"title": "
|
|
179
|
-
"description": "
|
|
173
|
+
"product-list": {
|
|
174
|
+
"title": "Product List Section",
|
|
175
|
+
"description": "Complete product list section with category breadcrumb (getCategoryPath + getIkasCategoryHref), filter sidebar with handleFilterValueClick and onFilterCategoryClick, sort via setSortType (not direct mutation), search via searchProductList, product grid, and pagination with setProductListVisiblePage. Data is automatically reactive in root components via autorun().",
|
|
180
176
|
"files": {
|
|
181
|
-
"index.tsx": "import { getDefaultSrc, Router } from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function HeroBannerSection({\n heading = \"Welcome to Our Store\",\n subtitle,\n buttonText = \"Shop Now\",\n buttonLink = \"/\",\n backgroundImage,\n backgroundColor = \"#111\",\n textColor = \"#fff\",\n}: Props) {\n const bgStyle: Record<string, string> = { backgroundColor, color: textColor };\n if (backgroundImage) {\n bgStyle.backgroundImage = `url(${getDefaultSrc(backgroundImage)})`;\n bgStyle.backgroundSize = \"cover\";\n bgStyle.backgroundPosition = \"center\";\n }\n\n return (\n <section className=\"hero-banner\" style={bgStyle}>\n <div className=\"hero-inner\">\n <h1 className=\"hero-heading\">{heading}</h1>\n {subtitle && <p className=\"hero-subtitle\">{subtitle}</p>}\n {buttonText && (\n <button\n className=\"hero-cta\"\n onClick={() => Router.navigate(buttonLink)}\n >\n {buttonText}\n </button>\n )}\n </div>\n </section>\n );\n}\n",
|
|
182
|
-
"types.ts": "import {
|
|
183
|
-
"styles.css": ".
|
|
184
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
177
|
+
"index.tsx": "import { useState } from \"preact/hooks\";\nimport {\n IkasProductList,\n IkasProductListSortType,\n getSelectedProductVariant,\n getProductVariantFormattedFinalPrice,\n getProductVariantMainImage,\n getSelectedProductVariantHref,\n getFilterDisplayedValues,\n handleFilterValueClick,\n handleNumberRangeOptionClick,\n getProductListFilterCategories,\n onFilterCategoryClick,\n isSwatchFilter,\n getIkasFilterThumbnailImage,\n getProductListSortOptions,\n setSortType,\n hasProductListNextPage,\n hasProductListPrevPage,\n getProductListNextPage,\n getProductListPrevPage,\n setProductListVisiblePage,\n searchProductList,\n getCategoryPath,\n getIkasCategoryHref,\n isEmpty,\n getDefaultSrc,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function ProductListSection({\n productList,\n title = \"Products\",\n showFilters = true,\n backgroundColor = \"#ffffff\",\n}: Props) {\n const [searchKeyword, setSearchKeyword] = useState(\"\");\n\n if (!productList) return null;\n\n const products = productList.data ?? [];\n const filterCategories = getProductListFilterCategories(productList);\n const sortOptions = getProductListSortOptions(productList);\n const hasNext = hasProductListNextPage(productList);\n const hasPrev = hasProductListPrevPage(productList);\n\n // Category breadcrumb path\n const category = productList.category;\n const categoryPath = category ? getCategoryPath(category) : [];\n\n const handleSort = (value: string) => {\n const selected = sortOptions.find((opt) => opt.value === value);\n if (selected) {\n setSortType(productList, selected.value as IkasProductListSortType);\n }\n };\n\n const handleSearch = (keyword: string) => {\n setSearchKeyword(keyword);\n searchProductList(productList, keyword);\n };\n\n return (\n <section className=\"product-list-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"product-list-inner\">\n {/* Category Breadcrumb */}\n {categoryPath.length > 0 && (\n <nav className=\"product-list-breadcrumb\">\n <a href=\"/\">Home</a>\n {categoryPath.map((cat: any, i: number) => (\n <span key={i}>\n <span className=\"breadcrumb-sep\"> / </span>\n <a href={getIkasCategoryHref(cat)}>{cat.name}</a>\n </span>\n ))}\n </nav>\n )}\n\n <div className=\"product-list-header\">\n <h1 className=\"product-list-title\">\n {category?.name || title}\n {category?.description && (\n <p className=\"product-list-description\">{category.description}</p>\n )}\n </h1>\n\n <div className=\"product-list-controls\">\n {/* Search */}\n <input\n className=\"product-list-search\"\n type=\"text\"\n placeholder=\"Search products...\"\n value={searchKeyword}\n onInput={(e) => handleSearch((e.target as HTMLInputElement).value)}\n />\n\n {/* Sort — use setSortType instead of direct mutation */}\n {sortOptions.length > 0 && (\n <select\n className=\"product-list-sort\"\n value={sortOptions.find((o) => o.isSelected)?.value ?? \"\"}\n onChange={(e) => handleSort((e.target as HTMLSelectElement).value)}\n >\n {sortOptions.map((opt) => (\n <option key={opt.value} value={opt.value}>{opt.label}</option>\n ))}\n </select>\n )}\n </div>\n </div>\n\n <div className=\"product-list-layout\">\n {/* Filter Sidebar — display-type-aware rendering */}\n {showFilters && (productList.filters ?? []).length > 0 && (\n <aside className=\"product-list-filters\">\n {(productList.filters ?? []).map((filter) => {\n const values = getFilterDisplayedValues(filter);\n if (values.length === 0 && !filter.numberRangeListOptions?.length) return null;\n\n return (\n <div key={filter.id} className=\"filter-group\">\n <h3 className=\"filter-group-title\">{filter.name}</h3>\n\n {/* SWATCH: color circles / thumbnail images */}\n {isSwatchFilter(filter) ? (\n <div className=\"filter-swatches\">\n {values.map((fv) => {\n const thumbnail = getIkasFilterThumbnailImage(fv);\n return (\n <button\n key={fv.name}\n className={`filter-swatch${fv.isSelected === true ? \" selected\" : \"\"}`}\n onClick={() => handleFilterValueClick(productList, filter, fv)}\n title={fv.name}\n >\n {thumbnail ? (\n <img src={getDefaultSrc(thumbnail)} alt={fv.name} />\n ) : (\n <span\n className=\"filter-swatch-color\"\n style={{ backgroundColor: fv.colorCode ?? \"#ccc\" }}\n />\n )}\n </button>\n );\n })}\n </div>\n ) : (\n /* BOX / LIST: checkboxes (default) */\n <div className=\"filter-values\">\n {values.map((fv) => (\n <label key={fv.name} className=\"filter-value\">\n <input\n type=\"checkbox\"\n checked={fv.isSelected === true}\n onChange={() =>\n handleFilterValueClick(productList, filter, fv)\n }\n />\n <span>{fv.name}</span>\n {fv.count != null && (\n <span className=\"filter-count\">({fv.count})</span>\n )}\n </label>\n ))}\n </div>\n )}\n\n {/* NUMBER_RANGE_LIST: predefined range buttons */}\n {filter.numberRangeListOptions?.map((option) => (\n <button\n key={`${option.from}-${option.to}`}\n className={`filter-range-btn${option.isSelected ? \" selected\" : \"\"}`}\n onClick={() => handleNumberRangeOptionClick(productList, filter, option)}\n >\n {option.from} - {option.to ?? \"+\"}\n </button>\n ))}\n </div>\n );\n })}\n\n {/* Category-level filtering */}\n {filterCategories.length > 0 && (\n <div className=\"filter-group\">\n <h3 className=\"filter-group-title\">Categories</h3>\n {filterCategories.map((cat) => (\n <button\n key={cat.name}\n className=\"filter-category-btn\"\n onClick={() => onFilterCategoryClick(productList, cat, true)}\n >\n {cat.name}\n </button>\n ))}\n </div>\n )}\n </aside>\n )}\n\n {/* Product Grid — IkasThemeInfiniteScroller pattern for infinite scroll */}\n <div className=\"product-grid\">\n {isEmpty(products) && (\n <p className=\"product-grid-empty\">No products found.</p>\n )}\n {products.map((product) => {\n const variant = getSelectedProductVariant(product);\n const productImage = getProductVariantMainImage(variant);\n const image = productImage?.image ?? null;\n const price = getProductVariantFormattedFinalPrice(variant) as unknown as string;\n const href = getSelectedProductVariantHref(product);\n\n return (\n <a key={product.id} href={href} className=\"product-card\">\n <div className=\"product-card-image-wrap\">\n {image && (\n <img\n src={getDefaultSrc(image)}\n alt={product.name}\n className=\"product-card-image\"\n />\n )}\n </div>\n <div className=\"product-card-info\">\n <h3 className=\"product-card-name\">{product.name}</h3>\n <span className=\"product-card-price\">{price}</span>\n </div>\n </a>\n );\n })}\n </div>\n </div>\n\n {/* Pagination — includes setProductListVisiblePage for page jumping */}\n {(hasPrev || hasNext) && (\n <div className=\"product-list-pagination\">\n <button\n className=\"pagination-btn\"\n disabled={!hasPrev}\n onClick={() => getProductListPrevPage(productList)}\n >\n Previous\n </button>\n <span className=\"pagination-info\">\n Page {productList.page}\n </span>\n <button\n className=\"pagination-btn\"\n disabled={!hasNext}\n onClick={() => getProductListNextPage(productList)}\n >\n Next\n </button>\n </div>\n )}\n </div>\n </section>\n );\n}\n\n",
|
|
178
|
+
"types.ts": "import { IkasProductList } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n productList: IkasProductList;\n title?: string;\n showFilters?: boolean;\n backgroundColor?: string;\n}\n",
|
|
179
|
+
"styles.css": ".product-list-section {\n width: 100%;\n padding: 40px 24px;\n}\n\n.product-list-inner {\n max-width: 1200px;\n margin: 0 auto;\n}\n\n.product-list-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 24px;\n}\n\n.product-list-title {\n font-size: 24px;\n font-weight: 700;\n color: #111;\n margin: 0;\n}\n\n.product-list-sort {\n padding: 8px 12px;\n font-size: 14px;\n border: 1px solid #ddd;\n border-radius: 6px;\n background: #fff;\n cursor: pointer;\n}\n\n.product-list-layout {\n display: flex;\n gap: 32px;\n}\n\n/* Filters */\n.product-list-filters {\n width: 240px;\n flex-shrink: 0;\n}\n\n.filter-group {\n margin-bottom: 24px;\n}\n\n.filter-group-title {\n font-size: 14px;\n font-weight: 600;\n color: #111;\n margin: 0 0 12px 0;\n}\n\n.filter-values {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.filter-value {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 14px;\n color: #555;\n cursor: pointer;\n}\n\n.filter-count {\n color: #999;\n font-size: 12px;\n}\n\n/* Product Grid */\n.product-grid {\n flex: 1;\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 24px;\n}\n\n.product-grid-empty {\n grid-column: 1 / -1;\n text-align: center;\n color: #666;\n padding: 48px 0;\n}\n\n.product-card {\n text-decoration: none;\n color: inherit;\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.product-card-image-wrap {\n aspect-ratio: 1;\n overflow: hidden;\n border-radius: 8px;\n background: #f5f5f5;\n}\n\n.product-card-image {\n width: 100%;\n height: 100%;\n object-fit: cover;\n transition: transform 0.2s ease;\n}\n\n.product-card:hover .product-card-image {\n transform: scale(1.03);\n}\n\n.product-card-info {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.product-card-name {\n font-size: 14px;\n font-weight: 500;\n color: #111;\n margin: 0;\n}\n\n.product-card-price {\n font-size: 15px;\n font-weight: 600;\n color: #111;\n}\n\n/* Pagination */\n.product-list-pagination {\n display: flex;\n justify-content: center;\n gap: 12px;\n margin-top: 40px;\n}\n\n.pagination-btn {\n padding: 10px 24px;\n font-size: 14px;\n font-weight: 500;\n background: #fff;\n border: 1px solid #ddd;\n border-radius: 6px;\n cursor: pointer;\n}\n\n.pagination-btn:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n\n.pagination-btn:hover:not(:disabled) {\n border-color: #111;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n .product-list-layout {\n flex-direction: column;\n }\n\n .product-list-filters {\n width: 100%;\n }\n\n .product-grid {\n grid-template-columns: repeat(2, 1fr);\n gap: 16px;\n }\n}\n",
|
|
180
|
+
"ikas-config-snippet.json": "{\n \"id\": \"product-list\",\n \"name\": \"Product List\",\n \"type\": \"section\",\n \"entry\": \"./src/components/ProductList/index.tsx\",\n \"styles\": \"./src/components/ProductList/styles.css\",\n \"props\": [\n {\n \"name\": \"productList\",\n \"displayName\": \"Product List\",\n \"type\": \"PRODUCT_LIST\",\n \"required\": true\n },\n {\n \"name\": \"title\",\n \"displayName\": \"Section Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Products\"\n },\n {\n \"name\": \"showFilters\",\n \"displayName\": \"Show Filters\",\n \"type\": \"BOOLEAN\",\n \"defaultValue\": true\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
185
181
|
}
|
|
186
182
|
},
|
|
187
|
-
"
|
|
188
|
-
"title": "
|
|
189
|
-
"description": "
|
|
183
|
+
"product-reviews": {
|
|
184
|
+
"title": "Product Reviews Section",
|
|
185
|
+
"description": "Complete product reviews section with review list display, star ratings, review submission form with login-required check. getProductCustomerReviews supports limit, page, and hasImage params. Review data is automatically reactive in root components via autorun().",
|
|
190
186
|
"files": {
|
|
191
|
-
"index.tsx": "import {
|
|
192
|
-
"types.ts": "
|
|
193
|
-
"styles.css": ".
|
|
194
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
187
|
+
"index.tsx": "import { useState } from \"preact/hooks\";\nimport {\n IkasProduct,\n getProductCustomerReviews,\n getIkasProductCustomerReviewForm,\n setCustomerReviewFormTitle,\n setCustomerReviewFormStar,\n setCustomerReviewFormComment,\n submitCustomerReviewForm,\n isCustomerReviewLoginRequired,\n getIkasCustomerReviewFormattedDate,\n customerStore,\n hasCustomer,\n Router,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function ProductReviewsSection({\n product,\n title = \"Customer Reviews\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n const [showForm, setShowForm] = useState(false);\n\n if (!product) return null;\n\n const reviews = getProductCustomerReviews(product) ?? [];\n const reviewForm = getIkasProductCustomerReviewForm(product);\n const loginRequired = isCustomerReviewLoginRequired() as unknown as boolean;\n const isLoggedIn = hasCustomer(customerStore) as unknown as boolean;\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n const success = await submitCustomerReviewForm(reviewForm);\n if (success) {\n setShowForm(false);\n }\n };\n\n const StarDisplay = ({ rating }: { rating: number }) => (\n <div className=\"reviews-stars\">\n {[1, 2, 3, 4, 5].map((star) => (\n <span key={star} className={star <= rating ? \"star-filled\" : \"star-empty\"}>\n ★\n </span>\n ))}\n </div>\n );\n\n return (\n <section className=\"reviews-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"reviews-inner\">\n <div className=\"reviews-header\">\n <h2 className=\"reviews-title\">\n {title} ({reviews.length})\n </h2>\n {!showForm && (\n <button\n className=\"reviews-write-btn\"\n onClick={() => {\n if (loginRequired && !isLoggedIn) {\n Router.navigateToPage(\"LOGIN\");\n } else {\n setShowForm(true);\n }\n }}\n >\n Write a Review\n </button>\n )}\n </div>\n\n {/* Review Form */}\n {showForm && (\n <form className=\"review-form\" onSubmit={handleSubmit}>\n {reviewForm.isFailure && reviewForm.responseMessage && (\n <div className=\"review-form-error\">{reviewForm.responseMessage}</div>\n )}\n\n <div className=\"review-form-stars\">\n <span className=\"review-form-label\">Rating</span>\n <div className=\"star-input\">\n {[1, 2, 3, 4, 5].map((star) => (\n <button\n key={star}\n type=\"button\"\n className={star <= reviewForm.star.value ? \"star-filled\" : \"star-empty\"}\n onClick={() => setCustomerReviewFormStar(reviewForm, star)}\n >\n ★\n </button>\n ))}\n </div>\n </div>\n\n <div className=\"review-form-field\">\n <label className=\"review-form-label\">{reviewForm.title.label}</label>\n <input\n className=\"review-form-input\"\n type=\"text\"\n placeholder={reviewForm.title.placeholder}\n value={reviewForm.title.value}\n onInput={(e) =>\n setCustomerReviewFormTitle(reviewForm, (e.target as HTMLInputElement).value)\n }\n />\n {reviewForm.title.hasError && reviewForm.title.message && (\n <span className=\"review-form-error-text\">{reviewForm.title.message}</span>\n )}\n </div>\n\n <div className=\"review-form-field\">\n <label className=\"review-form-label\">{reviewForm.comment.label}</label>\n <textarea\n className=\"review-form-textarea\"\n placeholder={reviewForm.comment.placeholder}\n value={reviewForm.comment.value}\n rows={4}\n onInput={(e) =>\n setCustomerReviewFormComment(\n reviewForm,\n (e.target as HTMLTextAreaElement).value\n )\n }\n />\n {reviewForm.comment.hasError && reviewForm.comment.message && (\n <span className=\"review-form-error-text\">{reviewForm.comment.message}</span>\n )}\n </div>\n\n <div className=\"review-form-actions\">\n <button\n type=\"submit\"\n className=\"review-form-submit\"\n disabled={reviewForm.isSubmitting}\n >\n {reviewForm.isSubmitting ? \"Submitting...\" : \"Submit Review\"}\n </button>\n <button\n type=\"button\"\n className=\"review-form-cancel\"\n onClick={() => setShowForm(false)}\n >\n Cancel\n </button>\n </div>\n </form>\n )}\n\n {/* Review List */}\n {reviews.length === 0 && !showForm && (\n <p className=\"reviews-empty\">No reviews yet. Be the first to review!</p>\n )}\n\n <div className=\"review-list\">\n {reviews.map((review) => (\n <div key={review.id} className=\"review-card\">\n <div className=\"review-card-header\">\n <StarDisplay rating={review.star} />\n <span className=\"review-card-date\">\n {getIkasCustomerReviewFormattedDate(review)}\n </span>\n </div>\n <h4 className=\"review-card-title\">{review.title}</h4>\n <p className=\"review-card-comment\">{review.comment}</p>\n <span className=\"review-card-author\">{review.customerName}</span>\n </div>\n ))}\n </div>\n </div>\n </section>\n );\n}\n\n",
|
|
188
|
+
"types.ts": "import { IkasProduct } from \"@ikas/bp-storefront\";\n\nexport interface Props {\n product: IkasProduct;\n title?: string;\n backgroundColor?: string;\n}\n",
|
|
189
|
+
"styles.css": ".reviews-section {\n width: 100%;\n padding: 48px 24px;\n border-top: 1px solid #eee;\n}\n\n.reviews-inner {\n max-width: 800px;\n margin: 0 auto;\n}\n\n.reviews-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 24px;\n}\n\n.reviews-title {\n font-size: 22px;\n font-weight: 700;\n color: #111;\n margin: 0;\n}\n\n.reviews-write-btn {\n padding: 10px 20px;\n font-size: 14px;\n font-weight: 600;\n color: #fff;\n background: #111;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n}\n\n.reviews-write-btn:hover {\n background: #333;\n}\n\n.reviews-empty {\n text-align: center;\n color: #666;\n padding: 32px 0;\n font-size: 15px;\n}\n\n/* Stars */\n.reviews-stars,\n.star-input {\n display: flex;\n gap: 2px;\n}\n\n.star-filled {\n color: #f59e0b;\n font-size: 16px;\n background: none;\n border: none;\n cursor: pointer;\n padding: 0;\n}\n\n.star-empty {\n color: #ddd;\n font-size: 16px;\n background: none;\n border: none;\n cursor: pointer;\n padding: 0;\n}\n\n.star-input .star-filled,\n.star-input .star-empty {\n font-size: 24px;\n}\n\n/* Review Form */\n.review-form {\n background: #f9fafb;\n border-radius: 12px;\n padding: 24px;\n margin-bottom: 32px;\n display: flex;\n flex-direction: column;\n gap: 16px;\n}\n\n.review-form-error {\n padding: 10px 14px;\n font-size: 13px;\n color: #b71c1c;\n background: #ffebee;\n border-radius: 6px;\n}\n\n.review-form-stars {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.review-form-field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.review-form-label {\n font-size: 14px;\n font-weight: 600;\n color: #333;\n}\n\n.review-form-input,\n.review-form-textarea {\n padding: 10px 12px;\n font-size: 14px;\n border: 1.5px solid #ddd;\n border-radius: 6px;\n outline: none;\n font-family: inherit;\n}\n\n.review-form-input:focus,\n.review-form-textarea:focus {\n border-color: #111;\n}\n\n.review-form-textarea {\n resize: vertical;\n min-height: 100px;\n}\n\n.review-form-error-text {\n font-size: 12px;\n color: #e53935;\n}\n\n.review-form-actions {\n display: flex;\n gap: 12px;\n}\n\n.review-form-submit {\n padding: 10px 20px;\n font-size: 14px;\n font-weight: 600;\n color: #fff;\n background: #111;\n border: none;\n border-radius: 6px;\n cursor: pointer;\n}\n\n.review-form-submit:disabled {\n background: #ccc;\n cursor: not-allowed;\n}\n\n.review-form-cancel {\n padding: 10px 20px;\n font-size: 14px;\n color: #666;\n background: none;\n border: 1px solid #ddd;\n border-radius: 6px;\n cursor: pointer;\n}\n\n/* Review List */\n.review-list {\n display: flex;\n flex-direction: column;\n gap: 20px;\n}\n\n.review-card {\n padding: 20px 0;\n border-bottom: 1px solid #f0f0f0;\n}\n\n.review-card:last-child {\n border-bottom: none;\n}\n\n.review-card-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 8px;\n}\n\n.review-card-date {\n font-size: 12px;\n color: #999;\n}\n\n.review-card-title {\n font-size: 15px;\n font-weight: 600;\n color: #111;\n margin: 0 0 6px 0;\n}\n\n.review-card-comment {\n font-size: 14px;\n color: #555;\n line-height: 1.6;\n margin: 0 0 8px 0;\n}\n\n.review-card-author {\n font-size: 13px;\n color: #888;\n font-weight: 500;\n}\n",
|
|
190
|
+
"ikas-config-snippet.json": "{\n \"id\": \"product-reviews\",\n \"name\": \"Product Reviews\",\n \"type\": \"section\",\n \"entry\": \"./src/components/ProductReviews/index.tsx\",\n \"styles\": \"./src/components/ProductReviews/styles.css\",\n \"props\": [\n {\n \"name\": \"product\",\n \"displayName\": \"Product\",\n \"type\": \"PRODUCT\",\n \"required\": true\n },\n {\n \"name\": \"title\",\n \"displayName\": \"Section Title\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"Customer Reviews\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
195
191
|
}
|
|
196
192
|
},
|
|
197
|
-
"
|
|
198
|
-
"title": "
|
|
199
|
-
"description": "
|
|
193
|
+
"register-page": {
|
|
194
|
+
"title": "Register Section",
|
|
195
|
+
"description": "Complete registration section with first name, last name, email, password, marketing consent (setRegisterFormIsMarketingAccepted), membership agreement (setRegisterFormIsMembershipAgreementAccepted), and social login (socialLogin + SocialLoginProvider). Uses navigateToPage for navigation.",
|
|
200
196
|
"files": {
|
|
201
|
-
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n
|
|
202
|
-
"types.ts": "export interface Props {\n
|
|
203
|
-
"styles.css": ".
|
|
204
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
197
|
+
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getRegisterForm,\n initRegisterForm,\n setRegisterFormEmail,\n setRegisterFormFirstName,\n setRegisterFormLastName,\n setRegisterFormPassword,\n submitRegisterForm,\n setRegisterFormIsMarketingAccepted,\n setRegisterFormIsMembershipAgreementAccepted,\n socialLogin,\n navigateToPage,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function RegisterSection({\n redirectAfterRegister = \"/account\",\n backgroundColor = \"#ffffff\",\n}: Props) {\n const registerForm = getRegisterForm(customerStore);\n\n useEffect(() => {\n initRegisterForm(registerForm);\n }, []);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n const success = await submitRegisterForm(registerForm);\n if (success) {\n navigateToPage(\"ACCOUNT\");\n }\n };\n\n const handleSocialRegister = async (provider: \"GOOGLE\" | \"FACEBOOK\" | \"APPLE\") => {\n await socialLogin(customerStore, provider as any);\n };\n\n return (\n <section className=\"register-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"register-inner\">\n <h1 className=\"register-title\">Create Account</h1>\n\n {registerForm.isFailure && registerForm.responseMessage && (\n <div className=\"register-error-banner\">{registerForm.responseMessage}</div>\n )}\n\n {/* Social Login Buttons */}\n <div className=\"register-social\">\n <button className=\"register-social-btn\" onClick={() => handleSocialRegister(\"GOOGLE\")}>\n Continue with Google\n </button>\n <button className=\"register-social-btn\" onClick={() => handleSocialRegister(\"FACEBOOK\")}>\n Continue with Facebook\n </button>\n </div>\n\n <div className=\"register-divider\"><span>or</span></div>\n\n <form className=\"register-form\" onSubmit={handleSubmit}>\n <div className=\"register-row\">\n <div className=\"register-field\">\n <label className=\"register-label\">{registerForm.firstName.label}</label>\n <input\n className={`register-input ${registerForm.firstName.hasError ? \"has-error\" : \"\"}`}\n type=\"text\"\n placeholder={registerForm.firstName.placeholder}\n value={registerForm.firstName.value}\n onInput={(e) =>\n setRegisterFormFirstName(registerForm, (e.target as HTMLInputElement).value)\n }\n />\n {registerForm.firstName.hasError && registerForm.firstName.message && (\n <span className=\"register-field-error\">{registerForm.firstName.message}</span>\n )}\n </div>\n\n <div className=\"register-field\">\n <label className=\"register-label\">{registerForm.lastName.label}</label>\n <input\n className={`register-input ${registerForm.lastName.hasError ? \"has-error\" : \"\"}`}\n type=\"text\"\n placeholder={registerForm.lastName.placeholder}\n value={registerForm.lastName.value}\n onInput={(e) =>\n setRegisterFormLastName(registerForm, (e.target as HTMLInputElement).value)\n }\n />\n {registerForm.lastName.hasError && registerForm.lastName.message && (\n <span className=\"register-field-error\">{registerForm.lastName.message}</span>\n )}\n </div>\n </div>\n\n <div className=\"register-field\">\n <label className=\"register-label\">{registerForm.email.label}</label>\n <input\n className={`register-input ${registerForm.email.hasError ? \"has-error\" : \"\"}`}\n type=\"email\"\n placeholder={registerForm.email.placeholder}\n value={registerForm.email.value}\n onInput={(e) =>\n setRegisterFormEmail(registerForm, (e.target as HTMLInputElement).value)\n }\n />\n {registerForm.email.hasError && registerForm.email.message && (\n <span className=\"register-field-error\">{registerForm.email.message}</span>\n )}\n </div>\n\n <div className=\"register-field\">\n <label className=\"register-label\">{registerForm.password.label}</label>\n <input\n className={`register-input ${registerForm.password.hasError ? \"has-error\" : \"\"}`}\n type=\"password\"\n placeholder={registerForm.password.placeholder}\n value={registerForm.password.value}\n onInput={(e) =>\n setRegisterFormPassword(registerForm, (e.target as HTMLInputElement).value)\n }\n />\n {registerForm.password.hasError && registerForm.password.message && (\n <span className=\"register-field-error\">{registerForm.password.message}</span>\n )}\n </div>\n\n {/* Marketing Consent — IkasFormItemBoolean */}\n <label className=\"register-checkbox\">\n <input\n type=\"checkbox\"\n checked={registerForm.isMarketingAccepted?.value ?? false}\n onChange={(e) =>\n setRegisterFormIsMarketingAccepted(registerForm, (e.target as HTMLInputElement).checked)\n }\n />\n <span>I want to receive marketing emails and promotions</span>\n </label>\n\n {/* Membership Agreement — IkasFormItemBoolean */}\n <label className=\"register-checkbox\">\n <input\n type=\"checkbox\"\n checked={registerForm.isMembershipAgreementAccepted?.value ?? false}\n onChange={(e) =>\n setRegisterFormIsMembershipAgreementAccepted(registerForm, (e.target as HTMLInputElement).checked)\n }\n />\n <span>I accept the membership agreement</span>\n </label>\n\n <button\n className=\"register-submit-btn\"\n type=\"submit\"\n disabled={registerForm.isSubmitting}\n >\n {registerForm.isSubmitting ? \"Creating account...\" : \"Create Account\"}\n </button>\n </form>\n\n <p className=\"register-login-link\">\n Already have an account?{\" \"}\n <a\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n navigateToPage(\"LOGIN\");\n }}\n >\n Sign in\n </a>\n </p>\n </div>\n </section>\n );\n}\n\n",
|
|
198
|
+
"types.ts": "export interface Props {\n redirectAfterRegister?: string;\n backgroundColor?: string;\n}\n",
|
|
199
|
+
"styles.css": ".register-section {\n width: 100%;\n padding: 64px 24px;\n}\n\n.register-inner {\n max-width: 480px;\n margin: 0 auto;\n}\n\n.register-title {\n font-size: 28px;\n font-weight: 700;\n color: #111;\n margin: 0 0 24px 0;\n text-align: center;\n}\n\n.register-error-banner {\n padding: 12px 16px;\n font-size: 14px;\n color: #b71c1c;\n background-color: #ffebee;\n border-radius: 8px;\n margin-bottom: 20px;\n}\n\n.register-form {\n display: flex;\n flex-direction: column;\n gap: 16px;\n}\n\n.register-row {\n display: flex;\n gap: 12px;\n}\n\n.register-row .register-field {\n flex: 1;\n}\n\n.register-field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.register-label {\n font-size: 14px;\n font-weight: 600;\n color: #333;\n}\n\n.register-input {\n padding: 12px 14px;\n font-size: 15px;\n border: 1.5px solid #ddd;\n border-radius: 8px;\n outline: none;\n transition: border-color 0.15s ease;\n}\n\n.register-input:focus {\n border-color: #111;\n}\n\n.register-input.has-error {\n border-color: #e53935;\n}\n\n.register-field-error {\n font-size: 12px;\n color: #e53935;\n}\n\n.register-submit-btn {\n padding: 14px 24px;\n font-size: 16px;\n font-weight: 600;\n color: #fff;\n background-color: #111;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: background-color 0.15s ease;\n margin-top: 8px;\n}\n\n.register-submit-btn:hover:not(:disabled) {\n background-color: #333;\n}\n\n.register-submit-btn:disabled {\n background-color: #ccc;\n cursor: not-allowed;\n}\n\n.register-login-link {\n font-size: 14px;\n color: #666;\n text-align: center;\n margin-top: 24px;\n}\n\n.register-login-link a {\n color: #111;\n font-weight: 600;\n text-decoration: none;\n}\n\n.register-login-link a:hover {\n text-decoration: underline;\n}\n\n@media (max-width: 480px) {\n .register-row {\n flex-direction: column;\n }\n}\n",
|
|
200
|
+
"ikas-config-snippet.json": "{\n \"id\": \"register-page\",\n \"name\": \"Register Page\",\n \"type\": \"section\",\n \"entry\": \"./src/components/RegisterSection/index.tsx\",\n \"styles\": \"./src/components/RegisterSection/styles.css\",\n \"props\": [\n {\n \"name\": \"redirectAfterRegister\",\n \"displayName\": \"Redirect After Register\",\n \"type\": \"TEXT\",\n \"defaultValue\": \"/account\"\n },\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
205
201
|
}
|
|
206
202
|
},
|
|
207
|
-
"
|
|
208
|
-
"title": "
|
|
209
|
-
"description": "
|
|
203
|
+
"reset-password": {
|
|
204
|
+
"title": "Reset Password Section",
|
|
205
|
+
"description": "Complete reset password section with new password and confirm password fields. Uses getRecoverPasswordForm/initRecoverPasswordForm/setRecoverPasswordFormPassword/setRecoverPasswordFormPasswordAgain/submitRecoverPasswordForm pattern.",
|
|
210
206
|
"files": {
|
|
211
|
-
"index.tsx": "import { useEffect
|
|
212
|
-
"types.ts": "export interface Props {}\n",
|
|
213
|
-
"styles.css": ".
|
|
214
|
-
"ikas-config-snippet.json": "{\n \"id\": \"
|
|
207
|
+
"index.tsx": "import { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getRecoverPasswordForm,\n initRecoverPasswordForm,\n setRecoverPasswordFormPassword,\n setRecoverPasswordFormPasswordAgain,\n submitRecoverPasswordForm,\n Router,\n} from \"@ikas/bp-storefront\";\nimport { Props } from \"./types\";\n\nexport default function ResetPasswordSection({ backgroundColor = \"#ffffff\" }: Props) {\n const recoverForm = getRecoverPasswordForm(customerStore);\n\n useEffect(() => {\n initRecoverPasswordForm(recoverForm);\n }, []);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n const success = await submitRecoverPasswordForm(recoverForm);\n if (success) {\n Router.navigateToPage(\"LOGIN\");\n }\n };\n\n return (\n <section className=\"reset-section\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"reset-inner\">\n <h1 className=\"reset-title\">Set New Password</h1>\n <p className=\"reset-subtitle\">Enter your new password below.</p>\n\n {recoverForm.isSuccess && (\n <div className=\"reset-success-banner\">Password has been reset successfully!</div>\n )}\n {recoverForm.isFailure && recoverForm.responseMessage && (\n <div className=\"reset-error-banner\">{recoverForm.responseMessage}</div>\n )}\n\n {!recoverForm.isSuccess && (\n <form className=\"reset-form\" onSubmit={handleSubmit}>\n <div className=\"reset-field\">\n <label className=\"reset-label\">{recoverForm.password.label}</label>\n <input\n className={`reset-input ${recoverForm.password.hasError ? \"has-error\" : \"\"}`}\n type=\"password\"\n placeholder={recoverForm.password.placeholder}\n value={recoverForm.password.value}\n onInput={(e) =>\n setRecoverPasswordFormPassword(recoverForm, (e.target as HTMLInputElement).value)\n }\n />\n {recoverForm.password.hasError && recoverForm.password.message && (\n <span className=\"reset-field-error\">{recoverForm.password.message}</span>\n )}\n </div>\n\n <div className=\"reset-field\">\n <label className=\"reset-label\">{recoverForm.passwordAgain.label}</label>\n <input\n className={`reset-input ${recoverForm.passwordAgain.hasError ? \"has-error\" : \"\"}`}\n type=\"password\"\n placeholder={recoverForm.passwordAgain.placeholder}\n value={recoverForm.passwordAgain.value}\n onInput={(e) =>\n setRecoverPasswordFormPasswordAgain(recoverForm, (e.target as HTMLInputElement).value)\n }\n />\n {recoverForm.passwordAgain.hasError && recoverForm.passwordAgain.message && (\n <span className=\"reset-field-error\">{recoverForm.passwordAgain.message}</span>\n )}\n </div>\n\n <button className=\"reset-submit-btn\" type=\"submit\" disabled={recoverForm.isSubmitting}>\n {recoverForm.isSubmitting ? \"Resetting...\" : \"Reset Password\"}\n </button>\n </form>\n )}\n\n <p className=\"reset-back-link\">\n <a href=\"#\" onClick={(e) => { e.preventDefault(); Router.navigateToPage(\"LOGIN\"); }}>\n Back to Sign In\n </a>\n </p>\n </div>\n </section>\n );\n}\n\n",
|
|
208
|
+
"types.ts": "export interface Props {\n backgroundColor?: string;\n}\n",
|
|
209
|
+
"styles.css": ".reset-section { width: 100%; padding: 64px 24px; }\n.reset-inner { max-width: 400px; margin: 0 auto; }\n.reset-title { font-size: 28px; font-weight: 700; color: #111; margin: 0 0 8px 0; text-align: center; }\n.reset-subtitle { font-size: 15px; color: #666; text-align: center; margin: 0 0 24px 0; }\n.reset-success-banner { padding: 12px 16px; font-size: 14px; color: #1b5e20; background: #e8f5e9; border-radius: 8px; margin-bottom: 20px; }\n.reset-error-banner { padding: 12px 16px; font-size: 14px; color: #b71c1c; background: #ffebee; border-radius: 8px; margin-bottom: 20px; }\n.reset-form { display: flex; flex-direction: column; gap: 16px; }\n.reset-field { display: flex; flex-direction: column; gap: 6px; }\n.reset-label { font-size: 14px; font-weight: 600; color: #333; }\n.reset-input { padding: 12px 14px; font-size: 15px; border: 1.5px solid #ddd; border-radius: 8px; outline: none; }\n.reset-input:focus { border-color: #111; }\n.reset-input.has-error { border-color: #e53935; }\n.reset-field-error { font-size: 12px; color: #e53935; }\n.reset-submit-btn { padding: 14px 24px; font-size: 16px; font-weight: 600; color: #fff; background: #111; border: none; border-radius: 8px; cursor: pointer; margin-top: 8px; }\n.reset-submit-btn:disabled { background: #ccc; cursor: not-allowed; }\n.reset-back-link { font-size: 14px; color: #666; text-align: center; margin-top: 24px; }\n.reset-back-link a { color: #111; font-weight: 600; text-decoration: none; }\n",
|
|
210
|
+
"ikas-config-snippet.json": "{\n \"id\": \"reset-password\",\n \"name\": \"Reset Password\",\n \"type\": \"section\",\n \"props\": [\n {\n \"name\": \"backgroundColor\",\n \"displayName\": \"Background Color\",\n \"type\": \"COLOR\",\n \"defaultValue\": \"#ffffff\"\n }\n ]\n}\n"
|
|
215
211
|
}
|
|
216
212
|
}
|
|
217
213
|
}
|