@salifbiaye/create-boutique 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/cli/dist/commands.js +382 -0
- package/cli/dist/create.js +194 -0
- package/cli/dist/docker.js +19 -0
- package/cli/dist/generate.js +322 -0
- package/cli/dist/guide.js +72 -0
- package/cli/dist/index.js +156 -0
- package/cli/dist/paths.js +29 -0
- package/cli/dist/ports.js +54 -0
- package/cli/dist/project.js +119 -0
- package/cli/dist/themes.js +24 -0
- package/docs/fiche-client.en.html +137 -0
- package/docs/fiche-client.html +137 -0
- package/docs/guide-complet.en.html +259 -0
- package/docs/guide-complet.fr.html +259 -0
- package/package.json +51 -0
- package/templates/backend/.dockerignore +9 -0
- package/templates/backend/Dockerfile +30 -0
- package/templates/backend/README.md +78 -0
- package/templates/backend/TEMPLATE_VERSION +1 -0
- package/templates/backend/docker/entrypoint.sh +28 -0
- package/templates/backend/instrumentation.ts +24 -0
- package/templates/backend/medusa-config.ts +114 -0
- package/templates/backend/package-lock.json +16222 -0
- package/templates/backend/package.json +39 -0
- package/templates/backend/src/admin/README.md +33 -0
- package/templates/backend/src/admin/components/image-field.tsx +61 -0
- package/templates/backend/src/admin/i18n/README.md +58 -0
- package/templates/backend/src/admin/i18n/index.ts +8 -0
- package/templates/backend/src/admin/i18n/json/en.json +66 -0
- package/templates/backend/src/admin/i18n/json/fr.json +66 -0
- package/templates/backend/src/admin/lib/client.ts +9 -0
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +286 -0
- package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +113 -0
- package/templates/backend/src/admin/tsconfig.json +24 -0
- package/templates/backend/src/admin/vite-env.d.ts +1 -0
- package/templates/backend/src/admin/widgets/category-image.tsx +56 -0
- package/templates/backend/src/api/README.md +135 -0
- package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +30 -0
- package/templates/backend/src/api/admin/boutique-campaigns/route.ts +24 -0
- package/templates/backend/src/api/middlewares.ts +28 -0
- package/templates/backend/src/api/rate-limit.ts +30 -0
- package/templates/backend/src/api/route.ts +6 -0
- package/templates/backend/src/api/store/campaign/route.ts +25 -0
- package/templates/backend/src/api/store/order-lookup/route.ts +74 -0
- package/templates/backend/src/emails/en.json +64 -0
- package/templates/backend/src/emails/fr.json +64 -0
- package/templates/backend/src/jobs/README.md +38 -0
- package/templates/backend/src/lib/emails.ts +94 -0
- package/templates/backend/src/lib/notify.ts +148 -0
- package/templates/backend/src/lib/project.ts +146 -0
- package/templates/backend/src/links/README.md +26 -0
- package/templates/backend/src/links/campaign-sales-channel.ts +9 -0
- package/templates/backend/src/modules/README.md +117 -0
- package/templates/backend/src/modules/campaign/index.ts +9 -0
- package/templates/backend/src/modules/campaign/migrations/.snapshot-boutique-campaign.json +272 -0
- package/templates/backend/src/modules/campaign/migrations/Migration20260928141440.ts +16 -0
- package/templates/backend/src/modules/campaign/migrations/Migration20260928145243.ts +13 -0
- package/templates/backend/src/modules/campaign/models/campaign.ts +28 -0
- package/templates/backend/src/modules/campaign/service.ts +6 -0
- package/templates/backend/src/modules/campaign/validators.ts +94 -0
- package/templates/backend/src/modules/resend/index.ts +6 -0
- package/templates/backend/src/modules/resend/service.ts +63 -0
- package/templates/backend/src/scripts/README.md +63 -0
- package/templates/backend/src/scripts/campaign-export.ts +15 -0
- package/templates/backend/src/scripts/campaign-import.ts +38 -0
- package/templates/backend/src/scripts/price-report.ts +34 -0
- package/templates/backend/src/scripts/seed.ts +418 -0
- package/templates/backend/src/scripts/storage.ts +63 -0
- package/templates/backend/src/subscribers/README.md +61 -0
- package/templates/backend/src/subscribers/account-emails.ts +27 -0
- package/templates/backend/src/subscribers/fulfillment-emails.ts +42 -0
- package/templates/backend/src/subscribers/order-emails.ts +12 -0
- package/templates/backend/src/subscribers/return-refund-emails.ts +50 -0
- package/templates/backend/src/workflows/README.md +81 -0
- package/templates/backend/src/workflows/steps/upsert-campaign.ts +53 -0
- package/templates/backend/src/workflows/upsert-campaign.ts +30 -0
- package/templates/backend/tsconfig.json +36 -0
- package/templates/storefront/.dockerignore +7 -0
- package/templates/storefront/.github/ISSUE_TEMPLATE/bug-report.yml +71 -0
- package/templates/storefront/.prettierrc +8 -0
- package/templates/storefront/Dockerfile +37 -0
- package/templates/storefront/LICENSE +21 -0
- package/templates/storefront/README.md +127 -0
- package/templates/storefront/locales/en.json +269 -0
- package/templates/storefront/locales/fr.json +269 -0
- package/templates/storefront/next-env.d.ts +7 -0
- package/templates/storefront/next-sitemap.js +19 -0
- package/templates/storefront/next.config.js +29 -0
- package/templates/storefront/package-lock.json +9031 -0
- package/templates/storefront/package.json +49 -0
- package/templates/storefront/postcss.config.js +6 -0
- package/templates/storefront/public/favicon.ico +0 -0
- package/templates/storefront/scripts/sync-design.mjs +30 -0
- package/templates/storefront/src/app/(checkout)/checkout/page.tsx +31 -0
- package/templates/storefront/src/app/(checkout)/layout.tsx +29 -0
- package/templates/storefront/src/app/(checkout)/not-found.tsx +20 -0
- package/templates/storefront/src/app/(main)/account/@dashboard/addresses/page.tsx +34 -0
- package/templates/storefront/src/app/(main)/account/@dashboard/loading.tsx +9 -0
- package/templates/storefront/src/app/(main)/account/@dashboard/orders/details/[id]/page.tsx +33 -0
- package/templates/storefront/src/app/(main)/account/@dashboard/orders/page.tsx +37 -0
- package/templates/storefront/src/app/(main)/account/@dashboard/page.tsx +22 -0
- package/templates/storefront/src/app/(main)/account/@dashboard/profile/page.tsx +53 -0
- package/templates/storefront/src/app/(main)/account/@login/page.tsx +12 -0
- package/templates/storefront/src/app/(main)/account/layout.tsx +20 -0
- package/templates/storefront/src/app/(main)/account/loading.tsx +9 -0
- package/templates/storefront/src/app/(main)/cart/loading.tsx +5 -0
- package/templates/storefront/src/app/(main)/cart/not-found.tsx +21 -0
- package/templates/storefront/src/app/(main)/cart/page.tsx +21 -0
- package/templates/storefront/src/app/(main)/categories/[...category]/page.tsx +55 -0
- package/templates/storefront/src/app/(main)/collections/[handle]/page.tsx +55 -0
- package/templates/storefront/src/app/(main)/layout.tsx +43 -0
- package/templates/storefront/src/app/(main)/legal/[page]/page.tsx +42 -0
- package/templates/storefront/src/app/(main)/not-found.tsx +21 -0
- package/templates/storefront/src/app/(main)/order/[id]/confirmed/loading.tsx +5 -0
- package/templates/storefront/src/app/(main)/order/[id]/confirmed/page.tsx +23 -0
- package/templates/storefront/src/app/(main)/order/[id]/transfer/[token]/accept/page.tsx +42 -0
- package/templates/storefront/src/app/(main)/order/[id]/transfer/[token]/decline/page.tsx +42 -0
- package/templates/storefront/src/app/(main)/order/[id]/transfer/[token]/page.tsx +35 -0
- package/templates/storefront/src/app/(main)/order/track/page.tsx +32 -0
- package/templates/storefront/src/app/(main)/page.tsx +68 -0
- package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +86 -0
- package/templates/storefront/src/app/(main)/store/page.tsx +28 -0
- package/templates/storefront/src/app/api/health/route.ts +13 -0
- package/templates/storefront/src/app/layout.tsx +42 -0
- package/templates/storefront/src/app/not-found.tsx +21 -0
- package/templates/storefront/src/app/opengraph-image.jpg +0 -0
- package/templates/storefront/src/app/twitter-image.jpg +0 -0
- package/templates/storefront/src/instrumentation-node.ts +18 -0
- package/templates/storefront/src/instrumentation.ts +9 -0
- package/templates/storefront/src/lib/boutique/campaign.ts +116 -0
- package/templates/storefront/src/lib/boutique/config.ts +152 -0
- package/templates/storefront/src/lib/boutique/fonts.ts +29 -0
- package/templates/storefront/src/lib/boutique/i18n-client.tsx +34 -0
- package/templates/storefront/src/lib/boutique/i18n.ts +43 -0
- package/templates/storefront/src/lib/boutique/schema.ts +131 -0
- package/templates/storefront/src/lib/config.ts +39 -0
- package/templates/storefront/src/lib/constants.tsx +75 -0
- package/templates/storefront/src/lib/context/modal-context.tsx +34 -0
- package/templates/storefront/src/lib/data/cart.ts +434 -0
- package/templates/storefront/src/lib/data/categories.ts +49 -0
- package/templates/storefront/src/lib/data/collections.ts +59 -0
- package/templates/storefront/src/lib/data/cookies.ts +103 -0
- package/templates/storefront/src/lib/data/customer.ts +261 -0
- package/templates/storefront/src/lib/data/fulfillment.ts +68 -0
- package/templates/storefront/src/lib/data/onboarding.ts +9 -0
- package/templates/storefront/src/lib/data/order-lookup.ts +34 -0
- package/templates/storefront/src/lib/data/orders.ts +112 -0
- package/templates/storefront/src/lib/data/payment.ts +35 -0
- package/templates/storefront/src/lib/data/products.ts +127 -0
- package/templates/storefront/src/lib/data/regions.ts +70 -0
- package/templates/storefront/src/lib/data/variants.ts +38 -0
- package/templates/storefront/src/lib/hooks/use-in-view.tsx +29 -0
- package/templates/storefront/src/lib/hooks/use-toggle-state.tsx +46 -0
- package/templates/storefront/src/lib/util/compare-addresses.ts +28 -0
- package/templates/storefront/src/lib/util/get-percentage-diff.ts +6 -0
- package/templates/storefront/src/lib/util/get-product-price.ts +79 -0
- package/templates/storefront/src/lib/util/isEmpty.ts +11 -0
- package/templates/storefront/src/lib/util/medusa-error.ts +22 -0
- package/templates/storefront/src/lib/util/money.ts +35 -0
- package/templates/storefront/src/lib/util/product.ts +5 -0
- package/templates/storefront/src/lib/util/repeat.ts +5 -0
- package/templates/storefront/src/lib/util/sort-products.ts +50 -0
- package/templates/storefront/src/modules/account/components/account-info/index.tsx +140 -0
- package/templates/storefront/src/modules/account/components/account-nav/index.tsx +198 -0
- package/templates/storefront/src/modules/account/components/address-book/index.tsx +28 -0
- package/templates/storefront/src/modules/account/components/address-card/add-address.tsx +169 -0
- package/templates/storefront/src/modules/account/components/address-card/edit-address-modal.tsx +241 -0
- package/templates/storefront/src/modules/account/components/login/index.tsx +65 -0
- package/templates/storefront/src/modules/account/components/order-card/index.tsx +88 -0
- package/templates/storefront/src/modules/account/components/order-overview/index.tsx +47 -0
- package/templates/storefront/src/modules/account/components/overview/index.tsx +169 -0
- package/templates/storefront/src/modules/account/components/profile-billing-address/index.tsx +193 -0
- package/templates/storefront/src/modules/account/components/profile-email/index.tsx +75 -0
- package/templates/storefront/src/modules/account/components/profile-name/index.tsx +79 -0
- package/templates/storefront/src/modules/account/components/profile-password/index.tsx +72 -0
- package/templates/storefront/src/modules/account/components/profile-phone/index.tsx +74 -0
- package/templates/storefront/src/modules/account/components/register/index.tsx +107 -0
- package/templates/storefront/src/modules/account/components/transfer-request-form/index.tsx +83 -0
- package/templates/storefront/src/modules/account/templates/account-layout.tsx +43 -0
- package/templates/storefront/src/modules/account/templates/login-template.tsx +27 -0
- package/templates/storefront/src/modules/cart/components/cart-item-select/index.tsx +75 -0
- package/templates/storefront/src/modules/cart/components/empty-cart-message/index.tsx +25 -0
- package/templates/storefront/src/modules/cart/components/item/index.tsx +144 -0
- package/templates/storefront/src/modules/cart/components/sign-in-prompt/index.tsx +27 -0
- package/templates/storefront/src/modules/cart/templates/index.tsx +51 -0
- package/templates/storefront/src/modules/cart/templates/items.tsx +58 -0
- package/templates/storefront/src/modules/cart/templates/preview.tsx +51 -0
- package/templates/storefront/src/modules/cart/templates/summary.tsx +50 -0
- package/templates/storefront/src/modules/categories/templates/index.tsx +94 -0
- package/templates/storefront/src/modules/checkout/components/address-select/index.tsx +117 -0
- package/templates/storefront/src/modules/checkout/components/addresses/index.tsx +186 -0
- package/templates/storefront/src/modules/checkout/components/billing_address/index.tsx +114 -0
- package/templates/storefront/src/modules/checkout/components/country-select/index.tsx +50 -0
- package/templates/storefront/src/modules/checkout/components/discount-code/index.tsx +181 -0
- package/templates/storefront/src/modules/checkout/components/error-message/index.tsx +13 -0
- package/templates/storefront/src/modules/checkout/components/payment/index.tsx +261 -0
- package/templates/storefront/src/modules/checkout/components/payment-button/index.tsx +195 -0
- package/templates/storefront/src/modules/checkout/components/payment-container/index.tsx +130 -0
- package/templates/storefront/src/modules/checkout/components/payment-test/index.tsx +12 -0
- package/templates/storefront/src/modules/checkout/components/payment-wrapper/index.tsx +42 -0
- package/templates/storefront/src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx +54 -0
- package/templates/storefront/src/modules/checkout/components/review/index.tsx +54 -0
- package/templates/storefront/src/modules/checkout/components/shipping/index.tsx +417 -0
- package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +219 -0
- package/templates/storefront/src/modules/checkout/components/submit-button/index.tsx +32 -0
- package/templates/storefront/src/modules/checkout/templates/checkout-form/index.tsx +38 -0
- package/templates/storefront/src/modules/checkout/templates/checkout-summary/index.tsx +31 -0
- package/templates/storefront/src/modules/collections/templates/index.tsx +44 -0
- package/templates/storefront/src/modules/common/components/cart-totals/index.tsx +84 -0
- package/templates/storefront/src/modules/common/components/checkbox/index.tsx +44 -0
- package/templates/storefront/src/modules/common/components/delete-button/index.tsx +42 -0
- package/templates/storefront/src/modules/common/components/divider/index.tsx +9 -0
- package/templates/storefront/src/modules/common/components/filter-radio-group/index.tsx +61 -0
- package/templates/storefront/src/modules/common/components/input/index.tsx +79 -0
- package/templates/storefront/src/modules/common/components/interactive-link/index.tsx +33 -0
- package/templates/storefront/src/modules/common/components/line-item-options/index.tsx +26 -0
- package/templates/storefront/src/modules/common/components/line-item-price/index.tsx +63 -0
- package/templates/storefront/src/modules/common/components/line-item-unit-price/index.tsx +62 -0
- package/templates/storefront/src/modules/common/components/localized-client-link/index.tsx +29 -0
- package/templates/storefront/src/modules/common/components/modal/index.tsx +118 -0
- package/templates/storefront/src/modules/common/components/native-select/index.tsx +76 -0
- package/templates/storefront/src/modules/common/components/radio/index.tsx +27 -0
- package/templates/storefront/src/modules/common/icons/back.tsx +37 -0
- package/templates/storefront/src/modules/common/icons/bancontact.tsx +26 -0
- package/templates/storefront/src/modules/common/icons/chevron-down.tsx +30 -0
- package/templates/storefront/src/modules/common/icons/eye-off.tsx +37 -0
- package/templates/storefront/src/modules/common/icons/eye.tsx +37 -0
- package/templates/storefront/src/modules/common/icons/fast-delivery.tsx +65 -0
- package/templates/storefront/src/modules/common/icons/ideal.tsx +26 -0
- package/templates/storefront/src/modules/common/icons/map-pin.tsx +37 -0
- package/templates/storefront/src/modules/common/icons/package.tsx +44 -0
- package/templates/storefront/src/modules/common/icons/paypal.tsx +30 -0
- package/templates/storefront/src/modules/common/icons/placeholder-image.tsx +44 -0
- package/templates/storefront/src/modules/common/icons/refresh.tsx +51 -0
- package/templates/storefront/src/modules/common/icons/spinner.tsx +37 -0
- package/templates/storefront/src/modules/common/icons/trash.tsx +51 -0
- package/templates/storefront/src/modules/common/icons/user.tsx +37 -0
- package/templates/storefront/src/modules/common/icons/x.tsx +37 -0
- package/templates/storefront/src/modules/layout/components/cart-mismatch-banner/index.tsx +59 -0
- package/templates/storefront/src/modules/order/components/help/index.tsx +26 -0
- package/templates/storefront/src/modules/order/components/item/index.tsx +57 -0
- package/templates/storefront/src/modules/order/components/items/index.tsx +44 -0
- package/templates/storefront/src/modules/order/components/onboarding-cta/index.tsx +28 -0
- package/templates/storefront/src/modules/order/components/order-details/index.tsx +64 -0
- package/templates/storefront/src/modules/order/components/order-summary/index.tsx +61 -0
- package/templates/storefront/src/modules/order/components/payment-details/index.tsx +64 -0
- package/templates/storefront/src/modules/order/components/shipping-details/index.tsx +74 -0
- package/templates/storefront/src/modules/order/components/transfer-actions/index.tsx +83 -0
- package/templates/storefront/src/modules/order/components/transfer-image/index.tsx +275 -0
- package/templates/storefront/src/modules/order/templates/order-completed-template.tsx +53 -0
- package/templates/storefront/src/modules/order/templates/order-details-template.tsx +46 -0
- package/templates/storefront/src/modules/products/components/thumbnail/index.tsx +70 -0
- package/templates/storefront/src/modules/products/templates/index.tsx +118 -0
- package/templates/storefront/src/modules/shipping/components/free-shipping-price-nudge/index.tsx +285 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-button/index.tsx +5 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-card-details/index.tsx +10 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-cart-item/index.tsx +35 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-cart-totals/index.tsx +30 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-code-form/index.tsx +13 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-line-item/index.tsx +35 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-order-confirmed-header/index.tsx +14 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-order-information/index.tsx +36 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-order-items/index.tsx +43 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-order-summary/index.tsx +15 -0
- package/templates/storefront/src/modules/skeletons/components/skeleton-product-preview/index.tsx +15 -0
- package/templates/storefront/src/modules/skeletons/templates/skeleton-cart-page/index.tsx +65 -0
- package/templates/storefront/src/modules/skeletons/templates/skeleton-order-confirmed/index.tsx +21 -0
- package/templates/storefront/src/modules/skeletons/templates/skeleton-product-grid/index.tsx +23 -0
- package/templates/storefront/src/modules/store/components/pagination/index.tsx +114 -0
- package/templates/storefront/src/modules/store/components/refinement-list/index.tsx +41 -0
- package/templates/storefront/src/modules/store/components/refinement-list/sort-products/index.tsx +48 -0
- package/templates/storefront/src/modules/store/templates/index.tsx +42 -0
- package/templates/storefront/src/modules/store/templates/paginated-products.tsx +80 -0
- package/templates/storefront/src/modules/ub/button.tsx +41 -0
- package/templates/storefront/src/modules/ub/campaign-blocks.tsx +197 -0
- package/templates/storefront/src/modules/ub/campaign-ended.tsx +28 -0
- package/templates/storefront/src/modules/ub/category-tiles.tsx +41 -0
- package/templates/storefront/src/modules/ub/countdown.tsx +46 -0
- package/templates/storefront/src/modules/ub/footer.tsx +56 -0
- package/templates/storefront/src/modules/ub/icons.tsx +42 -0
- package/templates/storefront/src/modules/ub/nav.tsx +90 -0
- package/templates/storefront/src/modules/ub/pdp/buy-box.tsx +140 -0
- package/templates/storefront/src/modules/ub/pdp/gallery.tsx +40 -0
- package/templates/storefront/src/modules/ub/product-card.tsx +66 -0
- package/templates/storefront/src/modules/ub/top-bar.tsx +20 -0
- package/templates/storefront/src/modules/ub/track-form.tsx +92 -0
- package/templates/storefront/src/proxy.ts +89 -0
- package/templates/storefront/src/styles/globals.css +98 -0
- package/templates/storefront/src/styles/ub/bundle.css +372 -0
- package/templates/storefront/src/styles/ub/fonts.css +17 -0
- package/templates/storefront/src/styles/ub/medusa-ui.css +90 -0
- package/templates/storefront/src/styles/ub/overrides.css +12 -0
- package/templates/storefront/src/styles/ub/tokens.css +9 -0
- package/templates/storefront/src/types/global.ts +24 -0
- package/templates/storefront/src/types/icon.ts +4 -0
- package/templates/storefront/tailwind.config.js +164 -0
- package/templates/storefront/themes/black-friday/campaign.en.json +31 -0
- package/templates/storefront/themes/black-friday/campaign.fr.json +31 -0
- package/templates/storefront/themes/black-friday/theme.json +9 -0
- package/templates/storefront/themes/cyber-monday/campaign.en.json +31 -0
- package/templates/storefront/themes/cyber-monday/campaign.fr.json +31 -0
- package/templates/storefront/themes/cyber-monday/theme.json +9 -0
- package/templates/storefront/themes/halloween/campaign.en.json +31 -0
- package/templates/storefront/themes/halloween/campaign.fr.json +31 -0
- package/templates/storefront/themes/halloween/theme.json +9 -0
- package/templates/storefront/themes/noel/campaign.en.json +31 -0
- package/templates/storefront/themes/noel/campaign.fr.json +31 -0
- package/templates/storefront/themes/noel/theme.json +9 -0
- package/templates/storefront/themes/nouvel-an/campaign.en.json +31 -0
- package/templates/storefront/themes/nouvel-an/campaign.fr.json +31 -0
- package/templates/storefront/themes/nouvel-an/theme.json +9 -0
- package/templates/storefront/themes/thanksgiving/campaign.en.json +31 -0
- package/templates/storefront/themes/thanksgiving/campaign.fr.json +31 -0
- package/templates/storefront/themes/thanksgiving/theme.json +9 -0
- package/templates/storefront/tsconfig.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# create-boutique
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import { compose, composeOutput, isRunning } from "./docker.js";
|
|
6
|
+
import { composeYaml, readEnv, refreshEnv, writeCampaignFiles, writeReadme } from "./generate.js";
|
|
7
|
+
import { copyDir, TEMPLATES, templateVersion } from "./paths.js";
|
|
8
|
+
import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
|
|
9
|
+
import { currencies, effective, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG } from "./project.js";
|
|
10
|
+
import { listThemes } from "./themes.js";
|
|
11
|
+
import { writeGuides } from "./guide.js";
|
|
12
|
+
const STATE = (dir) => path.join(dir, ".create-boutique-state.json");
|
|
13
|
+
const hash = (p) => crypto.createHash("sha256").update(JSON.stringify(p)).digest("hex");
|
|
14
|
+
// ─────────────────────────── prix par devise ───────────────────────────
|
|
15
|
+
/** seed/products.csv doit avoir une colonne price_<devise> pour chaque devise utilisée (sinon produits invisibles). */
|
|
16
|
+
function checkCsvCurrencies(target, p) {
|
|
17
|
+
const csv = path.join(target, "seed", "products.csv");
|
|
18
|
+
if (!fs.existsSync(csv))
|
|
19
|
+
return;
|
|
20
|
+
const header = fs
|
|
21
|
+
.readFileSync(csv, "utf8")
|
|
22
|
+
.replace(/^/, "")
|
|
23
|
+
.split(/\r?\n/)[0]
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((h) => h.trim());
|
|
26
|
+
const missing = currencies(p).filter((c) => !header.includes(`price_${c}`));
|
|
27
|
+
if (missing.length) {
|
|
28
|
+
throw new ProjectError(`seed/products.csv n'a pas de colonne ${missing.map((c) => `price_${c}`).join(", ")} : ajoutez ces prix dans le CSV (ou dans l'admin si le catalogue est déjà importé)`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Projet lancé : produits publiés sans prix dans la devise de chaque boutique (avertissement). */
|
|
32
|
+
function priceWarnings(target) {
|
|
33
|
+
if (!isRunning(target, "backend"))
|
|
34
|
+
return;
|
|
35
|
+
const r = composeOutput(target, ["exec", "-T", "backend", "npx", "medusa", "exec", "./src/scripts/price-report.js"]);
|
|
36
|
+
const m = r.stdout.match(/<<<PRICES([\s\S]*)PRICES>>>/);
|
|
37
|
+
if (!m)
|
|
38
|
+
return;
|
|
39
|
+
const report = JSON.parse(m[1]);
|
|
40
|
+
for (const [slug, { currency, missing, published }] of Object.entries(report)) {
|
|
41
|
+
if (published === 0) {
|
|
42
|
+
console.log(pc.yellow(`! ${slug} : aucun produit publié dans cette boutique → Admin → Canaux de vente → ${slug} → Ajouter des produits`));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!missing.length)
|
|
46
|
+
continue;
|
|
47
|
+
const list = `${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}`;
|
|
48
|
+
console.log(pc.yellow(`! ${slug} : ${missing.length} produit(s) sans prix en ${currency.toUpperCase()} (invisibles) : ${list} → Admin → Produits → prix`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ─────────────────────────── apply ───────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* Applique config/project.json : valide (sans rien écrire si erreur), régénère docker-compose.yml,
|
|
54
|
+
* .env (variables dérivées), README, campagnes des nouvelles boutiques, puis relance le projet.
|
|
55
|
+
* Le backend redémarre seulement si project.json a changé (il relance alors son seed idempotent).
|
|
56
|
+
*/
|
|
57
|
+
export async function apply(dir, opts = { up: true }) {
|
|
58
|
+
const target = path.resolve(dir);
|
|
59
|
+
const p = readProject(target); // lève une ProjectError détaillée si invalide
|
|
60
|
+
checkCsvCurrencies(target, p); // avant toute écriture
|
|
61
|
+
const others = reservedByOthers(target);
|
|
62
|
+
const clash = [p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)].filter((port) => others.has(port));
|
|
63
|
+
if (clash.length)
|
|
64
|
+
throw new ProjectError(`port(s) déjà réservé(s) par un autre projet : ${clash.join(", ")}`);
|
|
65
|
+
writeCampaignFiles(target, p, p.defaults.language);
|
|
66
|
+
writeJsonSchema(target);
|
|
67
|
+
fs.writeFileSync(path.join(target, "docker-compose.yml"), composeYaml(p));
|
|
68
|
+
refreshEnv(target, p);
|
|
69
|
+
writeReadme(target, p);
|
|
70
|
+
reservePorts(target, [p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)]);
|
|
71
|
+
const previous = fs.existsSync(STATE(target)) ? JSON.parse(fs.readFileSync(STATE(target), "utf8")).hash : null;
|
|
72
|
+
const changed = previous !== hash(p);
|
|
73
|
+
console.log(pc.green("✔ project.json valide, docker-compose.yml et .env régénérés"));
|
|
74
|
+
if (!opts.up)
|
|
75
|
+
return;
|
|
76
|
+
if (compose(target, ["up", "-d", "--remove-orphans"]) !== 0)
|
|
77
|
+
throw new Error("docker compose up a échoué");
|
|
78
|
+
// État inconnu (jamais appliqué avec démarrage) : on considère que project.json a changé.
|
|
79
|
+
if (changed && isRunning(target, "backend")) {
|
|
80
|
+
console.log("project.json a changé : redémarrage du backend (régions, boutiques, clés créées si besoin)…");
|
|
81
|
+
compose(target, ["restart", "backend"]);
|
|
82
|
+
compose(target, ["up", "-d"]); // relance les boutiques qui attendaient leur clé
|
|
83
|
+
}
|
|
84
|
+
fs.writeFileSync(STATE(target), JSON.stringify({ hash: hash(p), appliedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
85
|
+
priceWarnings(target);
|
|
86
|
+
}
|
|
87
|
+
// ─────────────────────────── add / remove / set ───────────────────────────
|
|
88
|
+
export async function add(dir, specs, opts) {
|
|
89
|
+
if (!specs.length)
|
|
90
|
+
throw new ProjectError("--boutique <slug>:<thème> obligatoire");
|
|
91
|
+
const target = path.resolve(dir);
|
|
92
|
+
const p = readProject(target);
|
|
93
|
+
const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort, ...p.boutiques.map((b) => b.port)]);
|
|
94
|
+
let from = Math.max(...p.boutiques.map((b) => b.port)) + 1;
|
|
95
|
+
for (const spec of specs) {
|
|
96
|
+
const [slug, theme] = spec.split(":");
|
|
97
|
+
if (!slug || !theme || !SLUG.test(slug))
|
|
98
|
+
throw new ProjectError(`format attendu : --boutique <slug>:<thème> (reçu « ${spec} »)`);
|
|
99
|
+
const port = await nextFreePort(from, taken);
|
|
100
|
+
taken.add(port);
|
|
101
|
+
from = port + 1;
|
|
102
|
+
p.boutiques.push({ slug, theme, port });
|
|
103
|
+
console.log(`+ ${slug} (${theme}) → http://localhost:${port}`);
|
|
104
|
+
}
|
|
105
|
+
writeProject(target, validateProject(p));
|
|
106
|
+
await apply(target, opts);
|
|
107
|
+
}
|
|
108
|
+
export async function remove(dir, slugs, opts) {
|
|
109
|
+
const target = path.resolve(dir);
|
|
110
|
+
const p = readProject(target);
|
|
111
|
+
for (const slug of slugs) {
|
|
112
|
+
if (!p.boutiques.some((b) => b.slug === slug))
|
|
113
|
+
throw new ProjectError(`boutique inconnue : ${slug}`);
|
|
114
|
+
}
|
|
115
|
+
p.boutiques = p.boutiques.filter((b) => !slugs.includes(b.slug));
|
|
116
|
+
if (!p.boutiques.length)
|
|
117
|
+
throw new ProjectError("impossible de retirer la dernière boutique");
|
|
118
|
+
writeProject(target, validateProject(p));
|
|
119
|
+
console.log(`- ${slugs.join(", ")} retirée(s). Ses commandes et son sales channel restent dans l'admin.`);
|
|
120
|
+
await apply(target, opts);
|
|
121
|
+
}
|
|
122
|
+
export async function set(dir, o, opts) {
|
|
123
|
+
const target = path.resolve(dir);
|
|
124
|
+
const p = readProject(target);
|
|
125
|
+
if (o.boutique) {
|
|
126
|
+
const b = p.boutiques.find((x) => x.slug === o.boutique);
|
|
127
|
+
if (!b)
|
|
128
|
+
throw new ProjectError(`boutique inconnue : ${o.boutique}`);
|
|
129
|
+
if (o.language)
|
|
130
|
+
throw new ProjectError("la langue se règle pour tout le projet : create-boutique set <dossier> --language <code>");
|
|
131
|
+
if (o.name)
|
|
132
|
+
b.storeName = o.name;
|
|
133
|
+
if (o.theme)
|
|
134
|
+
b.theme = o.theme;
|
|
135
|
+
if (o.currency)
|
|
136
|
+
b.currency = o.currency;
|
|
137
|
+
if (o.country)
|
|
138
|
+
b.country = o.country;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
if (o.theme)
|
|
142
|
+
throw new ProjectError("--theme s'applique à une boutique : ajoutez --boutique <slug>");
|
|
143
|
+
if (o.name)
|
|
144
|
+
p.defaults.storeName = o.name;
|
|
145
|
+
if (o.currency)
|
|
146
|
+
p.defaults.currency = o.currency;
|
|
147
|
+
if (o.country)
|
|
148
|
+
p.defaults.country = o.country;
|
|
149
|
+
if (o.language)
|
|
150
|
+
p.defaults.language = o.language;
|
|
151
|
+
}
|
|
152
|
+
writeProject(target, validateProject(p));
|
|
153
|
+
await apply(target, opts);
|
|
154
|
+
}
|
|
155
|
+
// ─────────────────────────── themes ───────────────────────────
|
|
156
|
+
export function themes() {
|
|
157
|
+
for (const t of listThemes())
|
|
158
|
+
console.log(`${t.id.padEnd(16)} ${t.name.padEnd(16)} langues : ${t.languages.join(", ")}`);
|
|
159
|
+
}
|
|
160
|
+
// ─────────────────────────── update ───────────────────────────
|
|
161
|
+
/** Remplace storefront/ et backend/ par la version actuelle du template (config, seed, .env et données conservés). */
|
|
162
|
+
export async function update(dir, opts) {
|
|
163
|
+
const target = path.resolve(dir);
|
|
164
|
+
const p = readProject(target);
|
|
165
|
+
const from = p.templateVersion ?? "?";
|
|
166
|
+
const to = templateVersion();
|
|
167
|
+
for (const part of ["storefront", "backend"]) {
|
|
168
|
+
fs.rmSync(path.join(target, part), { recursive: true, force: true });
|
|
169
|
+
copyDir(path.join(TEMPLATES, part), path.join(target, part));
|
|
170
|
+
}
|
|
171
|
+
p.templateVersion = to;
|
|
172
|
+
writeProject(target, p);
|
|
173
|
+
console.log(pc.green(`✔ code mis à jour : template ${from} → ${to}`));
|
|
174
|
+
await apply(target, { up: false });
|
|
175
|
+
if (opts.up && compose(target, ["up", "-d", "--build", "--remove-orphans"]) !== 0)
|
|
176
|
+
throw new Error("docker compose up --build a échoué");
|
|
177
|
+
}
|
|
178
|
+
// ─────────────────────────── backup ───────────────────────────
|
|
179
|
+
export function backup(dir) {
|
|
180
|
+
const target = path.resolve(dir);
|
|
181
|
+
readProject(target);
|
|
182
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
183
|
+
const out = path.join(target, "backups", stamp);
|
|
184
|
+
fs.mkdirSync(out, { recursive: true });
|
|
185
|
+
const dump = composeOutput(target, ["exec", "-T", "postgres", "pg_dump", "-U", "medusa", "--clean", "--if-exists", "medusa"]);
|
|
186
|
+
if (dump.code !== 0)
|
|
187
|
+
throw new Error(`pg_dump a échoué : ${dump.stderr}`);
|
|
188
|
+
fs.writeFileSync(path.join(out, "database.sql"), dump.stdout);
|
|
189
|
+
if (compose(target, ["cp", "storage:/data", path.join(out, "storage")]) !== 0)
|
|
190
|
+
throw new Error("copie des images échouée");
|
|
191
|
+
console.log(pc.green(`✔ sauvegarde : ${out}`));
|
|
192
|
+
console.log("Restauration base : docker compose exec -T postgres psql -U medusa medusa < backups/<date>/database.sql");
|
|
193
|
+
}
|
|
194
|
+
// ─────────────────────────── campaign export / import ───────────────────────────
|
|
195
|
+
export function campaignExport(dir) {
|
|
196
|
+
const target = path.resolve(dir);
|
|
197
|
+
readProject(target);
|
|
198
|
+
const r = composeOutput(target, ["exec", "-T", "backend", "npx", "medusa", "exec", "./src/scripts/campaign-export.js"]);
|
|
199
|
+
const m = r.stdout.match(/<<<CAMPAIGNS([\s\S]*)CAMPAIGNS>>>/);
|
|
200
|
+
if (r.code !== 0 || !m)
|
|
201
|
+
throw new Error(`export impossible (le backend tourne-t-il ?)\n${r.stderr}`);
|
|
202
|
+
const campaigns = JSON.parse(m[1]);
|
|
203
|
+
const dirOut = path.join(target, "config", "campaigns");
|
|
204
|
+
fs.mkdirSync(dirOut, { recursive: true });
|
|
205
|
+
for (const [slug, c] of Object.entries(campaigns)) {
|
|
206
|
+
fs.writeFileSync(path.join(dirOut, `${slug}.json`), JSON.stringify(c, null, 2) + "\n");
|
|
207
|
+
console.log(`← ${slug}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export async function campaignImport(dir, only, yes) {
|
|
211
|
+
const target = path.resolve(dir);
|
|
212
|
+
const p = readProject(target);
|
|
213
|
+
const slugs = only.length ? only : p.boutiques.map((b) => b.slug);
|
|
214
|
+
const data = {};
|
|
215
|
+
for (const slug of slugs) {
|
|
216
|
+
const file = path.join(target, "config", "campaigns", `${slug}.json`);
|
|
217
|
+
if (!fs.existsSync(file))
|
|
218
|
+
throw new ProjectError(`fichier manquant : ${file}`);
|
|
219
|
+
data[slug] = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
220
|
+
}
|
|
221
|
+
if (!yes) {
|
|
222
|
+
const { confirm, isCancel } = await import("@clack/prompts");
|
|
223
|
+
const ok = await confirm({ message: `Écraser en base la campagne de ${slugs.join(", ")} (modifications faites dans l'admin perdues) ?`, initialValue: false });
|
|
224
|
+
if (isCancel(ok) || !ok)
|
|
225
|
+
return console.log("Rien n'a été importé.");
|
|
226
|
+
}
|
|
227
|
+
const r = composeOutput(target, ["exec", "-T", "-e", `CAMPAIGN_IMPORT=${Buffer.from(JSON.stringify(data)).toString("base64")}`, "backend", "npx", "medusa", "exec", "./src/scripts/campaign-import.js"]);
|
|
228
|
+
if (r.code !== 0 || !/campagne\(s\) importée/.test(r.stdout))
|
|
229
|
+
throw new Error(`import impossible\n${r.stdout}\n${r.stderr}`);
|
|
230
|
+
console.log(pc.green(`✔ ${slugs.length} campagne(s) importée(s) (visibles sur les boutiques en ~1 min)`));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Test de bout en bout, pour chaque boutique : backend en ligne, boutique en ligne avec son thème,
|
|
234
|
+
* produit avec un prix dans sa devise, commande complète en paiement manuel (panier → adresse →
|
|
235
|
+
* livraison → paiement → commande). Signale aussi ce qui manque (Stripe, e-mails).
|
|
236
|
+
*/
|
|
237
|
+
export async function check(dir) {
|
|
238
|
+
const target = path.resolve(dir);
|
|
239
|
+
const p = readProject(target);
|
|
240
|
+
const env = readEnv(target);
|
|
241
|
+
const backend = `http://localhost:${p.apiPort}`;
|
|
242
|
+
let failed = 0;
|
|
243
|
+
const health = await fetch(`${backend}/health`).then((r) => r.ok).catch(() => false);
|
|
244
|
+
console.log(`${health ? pc.green("OK") : pc.red("KO")} backend ${backend}`);
|
|
245
|
+
if (!health)
|
|
246
|
+
throw new Error("le backend ne répond pas : docker compose up -d, puis docker compose logs backend");
|
|
247
|
+
for (const b of p.boutiques) {
|
|
248
|
+
const e = effective(p, b);
|
|
249
|
+
const steps = [];
|
|
250
|
+
try {
|
|
251
|
+
const url = b.url ?? `http://localhost:${b.port}`;
|
|
252
|
+
const html = await fetch(url).then((r) => (r.ok ? r.text() : Promise.reject(new Error(`boutique HTTP ${r.status}`))));
|
|
253
|
+
if (!html.includes(`data-theme="${b.theme}"`))
|
|
254
|
+
throw new Error(`thème ${b.theme} absent de la page`);
|
|
255
|
+
steps.push("page");
|
|
256
|
+
const key = composeOutput(target, ["exec", "-T", "backend", "cat", `/app/keys/${b.slug}`]).stdout.trim();
|
|
257
|
+
if (!key.startsWith("pk_"))
|
|
258
|
+
throw new Error("publishable key absente (seed du backend ?)");
|
|
259
|
+
const store = async (pathname, init) => {
|
|
260
|
+
const r = await fetch(`${backend}${pathname}`, {
|
|
261
|
+
method: init?.method ?? "GET",
|
|
262
|
+
headers: { "x-publishable-api-key": key, "content-type": "application/json" },
|
|
263
|
+
body: init?.body ? JSON.stringify(init.body) : undefined,
|
|
264
|
+
});
|
|
265
|
+
const json = await r.json().catch(() => ({}));
|
|
266
|
+
if (!r.ok)
|
|
267
|
+
throw new Error(`${pathname} → ${r.status} ${json.message ?? ""}`);
|
|
268
|
+
return json;
|
|
269
|
+
};
|
|
270
|
+
const { regions } = await store("/store/regions");
|
|
271
|
+
const region = regions.find((r) => r.currency_code === e.currency && r.countries.some((c) => c.iso_2 === e.country));
|
|
272
|
+
if (!region)
|
|
273
|
+
throw new Error(`aucune région ${e.currency.toUpperCase()} contenant ${e.country}`);
|
|
274
|
+
const { products } = await store(`/store/products?region_id=${region.id}&fields=*variants.calculated_price&limit=50`);
|
|
275
|
+
const variant = products.flatMap((pr) => pr.variants ?? []).find((v) => v.calculated_price?.calculated_amount != null);
|
|
276
|
+
if (!variant)
|
|
277
|
+
throw new Error(`aucun produit avec un prix en ${e.currency.toUpperCase()}`);
|
|
278
|
+
steps.push("prix");
|
|
279
|
+
const { cart } = await store("/store/carts", { method: "POST", body: { region_id: region.id } });
|
|
280
|
+
await store(`/store/carts/${cart.id}/line-items`, { method: "POST", body: { variant_id: variant.id, quantity: 1 } });
|
|
281
|
+
const address = { first_name: "Check", last_name: "Boutique", address_1: "1 Test Street", city: "Test", postal_code: "10001", country_code: e.country };
|
|
282
|
+
await store(`/store/carts/${cart.id}`, { method: "POST", body: { email: "check@example.com", shipping_address: address, billing_address: address } });
|
|
283
|
+
const { shipping_options } = await store(`/store/shipping-options?cart_id=${cart.id}`);
|
|
284
|
+
if (!shipping_options?.length)
|
|
285
|
+
throw new Error(`aucune livraison vers ${e.country}`);
|
|
286
|
+
await store(`/store/carts/${cart.id}/shipping-methods`, { method: "POST", body: { option_id: shipping_options[0].id } });
|
|
287
|
+
const { payment_collection } = await store("/store/payment-collections", { method: "POST", body: { cart_id: cart.id } });
|
|
288
|
+
await store(`/store/payment-collections/${payment_collection.id}/payment-sessions`, { method: "POST", body: { provider_id: "pp_system_default" } });
|
|
289
|
+
const done = await store(`/store/carts/${cart.id}/complete`, { method: "POST" });
|
|
290
|
+
if (done.type !== "order")
|
|
291
|
+
throw new Error(`commande non créée : ${done.error?.message ?? "?"}`);
|
|
292
|
+
steps.push(`commande #${done.order.display_id}`);
|
|
293
|
+
console.log(`${pc.green("OK")} ${b.slug.padEnd(24)} ${steps.join(" → ")}`);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
failed++;
|
|
297
|
+
console.log(`${pc.red("KO")} ${b.slug.padEnd(24)} ${steps.join(" → ")}${steps.length ? " → " : ""}${pc.red(err.message)}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!env.STRIPE_API_KEY)
|
|
301
|
+
console.log(pc.yellow("! Stripe non configuré : paiement manuel uniquement (STRIPE_API_KEY dans .env)"));
|
|
302
|
+
else if (!env.STRIPE_WEBHOOK_SECRET)
|
|
303
|
+
console.log(pc.yellow("! STRIPE_WEBHOOK_SECRET vide : webhook Stripe à créer (voir README)"));
|
|
304
|
+
if (!env.RESEND_API_KEY)
|
|
305
|
+
console.log(pc.yellow("! RESEND_API_KEY vide : aucun e-mail n'est envoyé aux clients"));
|
|
306
|
+
console.log(pc.dim(" Les commandes de test (check@example.com) peuvent être annulées dans l'admin."));
|
|
307
|
+
if (failed)
|
|
308
|
+
throw new Error(`${failed} boutique(s) en échec`);
|
|
309
|
+
}
|
|
310
|
+
// ─────────────────────────── admin / guide ───────────────────────────
|
|
311
|
+
/** Accès à l'admin du projet (lus dans .env). Le mot de passe s'affiche seulement sur cette machine. */
|
|
312
|
+
export function admin(dir) {
|
|
313
|
+
const target = path.resolve(dir);
|
|
314
|
+
const p = readProject(target);
|
|
315
|
+
const env = readEnv(target);
|
|
316
|
+
console.log(`Admin http://localhost:${p.apiPort}/app`);
|
|
317
|
+
console.log(`E-mail ${env.ADMIN_EMAIL ?? "?"}`);
|
|
318
|
+
console.log(`Mot de passe ${env.ADMIN_PASSWORD ?? "?"}`);
|
|
319
|
+
console.log(pc.dim("À transmettre au client séparément de la fiche (jamais par le même canal). Il peut le changer ensuite : « Mot de passe oublié ? » sur la page de connexion."));
|
|
320
|
+
}
|
|
321
|
+
/** Régénère la fiche client (1 page) et le guide complet du projet. */
|
|
322
|
+
export function guide(dir) {
|
|
323
|
+
const target = path.resolve(dir);
|
|
324
|
+
const p = readProject(target);
|
|
325
|
+
const env = readEnv(target);
|
|
326
|
+
for (const r of writeGuides(target, p, env.ADMIN_EMAIL ?? p.defaults.merchantEmail ?? "")) {
|
|
327
|
+
console.log(`${r.pdf ? pc.green("✔") : pc.yellow("!")} ${path.basename(r.file)}${r.pdf ? "" : " (Chrome/Edge introuvable : à imprimer en PDF)"}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// ─────────────────────────── stripe-webhook ───────────────────────────
|
|
331
|
+
const STRIPE_EVENTS = [
|
|
332
|
+
"payment_intent.succeeded",
|
|
333
|
+
"payment_intent.payment_failed",
|
|
334
|
+
"payment_intent.amount_capturable_updated",
|
|
335
|
+
"payment_intent.partially_funded",
|
|
336
|
+
];
|
|
337
|
+
/** Remplace (ou ajoute) une variable du .env sans toucher aux autres. */
|
|
338
|
+
function setEnvValue(target, key, value) {
|
|
339
|
+
const file = path.join(target, ".env");
|
|
340
|
+
let text = fs.readFileSync(file, "utf8");
|
|
341
|
+
const re = new RegExp(`^${key}=.*$`, "m");
|
|
342
|
+
text = re.test(text) ? text.replace(re, `${key}=${value}`) : `${text.trimEnd()}\n${key}=${value}\n`;
|
|
343
|
+
fs.writeFileSync(file, text);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Crée le webhook Stripe du projet (adresse publique du backend) avec la clé STRIPE_API_KEY du .env,
|
|
347
|
+
* écrit le secret whsec_… dans STRIPE_WEBHOOK_SECRET et relance le backend.
|
|
348
|
+
* Relançable : un webhook existant sur la même URL est remplacé (Stripe ne redonne jamais un secret).
|
|
349
|
+
*/
|
|
350
|
+
export async function stripeWebhook(dir, backendUrl, opts) {
|
|
351
|
+
const target = path.resolve(dir);
|
|
352
|
+
readProject(target);
|
|
353
|
+
const env = readEnv(target);
|
|
354
|
+
const key = env.STRIPE_API_KEY;
|
|
355
|
+
if (!key?.startsWith("sk_"))
|
|
356
|
+
throw new ProjectError("STRIPE_API_KEY (sk_…) absente du .env : ajoutez les clés Stripe d'abord");
|
|
357
|
+
if (!backendUrl || !/^https:\/\/[^/]+/.test(backendUrl) || /localhost|127\.0\.0\.1/.test(backendUrl)) {
|
|
358
|
+
throw new ProjectError("--url <adresse publique HTTPS du backend> obligatoire (ex. https://api.marque.com) : Stripe ne peut pas joindre localhost");
|
|
359
|
+
}
|
|
360
|
+
const url = `${backendUrl.replace(/\/$/, "")}/hooks/payment/stripe_stripe`;
|
|
361
|
+
const stripe = async (p, method = "GET", body) => {
|
|
362
|
+
const r = await fetch(`https://api.stripe.com/v1${p}`, { method, headers: { authorization: `Bearer ${key}` }, body });
|
|
363
|
+
const j = await r.json();
|
|
364
|
+
if (!r.ok)
|
|
365
|
+
throw new Error(`Stripe : ${j.error?.message ?? r.status}`);
|
|
366
|
+
return j;
|
|
367
|
+
};
|
|
368
|
+
const { data: existing } = await stripe("/webhook_endpoints?limit=100");
|
|
369
|
+
for (const w of existing.filter((w) => w.url === url)) {
|
|
370
|
+
await stripe(`/webhook_endpoints/${w.id}`, "DELETE");
|
|
371
|
+
console.log(`ancien webhook ${w.id} remplacé`);
|
|
372
|
+
}
|
|
373
|
+
const body = new URLSearchParams({ url, description: `create-boutique ${path.basename(target)}` });
|
|
374
|
+
for (const e of STRIPE_EVENTS)
|
|
375
|
+
body.append("enabled_events[]", e);
|
|
376
|
+
const created = await stripe("/webhook_endpoints", "POST", body);
|
|
377
|
+
setEnvValue(target, "STRIPE_WEBHOOK_SECRET", created.secret);
|
|
378
|
+
console.log(pc.green(`✔ webhook Stripe créé (${key.startsWith("sk_live") ? "production" : "test"}) : ${url}`));
|
|
379
|
+
console.log(pc.green("✔ STRIPE_WEBHOOK_SECRET écrit dans .env"));
|
|
380
|
+
if (opts.up && compose(target, ["up", "-d"]) !== 0)
|
|
381
|
+
throw new Error("docker compose up a échoué");
|
|
382
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { composeYaml, writeCampaignFiles, writeEnv, writeReadme } from "./generate.js";
|
|
7
|
+
import { writeGuides } from "./guide.js";
|
|
8
|
+
import { copyDir, TEMPLATES, templateVersion } from "./paths.js";
|
|
9
|
+
import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
|
|
10
|
+
import { validateProject, writeJsonSchema, writeProject, ProjectError } from "./project.js";
|
|
11
|
+
import { listThemes, themeIds } from "./themes.js";
|
|
12
|
+
/** Réponses du CLI : mêmes clés pour les options, le fichier --config et les questions. */
|
|
13
|
+
export const answersSchema = z.object({
|
|
14
|
+
name: z.string().min(1).max(60).optional(),
|
|
15
|
+
email: z.email().optional(),
|
|
16
|
+
currency: z.string().regex(/^[a-z]{3}$/).optional(),
|
|
17
|
+
country: z.string().regex(/^[a-z]{2}$/).optional(),
|
|
18
|
+
language: z.string().regex(/^[a-z]{2}$/).optional(),
|
|
19
|
+
themes: z.union([z.literal("all"), z.array(z.string())]).optional(),
|
|
20
|
+
boutiques: z.array(z.string().regex(/^[a-z0-9-]+:[a-z0-9-]+$/, "format slug:thème")).optional(),
|
|
21
|
+
stripePk: z.string().optional(),
|
|
22
|
+
stripeSk: z.string().optional(),
|
|
23
|
+
stripeWhsec: z.string().optional(),
|
|
24
|
+
resendKey: z.string().optional(),
|
|
25
|
+
emailFrom: z.email().optional(),
|
|
26
|
+
products: z.string().optional(),
|
|
27
|
+
images: z.string().optional(),
|
|
28
|
+
storePort: z.number().int().optional(),
|
|
29
|
+
apiPort: z.number().int().optional(),
|
|
30
|
+
storagePort: z.number().int().optional(),
|
|
31
|
+
});
|
|
32
|
+
const cancelled = (v) => {
|
|
33
|
+
if (p.isCancel(v)) {
|
|
34
|
+
p.cancel("Création annulée.");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
return v;
|
|
38
|
+
};
|
|
39
|
+
const slugify = (s) => s
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.normalize("NFD")
|
|
42
|
+
.replace(/[̀-ͯ]/g, "")
|
|
43
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
44
|
+
.replace(/^-+|-+$/g, "");
|
|
45
|
+
/** Complète les réponses manquantes par des questions (sauf --yes : valeurs par défaut). */
|
|
46
|
+
async function ask(a, dirName, yes) {
|
|
47
|
+
const themes = listThemes();
|
|
48
|
+
if (yes) {
|
|
49
|
+
if (!a.email)
|
|
50
|
+
throw new ProjectError("--email est obligatoire avec --yes");
|
|
51
|
+
return {
|
|
52
|
+
...a,
|
|
53
|
+
name: a.name ?? dirName,
|
|
54
|
+
email: a.email,
|
|
55
|
+
currency: a.currency ?? "usd",
|
|
56
|
+
country: a.country ?? "us",
|
|
57
|
+
language: a.language ?? "en",
|
|
58
|
+
themes: a.themes ?? (a.boutiques?.length ? [] : "all"),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
p.intro(pc.inverse(" create-boutique "));
|
|
62
|
+
const name = a.name ?? cancelled(await p.text({ message: "Nom de la boutique", initialValue: dirName, validate: (v) => (v?.trim() ? undefined : "obligatoire") }));
|
|
63
|
+
const email = a.email ?? cancelled(await p.text({ message: "E-mail admin (et du marchand)", validate: (v) => (z.email().safeParse(v).success ? undefined : "e-mail invalide") }));
|
|
64
|
+
const currency = a.currency ?? cancelled(await p.text({ message: "Devise (code ISO)", initialValue: "usd", validate: (v) => (/^[a-z]{3}$/.test(v ?? "") ? undefined : "ex. usd, eur, xof") }));
|
|
65
|
+
const country = a.country ?? cancelled(await p.text({ message: "Pays principal (code ISO)", initialValue: "us", validate: (v) => (/^[a-z]{2}$/.test(v ?? "") ? undefined : "ex. us, fr, sn") }));
|
|
66
|
+
const language = a.language ?? cancelled(await p.text({ message: "Langue du site", initialValue: "en", validate: (v) => (/^[a-z]{2}$/.test(v ?? "") ? undefined : "ex. en, fr") }));
|
|
67
|
+
let chosen = a.themes;
|
|
68
|
+
if (!chosen) {
|
|
69
|
+
chosen = cancelled(await p.multiselect({
|
|
70
|
+
message: "Thèmes (une boutique par thème ; espace = cocher)",
|
|
71
|
+
options: themes.map((t) => ({ value: t.id, label: t.id, hint: t.name })),
|
|
72
|
+
initialValues: themes.map((t) => t.id),
|
|
73
|
+
required: false,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
const extra = [...(a.boutiques ?? [])];
|
|
77
|
+
if (!a.boutiques) {
|
|
78
|
+
while (cancelled(await p.confirm({ message: "Ajouter une autre boutique ?", initialValue: false }))) {
|
|
79
|
+
const slug = cancelled(await p.text({ message: "Nom (slug) de la boutique", validate: (v) => (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v ?? "") ? undefined : "minuscules, chiffres et tirets") }));
|
|
80
|
+
const theme = cancelled(await p.select({ message: "Thème", options: themes.map((t) => ({ value: t.id, label: t.id })) }));
|
|
81
|
+
extra.push(`${slug}:${theme}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const stripePk = a.stripePk ?? (cancelled(await p.text({ message: "Clé Stripe publique (vide = paiement manuel)", placeholder: "pk_test_…", defaultValue: "" })) || undefined);
|
|
85
|
+
const stripeSk = stripePk ? (a.stripeSk ?? (cancelled(await p.password({ message: "Clé Stripe secrète" })) || undefined)) : a.stripeSk;
|
|
86
|
+
const products = a.products ?? (cancelled(await p.text({ message: "Fichier produits (CSV, optionnel)", defaultValue: "" })) || undefined);
|
|
87
|
+
const images = a.images ?? (cancelled(await p.text({ message: "Dossier images (optionnel)", defaultValue: "" })) || undefined);
|
|
88
|
+
return { ...a, name, email, currency, country, language, themes: chosen, boutiques: extra, stripePk, stripeSk, products, images };
|
|
89
|
+
}
|
|
90
|
+
export async function create(dir, input, opts) {
|
|
91
|
+
const target = path.resolve(dir);
|
|
92
|
+
if (fs.existsSync(target) && fs.readdirSync(target).length) {
|
|
93
|
+
throw new ProjectError(`le dossier ${target} existe déjà et n'est pas vide`);
|
|
94
|
+
}
|
|
95
|
+
const project = slugify(path.basename(target));
|
|
96
|
+
if (!project)
|
|
97
|
+
throw new ProjectError("nom de dossier invalide");
|
|
98
|
+
const a = await ask(input, path.basename(target), opts.yes);
|
|
99
|
+
// Boutiques : une par thème coché (slug = <projet>-<thème>) + --boutique slug:thème
|
|
100
|
+
const known = themeIds();
|
|
101
|
+
const themeList = a.themes === "all" ? known : (a.themes ?? []);
|
|
102
|
+
for (const t of themeList)
|
|
103
|
+
if (!known.includes(t))
|
|
104
|
+
throw new ProjectError(`thème inconnu : ${t} (disponibles : ${known.join(", ")})`);
|
|
105
|
+
const wanted = [
|
|
106
|
+
...themeList.map((t) => ({ slug: `${project}-${t}`, theme: t })),
|
|
107
|
+
...(a.boutiques ?? []).map((s) => {
|
|
108
|
+
const [slug, theme] = s.split(":");
|
|
109
|
+
return { slug, theme };
|
|
110
|
+
}),
|
|
111
|
+
];
|
|
112
|
+
if (!wanted.length)
|
|
113
|
+
throw new ProjectError("au moins une boutique est obligatoire (--themes ou --boutique)");
|
|
114
|
+
// Ports : libres sur la machine et non réservés par un autre projet généré.
|
|
115
|
+
const taken = reservedByOthers(target);
|
|
116
|
+
const apiPort = await nextFreePort(a.apiPort ?? 9000, taken);
|
|
117
|
+
taken.add(apiPort);
|
|
118
|
+
const storagePort = await nextFreePort(a.storagePort ?? 9100, taken);
|
|
119
|
+
taken.add(storagePort);
|
|
120
|
+
let nextStore = a.storePort ?? 8001;
|
|
121
|
+
const boutiques = [];
|
|
122
|
+
for (const w of wanted) {
|
|
123
|
+
const port = await nextFreePort(nextStore, taken);
|
|
124
|
+
taken.add(port);
|
|
125
|
+
nextStore = port + 1;
|
|
126
|
+
boutiques.push({ slug: w.slug, theme: w.theme, port });
|
|
127
|
+
}
|
|
128
|
+
const proj = validateProject({
|
|
129
|
+
project,
|
|
130
|
+
templateVersion: templateVersion(),
|
|
131
|
+
apiPort,
|
|
132
|
+
storagePort,
|
|
133
|
+
defaults: {
|
|
134
|
+
storeName: a.name,
|
|
135
|
+
currency: a.currency,
|
|
136
|
+
country: a.country,
|
|
137
|
+
language: a.language,
|
|
138
|
+
shipsTo: "all",
|
|
139
|
+
merchantEmail: a.email,
|
|
140
|
+
email: { mode: "platform" },
|
|
141
|
+
},
|
|
142
|
+
boutiques,
|
|
143
|
+
});
|
|
144
|
+
// Récapitulatif
|
|
145
|
+
const width = Math.max(...proj.boutiques.map((b) => b.slug.length)) + 2;
|
|
146
|
+
const lines = [
|
|
147
|
+
...proj.boutiques.map((b) => `${b.slug.padEnd(width)}${b.theme.padEnd(14)}→ http://localhost:${b.port}`),
|
|
148
|
+
`${"Backend / admin".padEnd(width + 14)}→ http://localhost:${proj.apiPort}/app`,
|
|
149
|
+
`Devise ${a.currency.toUpperCase()} · pays ${a.country.toUpperCase()} · langue ${a.language}`,
|
|
150
|
+
`Paiement : ${a.stripeSk ? "Stripe + manuel" : "manuel (Stripe : non configuré)"}`,
|
|
151
|
+
`Catalogue : ${a.products ?? "aucun (à ajouter dans l'admin)"}`,
|
|
152
|
+
];
|
|
153
|
+
if (opts.yes)
|
|
154
|
+
console.log(`\nRécapitulatif : ${proj.boutiques.length} boutique(s)\n ${lines.join("\n ")}\n`);
|
|
155
|
+
else {
|
|
156
|
+
p.note(lines.join("\n"), `Récapitulatif : ${proj.boutiques.length} boutique(s)`);
|
|
157
|
+
if (!cancelled(await p.confirm({ message: "Créer le projet ?", initialValue: true }))) {
|
|
158
|
+
p.cancel("Rien n'a été créé.");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Écriture du projet
|
|
163
|
+
copyDir(path.join(TEMPLATES, "storefront"), path.join(target, "storefront"));
|
|
164
|
+
copyDir(path.join(TEMPLATES, "backend"), path.join(target, "backend"));
|
|
165
|
+
writeProject(target, proj);
|
|
166
|
+
writeJsonSchema(target);
|
|
167
|
+
writeCampaignFiles(target, proj, a.language);
|
|
168
|
+
fs.mkdirSync(path.join(target, "seed", "images"), { recursive: true });
|
|
169
|
+
if (a.products)
|
|
170
|
+
fs.copyFileSync(path.resolve(a.products), path.join(target, "seed", "products.csv"));
|
|
171
|
+
if (a.images)
|
|
172
|
+
copyDir(path.resolve(a.images), path.join(target, "seed", "images"));
|
|
173
|
+
writeEnv(target, proj, {
|
|
174
|
+
adminEmail: a.email,
|
|
175
|
+
stripePk: a.stripePk,
|
|
176
|
+
stripeSk: a.stripeSk,
|
|
177
|
+
stripeWhsec: a.stripeWhsec,
|
|
178
|
+
resendKey: a.resendKey,
|
|
179
|
+
emailFrom: a.emailFrom,
|
|
180
|
+
});
|
|
181
|
+
fs.writeFileSync(path.join(target, "docker-compose.yml"), composeYaml(proj));
|
|
182
|
+
writeReadme(target, proj);
|
|
183
|
+
fs.writeFileSync(path.join(target, ".gitignore"), ".env\nbackups/\nnode_modules/\n");
|
|
184
|
+
reservePorts(target, [proj.apiPort, proj.storagePort, ...proj.boutiques.map((b) => b.port)]);
|
|
185
|
+
for (const g of writeGuides(target, proj, a.email)) {
|
|
186
|
+
if (!g.pdf)
|
|
187
|
+
console.log(pc.yellow(`Chrome/Edge introuvable : ${path.basename(g.file)} à imprimer en PDF.`));
|
|
188
|
+
}
|
|
189
|
+
const next = `cd ${path.relative(process.cwd(), target) || "."}\ndocker compose up -d`;
|
|
190
|
+
if (opts.yes)
|
|
191
|
+
console.log(`Projet créé : ${target}\nEnsuite :\n ${next.replace("\n", "\n ")}\n`);
|
|
192
|
+
else
|
|
193
|
+
p.outro(`Projet créé. Ensuite :\n\n ${pc.cyan(next.replace("\n", "\n "))}\n\n Mot de passe admin : ADMIN_PASSWORD dans .env (à transmettre à part).`);
|
|
194
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** docker compose dans le dossier du projet (sortie affichée). Renvoie le code de sortie. */
|
|
3
|
+
export function compose(dir, args, opts = {}) {
|
|
4
|
+
const r = spawnSync("docker", ["compose", ...args], { cwd: dir, stdio: "inherit", shell: false, ...opts });
|
|
5
|
+
if (r.error)
|
|
6
|
+
throw new Error(`docker introuvable : installez Docker (${r.error.message})`);
|
|
7
|
+
return r.status ?? 1;
|
|
8
|
+
}
|
|
9
|
+
/** docker compose avec sortie capturée. */
|
|
10
|
+
export function composeOutput(dir, args, input) {
|
|
11
|
+
const r = spawnSync("docker", ["compose", ...args], { cwd: dir, encoding: "utf8", input, maxBuffer: 256 * 1024 * 1024 });
|
|
12
|
+
if (r.error)
|
|
13
|
+
throw new Error(`docker introuvable : installez Docker (${r.error.message})`);
|
|
14
|
+
return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
15
|
+
}
|
|
16
|
+
export function isRunning(dir, service) {
|
|
17
|
+
const { stdout } = composeOutput(dir, ["ps", "--status", "running", "--services"]);
|
|
18
|
+
return stdout.split(/\r?\n/).includes(service);
|
|
19
|
+
}
|