first commit
@@ -0,0 +1,143 @@
|
|||||||
|
# place4bees – vereinte Webseite
|
||||||
|
|
||||||
|
Selbst-hostbare Architektur: **Medusa.js** (Shop-Backend + Admin) + **Astro** (handgebautes Frontend).
|
||||||
|
|
||||||
|
**place4bees ist die neue, einzige Unternehmensmarke.** "Imkerei Björn Schumann" / "hhonig" taucht im Auftritt nirgends mehr auf (außer ggf. später im Impressum aus rechtlichen Gründen).
|
||||||
|
|
||||||
|
## Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ Medusa-Backend (Node.js, Turborepo), self-hosted via Docker
|
||||||
|
→ apps/backend enthält die eigentliche Medusa-Instanz
|
||||||
|
frontend/ Astro-Frontend
|
||||||
|
docker-compose.yml Postgres + Redis + Medusa-Backend (self-hosted)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Eine Website, zwei Domains, zwei Bereiche
|
||||||
|
|
||||||
|
Es gibt **keine zwei Marken mehr**, sondern eine Website mit zwei Bereichen ("Sections"), gesteuert über `frontend/src/lib/sections.ts`:
|
||||||
|
|
||||||
|
- **Privatkunden** (`private`): Start, Shop, Über uns, Kontakt
|
||||||
|
- **Business** (`business`): Start, Angebot, Anfrage (nur Formular, kein Checkout)
|
||||||
|
|
||||||
|
Ein **persistenter Tab-Umschalter im Header** ("Privatkunden" / "Business") ist auf jeder Seite sichtbar und wechselt zwischen den Bereichen – unabhängig davon, auf welcher Domain man sich befindet:
|
||||||
|
|
||||||
|
- **hhonig.de** → `/` zeigt automatisch den Privatkunden-Bereich
|
||||||
|
- **place4bees.com** → `/` zeigt automatisch den Business-Bereich
|
||||||
|
- Auf beiden Domains sind **beide Bereiche vollständig erreichbar** über die stabilen Pfade `/privatkunden` und `/business` (die Tabs verlinken immer auf diese festen Pfade, nie auf das host-abhängige `/`, damit kein Klick-Loop entsteht).
|
||||||
|
|
||||||
|
Die Zuordnung passiert in `frontend/src/lib/sections.ts` (`resolveSection`, `isBusinessHost`) anhand des `Host`-Headers – rein serverseitig, keine Cookies/Redirects nötig.
|
||||||
|
|
||||||
|
## SEO-Konzept
|
||||||
|
|
||||||
|
- **Canonical-Konsolidierung:** Da dieselben Inhalte technisch auf zwei Domains erreichbar sind, zeigt jede Seite ihren `<link rel="canonical">` fest auf `https://place4bees.com/<pfad>` (in `Layout.astro`), unabhängig davon, über welche Domain sie aufgerufen wurde. Damit bündelt Google die Rankings auf einer Domain, statt sie zwischen hhonig.de und place4bees.com aufzuteilen. hhonig.de bleibt für Bestandsbesucher/Backlinks nutzbar, ohne hart per 301 umgeleitet zu werden.
|
||||||
|
- **Wichtig vor Go-Live:** Bestehende URLs von hhonig.de, shop.hhonig.de und place4bees.com erfassen und auf die neuen Pfade (`/`, `/shop`, `/business`, …) mappen – als 301-Redirects dort, wo sich die URL-Struktur ändert.
|
||||||
|
- Google Search Console für beide Domains einrichten/aktualisieren, aktualisierte Sitemap einreichen.
|
||||||
|
- Produktseiten sollten vor Launch noch strukturierte Daten (schema.org `Product`) bekommen; der Privatkunden-Bereich zusätzlich `LocalBusiness`.
|
||||||
|
|
||||||
|
## Design / Theming
|
||||||
|
|
||||||
|
Farben sind als semantische Tokens in `frontend/src/styles/global.css` definiert (`--color-brand-primary`, `--color-brand-ink`, …) statt als rohe Tailwind-Farben in den Komponenten. Ein komplettes Reskinning bedeutet: Werte in dieser einen Datei ändern – Komponenten bleiben unangetastet. Für einen eigenen Akzent nur im Business-Bereich gibt es bereits den Haken `[data-section="business"] { ... }` in derselben Datei.
|
||||||
|
|
||||||
|
**Aktuelles Look & Feel (2. Iteration, bewusst kontrastreicher):** dunkle Ink-Hero- und Footer-Flächen mit gestaffelter Hexagon-Grafik (`.hex-shape` in `global.css`, mehrere überlappende Sechsecke statt einer einzelnen Fläche), große Fraunces-Serifen-Headlines in Creme auf Dunkelbraun, Honiggold als Primärfarbe im Privatkunden-Bereich, Petrolgrün im Business-Bereich (automatisch über `[data-section="business"]`). Shop-Karten mit Schatten, Hover-Lift-Animation und Kategorie-Badges.
|
||||||
|
|
||||||
|
## Checkout-Flow
|
||||||
|
|
||||||
|
Voll funktionsfähig gegen die Medusa Store API, End-to-End getestet:
|
||||||
|
|
||||||
|
1. **Produktseite** (`/shop/[handle]`) → `AddToCartButton` legt bei Bedarf einen Cart an (Cookie `cart_id`) und fügt die gewählte Variante hinzu.
|
||||||
|
2. **Warenkorb** (`/warenkorb`) → Menge ändern / Position entfernen.
|
||||||
|
3. **Kasse** (`/kasse`) → Adresse, Versandart (aus Medusa geladen), Zahlung.
|
||||||
|
4. **Bestellbestätigung** (`/bestellung/danke`).
|
||||||
|
|
||||||
|
Zahlung läuft im Frontend aktuell noch über Medusas eingebauten `pp_system_default`-Provider (kein echtes Zahlungs-Gateway). Das PayPal-Backend ist bereits vorbereitet (siehe unten), die Frontend-Anbindung fehlt noch.
|
||||||
|
|
||||||
|
Alle Cart/Checkout-Aufrufe laufen direkt vom Browser aus gegen die Medusa Store API (`frontend/src/lib/cart.ts`), ohne eigene Backend-Route dazwischen.
|
||||||
|
|
||||||
|
## Zahlungsanbieter: PayPal
|
||||||
|
|
||||||
|
**Hinweis zur Wahl:** Medusa pflegt offiziell nur eine Stripe-Integration. Für PayPal gibt es kein offizielles Plugin, nur Community-Pakete einzelner kleiner Anbieter – hier verwendet: [`@easypayment/medusa-payment-paypal`](https://www.npmjs.com/package/@easypayment/medusa-payment-paypal) (aktuell das am aktivsten gepflegte). Das ist ein bewusst in Kauf genommenes Risiko (Wartung/Sicherheit liegen bei einem kleinen Drittanbieter, nicht bei Medusa selbst) – wurde nach Rücksprache trotzdem gewünscht.
|
||||||
|
|
||||||
|
**Bereits erledigt (Backend):**
|
||||||
|
- Plugin installiert, in `medusa-config.ts` registriert (Plugin + zwei Payment-Provider: `paypal` für Smart Buttons/Wallet, `paypal_card` für Karten-Zahlung über PayPal).
|
||||||
|
- Datenbank-Migration ausgeführt.
|
||||||
|
- Beide Provider (`pp_paypal_paypal`, `pp_paypal_card_paypal_card`) für die Europe-Region aktiviert.
|
||||||
|
- Server startet fehlerfrei mit der neuen Konfiguration.
|
||||||
|
|
||||||
|
**Noch offen (braucht dich):**
|
||||||
|
1. **PayPal-Konto verbinden:** Medusa Admin → Settings → PayPal → PayPal Connection → zunächst **Sandbox** wählen (Test-Modus, kostenlos und ohne Geschäftskonto-Prüfung sofort über [developer.paypal.com](https://developer.paypal.com/dashboard/) anlegbar) → "Connect to PayPal" oder Client-ID/Secret manuell eintragen.
|
||||||
|
2. **Frontend-Anbindung fehlt noch:** Das mitgelieferte UI-Paket (`@easypayment/medusa-paypal-ui`) ist explizit für Next.js-Storefronts gebaut und passt nicht zu unserem Astro-Frontend. Die PayPal-Buttons müssen wir selbst gegen PayPal's JS-SDK und die Medusa-Payment-Session bauen – das kann ich erst sinnvoll umsetzen und testen, sobald ein Sandbox-Konto verbunden ist (sonst baue ich ungetesteten Zahlungscode).
|
||||||
|
3. Sobald Sandbox läuft: echte End-to-End-Bestellung testen, dann auf **Live** umschalten (im selben Admin-Screen, ohne Server-Neustart laut Plugin-Doku).
|
||||||
|
4. Optional: `PAYPAL_ENCRYPTION_KEY` (beliebiger starker Zufallsstring) in `.env` setzen, damit die gespeicherten PayPal-Zugangsdaten in der Datenbank verschlüsselt statt im Klartext liegen.
|
||||||
|
|
||||||
|
## Produktdaten: Preis, MwSt., Grundpreis, Lieferzeit
|
||||||
|
|
||||||
|
- **Preise sind inkl. MwSt.** und werden auch so angezeigt (Pflicht im B2C-Verkauf in Deutschland). Technisch über Medusas "Price Preferences" gelöst (`/admin/price-preferences`, `is_tax_inclusive: true` für Währung UND Region – beide müssen übereinstimmen, siehe Bugfix unten).
|
||||||
|
- **Zwei Steuersätze eingerichtet** für die Deutschland-Steuerregion: 7% ermäßigt für alle 13 Honig-Produkte (Lebensmittel), 19% Standard für alles andere (Bienenprodukte, Süßigkeiten, Kurse, Gutscheine, Patenschaft). **Bitte mit einem Steuerberater gegenprüfen** – insbesondere Gutscheine und Kurse/Patenschaften können abweichende umsatzsteuerliche Behandlung erfordern, das habe ich nur nach bestem Wissen als Entwickler, nicht als Steuerexperte eingerichtet.
|
||||||
|
- **Grundpreis (Preis pro kg)** wird auf Produktseite und in der Shop-Übersicht angezeigt, wo vorhanden – berechnet aus dem tatsächlichen Nettofüllgewicht (z.B. "500 Gramm" aus dem Variantennamen), nicht aus dem Versandgewicht. Gespeichert als `metadata.net_weight_grams` pro Variante. Nur bei den 13 Honigsorten vorhanden (einzige Produkte, die nach Gewicht verkauft werden – für Gutscheine, Kurse, Kerzen etc. ist kein Grundpreis vorgeschrieben).
|
||||||
|
- **Lieferzeit** ("2-3 Werktage") als `metadata.delivery_time` pro Produkt hinterlegt und auf der Produktseite separat angezeigt (vorher stand das nur unstrukturiert im Beschreibungstext).
|
||||||
|
- **Kategorie-Badge** (Honig, Bienenprodukte, Süßigkeiten mit Honig, Geschenkgutschein, Kurse, Bienenpatenschaften) aus den ursprünglichen Shopify-Tags als `metadata.category` übernommen, auf Shop-Karte und Produktseite sichtbar.
|
||||||
|
|
||||||
|
**Wichtiger Bugfix:** Medusa hatte Produktpreise und Versandkosten inkonsistent besteuert – Produkte inkl. MwSt. (Bruttopreis blieb gleich), Versand exkl. MwSt. (Steuer wurde zusätzlich draufgerechnet, 10€ Versand wurden im Warenkorb zu 11,90€). Ursache: zwei widersprüchliche Price-Preference-Einträge (`currency_code: eur` → inklusiv, `region_id` Deutschland → exklusiv). Behoben, indem die Region-Preference ebenfalls auf `is_tax_inclusive: true` gesetzt wurde. End-to-End getestet: 7,90€ Honig + 10€ Versand = 17,90€ Gesamtsumme, keine versteckte Aufpreisung mehr.
|
||||||
|
|
||||||
|
## Lokal starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Postgres + Redis + Backend
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# oder Backend lokal für Entwicklung (schneller iterieren):
|
||||||
|
cd backend/apps/backend && npm run dev # läuft auf Port 9001 (9000 war lokal belegt)
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm run dev # läuft auf Port 4321
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin-Oberfläche für Produktpflege: **http://localhost:9001/app**
|
||||||
|
(Login wurde angelegt: `admin@hhonig.de` / `ChangeMe123!` – bitte nach dem ersten Login ändern.)
|
||||||
|
|
||||||
|
**Host-abhängiges Verhalten lokal testen:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Host: hhonig.de" http://localhost:4321/ # → Privatkunden
|
||||||
|
curl -H "Host: place4bees.com" http://localhost:4321/ # → Business
|
||||||
|
```
|
||||||
|
|
||||||
|
(`server.allowedHosts` in `astro.config.mjs` erlaubt diese Test-Hostnamen gegen den Dev-Server.)
|
||||||
|
|
||||||
|
## Bereits erledigt
|
||||||
|
|
||||||
|
- Medusa-Backend lokal lauffähig (Postgres + Redis via Docker), Admin-User angelegt, Admin-UI getestet.
|
||||||
|
- Astro-Frontend mit Tailwind, Node-SSR-Adapter, zentralen Design-Tokens.
|
||||||
|
- Eine Website, zwei Bereiche (Privatkunden/Business) mit persistentem Tab-Umschalter, host-abhängigem Default, konsistent auf beiden Domains erreichbar.
|
||||||
|
- Shop-Anbindung an Medusa Store API funktioniert End-to-End (Produktliste + Produktdetail).
|
||||||
|
- Kompletter Checkout-Flow (Cart → Adresse → Versand → Zahlung → Bestellung) gebaut und End-to-End getestet, Bestellung erscheint im Admin.
|
||||||
|
- **Produktkatalog von shop.hhonig.de migriert:** alle 23 Produkte (Honig, Bienenprodukte, Patenschaften, Kurse, Gutscheine, Süßigkeiten) über Shopifys öffentliche `products.json`-API ausgelesen und per Medusa Admin API angelegt – inkl. Varianten (Größen/Nennwerte), Preisen und Bildern. Migrations-Skript: siehe unten.
|
||||||
|
- Preis-Darstellung im Frontend korrigiert: Medusa v2 speichert Beträge als Dezimalwert (10 = 10,00 €, keine Cent-Umrechnung) – frühere `/100`-Division in Warenkorb/Kasse/Produktseite entfernt.
|
||||||
|
- Produktseite zeigt bei mehreren Größen/Varianten jetzt den zur aktuellen Auswahl passenden Preis (aktualisiert sich beim Wechsel der Variante).
|
||||||
|
- Produktbilder selbst gehostet (nicht mehr auf Shopify-CDN verlinkt), siehe Produktmigration unten.
|
||||||
|
- PayPal-Backend vorbereitet (Plugin, Provider, Region-Aktivierung) – Frontend-Buttons folgen, siehe "Nächste Schritte".
|
||||||
|
- MwSt. (7%/19%, inkl.-Anzeige), Grundpreis (€/kg) und Lieferzeit je Produkt ergänzt, siehe eigener Abschnitt unten.
|
||||||
|
- Redesign: dunkle Hero-/Footer-Flächen mit gestaffelter Hexagon-Grafik, überarbeitete Shop-Karten mit Preis/Grundpreis/Kategorie-Badge.
|
||||||
|
|
||||||
|
## Produktmigration (Shopify → Medusa)
|
||||||
|
|
||||||
|
Einmalig ausgeführtes Node-Skript, kein Teil der laufenden App: holt den Katalog über Shopifys öffentliche `https://shop.hhonig.de/products.json`-API und legt jedes Produkt per Medusa Admin API an (Varianten, Preise, Bilder). Nicht im Repo abgelegt (lief aus dem Scratchpad); bei Bedarf einfach neu schreiben oder erneut anfragen.
|
||||||
|
|
||||||
|
Bewusste Vereinfachungen beim Import, die noch nachgezogen werden sollten:
|
||||||
|
|
||||||
|
- ~~Bilder waren extern auf `cdn.shopify.com` verlinkt~~ – **erledigt:** alle 52 Bilder heruntergeladen und über Medusas lokales File-Modul selbst gehostet (`/static/...` auf dem Medusa-Server). Konfiguriert in `medusa-config.ts` (`@medusajs/file-local`, `backend_url` aus `MEDUSA_BACKEND_URL`). Für Produktion: `MEDUSA_BACKEND_URL` auf die echte Backend-Domain setzen, sonst zeigen die Bild-URLs auf `localhost`.
|
||||||
|
- **Tags/Kategorien wurden nicht übernommen** (Medusas Tag-API verlangt vorab angelegte Tag-IDs). Die Shopify-Kategorien (Honig, Bienenprodukte, Bienenpatenschaften, Kurse, Geschenkgutschein, Süßigkeiten) liegen aber in den Rohdaten vor und lassen sich bei Bedarf nachträglich als Medusa-Kategorien/Collections anlegen, sobald es eine Kategorie-Navigation im Frontend gibt.
|
||||||
|
- **Varianten sind auf `manage_inventory: false`** gesetzt (immer bestellbar, keine Lagerbestandsprüfung) – für den Start pragmatisch, sollte aber vor Go-Live durch echte Lagerbestände ersetzt werden, damit nichts verkauft wird, was nicht vorrätig ist.
|
||||||
|
- Ein Medusa-Demo-Produkt ("Medusa T-Shirt") ließ sich nicht löschen, da eine Testbestellung eine Lagerreservierung dafür hält – unschädlich, kann später im Admin entfernt werden, sobald die Testbestellung storniert/gelöscht ist.
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. PayPal-Sandbox-Konto verbinden (Medusa Admin → Settings → PayPal) und danach die PayPal-Buttons im Frontend bauen (siehe oben).
|
||||||
|
2. Steuersätze/-zuordnung von einem Steuerberater gegenprüfen lassen (siehe oben), bevor echte Bestellungen laufen.
|
||||||
|
3. Echte Lagerbestände pflegen statt `manage_inventory: false` (aktuell immer bestellbar, keine Bestandsprüfung).
|
||||||
|
4. Kontakt-/Anfrage-Formulare (`/kontakt`, `/business/anfrage`) an einen Versand-Endpunkt anbinden (z.B. E-Mail-Versand über eigenes API-Route in Astro).
|
||||||
|
5. Produktions-Deployment: Reverse-Proxy/Server so einrichten, dass beide Domains (hhonig.de, place4bees.com) auf dieselbe Astro-Instanz zeigen (Host-Header muss unverändert durchgereicht werden) und 301-Redirect-Mapping für geänderte Alt-URLs erstellen.
|
||||||
|
6. Redis-gestützte Event-Bus/Cache-Module für Produktionsbetrieb ergänzen (aktuell nutzt Medusa lokal In-Memory-Fallback).
|
||||||
|
7. Echte Medusa-Kategorien/Collections für die 6 Produktgruppen anlegen, sobald eine Kategorie-Navigation im Shop gewünscht ist (aktuell nur als Badge über `metadata.category`, keine Filter-Navigation).
|
||||||
|
8. Impressum/Rechtliches ergänzen (dort ggf. "Imkerei Björn Schumann" als rechtlicher Betreibername, falls weiterhin relevant).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Yarn 2+
|
||||||
|
.yarn/*
|
||||||
|
.yarn
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
**/.next/
|
||||||
|
**/out/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
**/build
|
||||||
|
**/dist
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.development.local
|
||||||
|
**/.env.test.local
|
||||||
|
**/.env.production.local
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# macOS system files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
**/.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
auto-install-peers=true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Medusa
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://www.medusajs.com">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
|
||||||
|
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<h1 align="center">
|
||||||
|
Medusa DTC Starter
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h4 align="center">
|
||||||
|
<a href="https://docs.medusajs.com">Documentation</a> |
|
||||||
|
<a href="https://www.medusajs.com">Website</a>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Building blocks for digital commerce
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/medusajs/medusa/blob/develop/LICENSE">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="Medusa is released under the MIT license." />
|
||||||
|
</a>
|
||||||
|
<a href="https://circleci.com/gh/medusajs/medusa">
|
||||||
|
<img src="https://circleci.com/gh/medusajs/medusa.svg?style=shield" alt="Current CircleCI build status." />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/medusajs/medusa/blob/develop/CONTRIBUTING.md">
|
||||||
|
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.producthunt.com/posts/medusa"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Day-%23DA552E" alt="Product Hunt"></a>
|
||||||
|
<a href="https://discord.gg/xpCwq3Kfn8">
|
||||||
|
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
|
||||||
|
</a>
|
||||||
|
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
|
||||||
|
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# Medusa DTC Starter
|
||||||
|
|
||||||
|
A production-ready monorepo starter for direct-to-consumer ecommerce stores powered by Medusa and Next.js. Includes a fully featured storefront with product browsing, cart, checkout, customer accounts, and order management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- All of [Medusa's commerce features](https://docs.medusajs.com/resources/commerce-modules)
|
||||||
|
- Multi-region support with automatic country detection
|
||||||
|
- Product catalog with variant selection
|
||||||
|
- Cart with promotion codes
|
||||||
|
- Multi-step checkout with shipping and payment
|
||||||
|
- Customer accounts with order history and address management
|
||||||
|
- Order transfer between accounts
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Deploy with Medusa Cloud
|
||||||
|
|
||||||
|
The fastest way to get started is deploying with [Medusa Cloud](https://cloud.medusajs.com):
|
||||||
|
|
||||||
|
1. [Create a Medusa Cloud account](https://cloud.medusajs.com)
|
||||||
|
2. Deploy this starter directly from your dashboard
|
||||||
|
|
||||||
|
### Local Installation
|
||||||
|
|
||||||
|
> **Prerequisites:
|
||||||
|
>
|
||||||
|
> - [Node.js](https://nodejs.org/) v20+
|
||||||
|
> - [PostgreSQL](https://www.postgresql.org/) v15+
|
||||||
|
> - [pnpm](https://pnpm.io/) v10+
|
||||||
|
|
||||||
|
1. Clone the repository and install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/medusajs/dtc-starter.git
|
||||||
|
cd dtc-starter
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up environment variables for the backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp apps/backend/.env.template apps/backend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set the database URL in `apps/backend.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replace with actual database URL, make sure the database exists.
|
||||||
|
DATABASE_URL=postgres://postgres:@localhost:5432/medusa-dtc-starter
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend
|
||||||
|
pnpm medusa db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Add admin user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend
|
||||||
|
pnpm medusa user -e admin@test.com -p supersecret
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Start Medusa backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Open the admin dashboard at `localhost:9000/app` and log in. Retrieve your publishable API key at Settings > Publishable API key.
|
||||||
|
|
||||||
|
8. Set up environment variables for the storefront:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp apps/storefront/.env.template apps/storefront/.env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
9. Update `apps/storefront/.env.local` with your Medusa publishable API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_6c3...
|
||||||
|
```
|
||||||
|
|
||||||
|
10. Start storefront:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/storefront
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The storefront runs on `http://localhost:8000`.
|
||||||
|
|
||||||
|
You can slo run the following command from the root to start both backend and storefront:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The storefront is configured via environment variables in `apps/storefront/.env.local`:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY` | Publishable API key from your Medusa backend | — |
|
||||||
|
| `NEXT_PUBLIC_MEDUSA_BACKEND_URL` | URL of your Medusa backend | `http://localhost:9000` |
|
||||||
|
| `NEXT_PUBLIC_DEFAULT_REGION` | Default region country code | `dk` |
|
||||||
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of the storefront | `https://localhost:8000` |
|
||||||
|
| `NEXT_PUBLIC_STRIPE_KEY` | Stripe publishable key (optional) | — |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Medusa Documentation](https://docs.medusajs.com)
|
||||||
|
- [Medusa Cloud](https://cloud.medusajs.com)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
STORE_CORS=http://localhost:8000,https://docs.medusajs.com
|
||||||
|
ADMIN_CORS=http://localhost:5173,http://localhost:9000,https://docs.medusajs.com
|
||||||
|
AUTH_CORS=http://localhost:5173,http://localhost:9000,https://docs.medusajs.com
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
JWT_SECRET=supersecret
|
||||||
|
COOKIE_SECRET=supersecret
|
||||||
|
DATABASE_URL=
|
||||||
|
DB_NAME=medusa-backend
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/dist
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
/uploads
|
||||||
|
/node_modules
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
.idea
|
||||||
|
|
||||||
|
coverage
|
||||||
|
|
||||||
|
!src/**
|
||||||
|
|
||||||
|
./tsconfig.tsbuildinfo
|
||||||
|
medusa-db.sql
|
||||||
|
build
|
||||||
|
.cache
|
||||||
|
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
.medusa
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://www.medusajs.com">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
|
||||||
|
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<h1 align="center">
|
||||||
|
Medusa
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h4 align="center">
|
||||||
|
<a href="https://docs.medusajs.com">Documentation</a> |
|
||||||
|
<a href="https://www.medusajs.com">Website</a>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Building blocks for digital commerce
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
|
||||||
|
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.producthunt.com/posts/medusa"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Day-%23DA552E" alt="Product Hunt"></a>
|
||||||
|
<a href="https://discord.gg/xpCwq3Kfn8">
|
||||||
|
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
|
||||||
|
</a>
|
||||||
|
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
|
||||||
|
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This starter is compatible with versions >= 2 of `@medusajs/medusa`.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Visit the [Quickstart Guide](https://docs.medusajs.com/learn/installation) to set up a server.
|
||||||
|
|
||||||
|
Visit the [Docs](https://docs.medusajs.com/learn/installation#get-started) to learn more about our system requirements.
|
||||||
|
|
||||||
|
## What is Medusa
|
||||||
|
|
||||||
|
Medusa is a set of commerce modules and tools that allow you to build rich, reliable, and performant commerce applications without reinventing core commerce logic. The modules can be customized and used to build advanced ecommerce stores, marketplaces, or any product that needs foundational commerce primitives. All modules are open-source and freely available on npm.
|
||||||
|
|
||||||
|
Learn more about [Medusa’s architecture](https://docs.medusajs.com/learn/introduction/architecture) and [commerce modules](https://docs.medusajs.com/learn/fundamentals/modules/commerce-modules) in the Docs.
|
||||||
|
|
||||||
|
## Community & Contributions
|
||||||
|
|
||||||
|
The community and core team are available in [GitHub Discussions](https://github.com/medusajs/medusa/discussions), where you can ask for support, discuss roadmap, and share ideas.
|
||||||
|
|
||||||
|
Join our [Discord server](https://discord.com/invite/medusajs) to meet other community members.
|
||||||
|
|
||||||
|
## Other channels
|
||||||
|
|
||||||
|
- [GitHub Issues](https://github.com/medusajs/medusa/issues)
|
||||||
|
- [Twitter](https://twitter.com/medusajs)
|
||||||
|
- [LinkedIn](https://www.linkedin.com/company/medusajs)
|
||||||
|
- [Medusa Blog](https://medusajs.com/blog/)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { defineConfig } from "eslint/config"
|
||||||
|
import medusa from "@medusajs/eslint-plugin"
|
||||||
|
|
||||||
|
export default defineConfig([...medusa.configs.recommended])
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Uncomment this file to enable instrumentation and observability using OpenTelemetry
|
||||||
|
// Refer to the docs for installation instructions: https://docs.medusajs.com/learn/debugging-and-testing/instrumentation
|
||||||
|
|
||||||
|
// import { registerOtel } from "@medusajs/medusa"
|
||||||
|
// // If using an exporter other than Zipkin, require it here.
|
||||||
|
// import { ZipkinExporter } from "@opentelemetry/exporter-zipkin"
|
||||||
|
|
||||||
|
// // If using an exporter other than Zipkin, initialize it here.
|
||||||
|
// const exporter = new ZipkinExporter({
|
||||||
|
// serviceName: 'my-medusa-project',
|
||||||
|
// })
|
||||||
|
|
||||||
|
// export function register() {
|
||||||
|
// registerOtel({
|
||||||
|
// serviceName: 'medusajs',
|
||||||
|
// // pass exporter
|
||||||
|
// exporter,
|
||||||
|
// instrument: {
|
||||||
|
// http: true,
|
||||||
|
// workflows: true,
|
||||||
|
// query: true
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
// }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const { loadEnv } = require("@medusajs/utils");
|
||||||
|
loadEnv("test", process.cwd());
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
transform: {
|
||||||
|
"^.+\\.[jt]s$": [
|
||||||
|
"@swc/jest",
|
||||||
|
{
|
||||||
|
jsc: {
|
||||||
|
parser: { syntax: "typescript", decorators: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testEnvironment: "node",
|
||||||
|
moduleFileExtensions: ["js", "ts", "json"],
|
||||||
|
modulePathIgnorePatterns: ["dist/", "<rootDir>/.medusa/"],
|
||||||
|
setupFiles: ["./integration-tests/setup.js"],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.TEST_TYPE === "integration:http") {
|
||||||
|
module.exports.testMatch = ["**/integration-tests/http/*.spec.[jt]s"];
|
||||||
|
} else if (process.env.TEST_TYPE === "integration:modules") {
|
||||||
|
module.exports.testMatch = ["**/src/modules/*/__tests__/**/*.[jt]s"];
|
||||||
|
} else if (process.env.TEST_TYPE === "unit") {
|
||||||
|
module.exports.testMatch = ["**/src/**/__tests__/**/*.unit.spec.[jt]s"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { loadEnv, defineConfig } from '@medusajs/framework/utils'
|
||||||
|
|
||||||
|
loadEnv(process.env.NODE_ENV || 'development', process.cwd())
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
projectConfig: {
|
||||||
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
|
redisUrl: process.env.REDIS_URL,
|
||||||
|
http: {
|
||||||
|
storeCors: process.env.STORE_CORS!,
|
||||||
|
adminCors: process.env.ADMIN_CORS!,
|
||||||
|
authCors: process.env.AUTH_CORS!,
|
||||||
|
jwtSecret: process.env.JWT_SECRET,
|
||||||
|
cookieSecret: process.env.COOKIE_SECRET,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
resolve: "@easypayment/medusa-payment-paypal",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/file",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/file-local",
|
||||||
|
id: "local",
|
||||||
|
options: {
|
||||||
|
backend_url: `${process.env.MEDUSA_BACKEND_URL}/static`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/payment",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
// PayPal Smart Buttons (wallet checkout)
|
||||||
|
resolve: "@easypayment/medusa-payment-paypal/providers/paypal",
|
||||||
|
id: "paypal",
|
||||||
|
options: {},
|
||||||
|
dependencies: ["paypal_onboarding"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Advanced Card Fields (hosted card inputs)
|
||||||
|
resolve: "@easypayment/medusa-payment-paypal/providers/paypal_card",
|
||||||
|
id: "paypal_card",
|
||||||
|
options: {},
|
||||||
|
dependencies: ["paypal_onboarding"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "@dtc/backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "A starter for Medusa projects.",
|
||||||
|
"author": "Medusa (https://medusajs.com)",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"sqlite",
|
||||||
|
"postgres",
|
||||||
|
"typescript",
|
||||||
|
"ecommerce",
|
||||||
|
"headless",
|
||||||
|
"medusa"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "medusa build",
|
||||||
|
"start": "medusa start",
|
||||||
|
"dev": "medusa develop",
|
||||||
|
"lint": "medusa lint",
|
||||||
|
"test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
|
||||||
|
"test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
|
||||||
|
"test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@easypayment/medusa-payment-paypal": "^0.9.29",
|
||||||
|
"@medusajs/admin-sdk": "2.17.0",
|
||||||
|
"@medusajs/admin-shared": "2.17.0",
|
||||||
|
"@medusajs/caching": "2.17.0",
|
||||||
|
"@medusajs/cli": "2.17.0",
|
||||||
|
"@medusajs/dashboard": "2.17.0",
|
||||||
|
"@medusajs/draft-order": "2.17.0",
|
||||||
|
"@medusajs/framework": "2.17.0",
|
||||||
|
"@medusajs/medusa": "2.17.0",
|
||||||
|
"@medusajs/ui": "4.1.15",
|
||||||
|
"@tanstack/react-query": "5.64.2",
|
||||||
|
"react-i18next": "13.5.0",
|
||||||
|
"react-router-dom": "6.30.3",
|
||||||
|
"zod": "4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@medusajs/test-utils": "2.17.0",
|
||||||
|
"@swc/core": "^1.7.28",
|
||||||
|
"@swc/jest": "^0.2.36",
|
||||||
|
"@types/jest": "^29.5.13",
|
||||||
|
"@types/node": "^20.12.11",
|
||||||
|
"@types/react": "^18.3.2",
|
||||||
|
"@types/react-dom": "^18.2.25",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.14",
|
||||||
|
"yalc": "^1.0.0-pre.53"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"packageManager": "npm@10.9.2"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Admin Customizations
|
||||||
|
|
||||||
|
You can extend the Medusa Admin to add widgets and new pages. Your customizations interact with API routes to provide merchants with custom functionalities.
|
||||||
|
|
||||||
|
> Learn more about Admin Extensions in [this documentation](https://docs.medusajs.com/learn/fundamentals/admin).
|
||||||
|
|
||||||
|
## Example: Create a Widget
|
||||||
|
|
||||||
|
A widget is a React component that can be injected into an existing page in the admin dashboard.
|
||||||
|
|
||||||
|
For example, create the file `src/admin/widgets/product-widget.tsx` with the following content:
|
||||||
|
|
||||||
|
```tsx title="src/admin/widgets/product-widget.tsx"
|
||||||
|
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
||||||
|
|
||||||
|
// The widget
|
||||||
|
const ProductWidget = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Product Widget</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The widget's configurations
|
||||||
|
export const config = defineWidgetConfig({
|
||||||
|
zone: "product.details.after",
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ProductWidget
|
||||||
|
```
|
||||||
|
|
||||||
|
This inserts a widget with the text “Product Widget” at the end of a product’s details page.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Admin Customizations Translations
|
||||||
|
|
||||||
|
The Medusa Admin dashboard supports multiple languages for its interface. Medusa uses [react-i18next](https://react.i18next.com/) to manage translations in the admin dashboard.
|
||||||
|
|
||||||
|
To add translations, create JSON translation files for each language under the `src/admin/i18n/json` directory. For example, create the `src/admin/i18n/json/en.json` file with the following content:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"brands": {
|
||||||
|
"title": "Brands",
|
||||||
|
"description": "Manage your product brands"
|
||||||
|
},
|
||||||
|
"done": "Done"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, export the translations in `src/admin/i18n/index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import en from "./json/en.json" with { type: "json" }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
en: {
|
||||||
|
translation: en,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, use translations in your admin widgets and routes using the `useTranslation` hook:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
||||||
|
import { Button, Container, Heading } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
const ProductWidget = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<Container className="p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<Heading level="h2">{t("brands.title")}</Heading>
|
||||||
|
<p>{t("brands.description")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end px-6 py-4">
|
||||||
|
<Button variant="primary">{t("done")}</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = defineWidgetConfig({
|
||||||
|
zone: "product.details.before",
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ProductWidget
|
||||||
|
```
|
||||||
|
|
||||||
|
Learn more about translating admin extensions in the [Translate Admin Customizations](https://docs.medusajs.com/learn/fundamentals/admin/translations) documentation.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default {}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
".",
|
||||||
|
"../../.medusa/types/plugin-augmentations.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Custom API Routes
|
||||||
|
|
||||||
|
An API Route is a REST API endpoint.
|
||||||
|
|
||||||
|
An API Route is created in a TypeScript or JavaScript file under the `/src/api` directory of your Medusa application. The file’s name must be `route.ts` or `route.js`.
|
||||||
|
|
||||||
|
> Learn more about API Routes in [this documentation](https://docs.medusajs.com/learn/fundamentals/api-routes)
|
||||||
|
|
||||||
|
For example, to create a `GET` API Route at `/store/hello-world`, create the file `src/api/store/hello-world/route.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
res.json({
|
||||||
|
message: "Hello world!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported HTTP methods
|
||||||
|
|
||||||
|
The file based routing supports the following HTTP methods:
|
||||||
|
|
||||||
|
- GET
|
||||||
|
- POST
|
||||||
|
- PUT
|
||||||
|
- PATCH
|
||||||
|
- DELETE
|
||||||
|
- OPTIONS
|
||||||
|
- HEAD
|
||||||
|
|
||||||
|
You can define a handler for each of these methods by exporting a function with the name of the method in the paths `route.ts` file.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
// Handle GET requests
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
// Handle POST requests
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
// Handle PUT requests
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
To create an API route that accepts a path parameter, create a directory within the route's path whose name is of the format `[param]`.
|
||||||
|
|
||||||
|
For example, if you want to define a route that takes a `productId` parameter, you can do so by creating a file called `/api/products/[productId]/route.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type {
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const { productId } = req.params;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `You're looking for product ${productId}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To create an API route that accepts multiple path parameters, create within the file's path multiple directories whose names are of the format `[param]`.
|
||||||
|
|
||||||
|
For example, if you want to define a route that takes both a `productId` and a `variantId` parameter, you can do so by creating a file called `/api/products/[productId]/variants/[variantId]/route.ts`.
|
||||||
|
|
||||||
|
## Using the container
|
||||||
|
|
||||||
|
The Medusa container is available on `req.scope`. Use it to access modules' main services and other registered resources:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type {
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
|
||||||
|
export const GET = async (
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) => {
|
||||||
|
const productModuleService = req.scope.resolve("product")
|
||||||
|
|
||||||
|
const [, count] = await productModuleService.listAndCount()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Middleware
|
||||||
|
|
||||||
|
You can apply middleware to your routes by creating a file called `/api/middlewares.ts`. This file must export a configuration object with what middleware you want to apply to which routes.
|
||||||
|
|
||||||
|
For example, if you want to apply a custom middleware function to the `/store/custom` route, you can do so by adding the following to your `/api/middlewares.ts` file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineMiddlewares } from "@medusajs/framework/http"
|
||||||
|
import type {
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
MedusaNextFunction,
|
||||||
|
} from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
async function logger(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse,
|
||||||
|
next: MedusaNextFunction
|
||||||
|
) {
|
||||||
|
console.log("Request received");
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineMiddlewares({
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
matcher: "/store/custom",
|
||||||
|
middlewares: [logger],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The `matcher` property can be either a string or a regular expression. The `middlewares` property accepts an array of middleware functions.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
res.sendStatus(200);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
res.sendStatus(200);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Custom scheduled jobs
|
||||||
|
|
||||||
|
A scheduled job is a function executed at a specified interval of time in the background of your Medusa application.
|
||||||
|
|
||||||
|
> Learn more about scheduled jobs in [this documentation](https://docs.medusajs.com/learn/fundamentals/scheduled-jobs).
|
||||||
|
|
||||||
|
A scheduled job is created in a TypeScript or JavaScript file under the `src/jobs` directory.
|
||||||
|
|
||||||
|
For example, create the file `src/jobs/hello-world.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
MedusaContainer
|
||||||
|
} from "@medusajs/framework/types";
|
||||||
|
|
||||||
|
export default async function myCustomJob(container: MedusaContainer) {
|
||||||
|
const productService = container.resolve("product")
|
||||||
|
|
||||||
|
const products = await productService.listAndCountProducts();
|
||||||
|
|
||||||
|
// Do something with the products
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
name: "daily-product-report",
|
||||||
|
schedule: "0 0 * * *", // Every day at midnight
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
A scheduled job file must export:
|
||||||
|
|
||||||
|
- The function to be executed whenever it’s time to run the scheduled job.
|
||||||
|
- A configuration object defining the job. It has three properties:
|
||||||
|
- `name`: a unique name for the job.
|
||||||
|
- `schedule`: a [cron expression](https://crontab.guru/).
|
||||||
|
- `numberOfExecutions`: an optional integer, specifying how many times the job will execute before being removed
|
||||||
|
|
||||||
|
The `handler` is a function that accepts one parameter, `container`, which is a `MedusaContainer` instance used to resolve services.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Module Links
|
||||||
|
|
||||||
|
A module link forms an association between two data models of different modules, while maintaining module isolation.
|
||||||
|
|
||||||
|
> Learn more about links in [this documentation](https://docs.medusajs.com/learn/fundamentals/module-links)
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import BlogModule from "../modules/blog"
|
||||||
|
import ProductModule from "@medusajs/medusa/product"
|
||||||
|
import { defineLink } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
export default defineLink(
|
||||||
|
ProductModule.linkable.product,
|
||||||
|
BlogModule.linkable.post
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This defines a link between the Product Module's `product` data model and the Blog Module (custom module)'s `post` data model.
|
||||||
|
|
||||||
|
Then, in the Medusa application, run the following command to sync the links to the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa db:migrate
|
||||||
|
```
|
||||||
@@ -0,0 +1,839 @@
|
|||||||
|
import { MedusaContainer } from "@medusajs/framework";
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
ModuleRegistrationName,
|
||||||
|
Modules,
|
||||||
|
ProductStatus,
|
||||||
|
} from "@medusajs/framework/utils";
|
||||||
|
import {
|
||||||
|
createApiKeysWorkflow,
|
||||||
|
createCollectionsWorkflow,
|
||||||
|
createInventoryLevelsWorkflow,
|
||||||
|
createProductCategoriesWorkflow,
|
||||||
|
createProductOptionsWorkflow,
|
||||||
|
createProductsWorkflow,
|
||||||
|
createRegionsWorkflow,
|
||||||
|
createSalesChannelsWorkflow,
|
||||||
|
createShippingOptionsWorkflow,
|
||||||
|
createShippingProfilesWorkflow,
|
||||||
|
createStockLocationsWorkflow,
|
||||||
|
createStoresWorkflow,
|
||||||
|
createTaxRegionsWorkflow,
|
||||||
|
linkSalesChannelsToApiKeyWorkflow,
|
||||||
|
linkSalesChannelsToStockLocationWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows";
|
||||||
|
|
||||||
|
export default async function initial_data_seed({
|
||||||
|
container,
|
||||||
|
}: {
|
||||||
|
container: MedusaContainer;
|
||||||
|
}) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER);
|
||||||
|
const link = container.resolve(ContainerRegistrationKeys.LINK);
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
const fulfillmentModuleService = container.resolve(
|
||||||
|
ModuleRegistrationName.FULFILLMENT
|
||||||
|
);
|
||||||
|
|
||||||
|
const countries = ["gb", "de", "dk", "se", "fr", "es", "it"];
|
||||||
|
|
||||||
|
logger.info("Seeding store data...");
|
||||||
|
const {
|
||||||
|
result: [defaultSalesChannel],
|
||||||
|
} = await createSalesChannelsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
salesChannelsData: [
|
||||||
|
{
|
||||||
|
name: "Default Sales Channel",
|
||||||
|
description: "Created by Medusa",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
result: [publishableApiKey],
|
||||||
|
} = await createApiKeysWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
api_keys: [
|
||||||
|
{
|
||||||
|
title: "Default Publishable API Key",
|
||||||
|
type: "publishable",
|
||||||
|
created_by: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await linkSalesChannelsToApiKeyWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
id: publishableApiKey.id,
|
||||||
|
add: [defaultSalesChannel.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
result: [store],
|
||||||
|
} = await createStoresWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
stores: [
|
||||||
|
{
|
||||||
|
name: "Default Store",
|
||||||
|
supported_currencies: [
|
||||||
|
{
|
||||||
|
currency_code: "eur",
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency_code: "usd",
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default_sales_channel_id: defaultSalesChannel.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Seeding region data...");
|
||||||
|
const { result: regionResult } = await createRegionsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
regions: [
|
||||||
|
{
|
||||||
|
name: "Europe",
|
||||||
|
currency_code: "eur",
|
||||||
|
countries,
|
||||||
|
payment_providers: ["pp_system_default"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const region = regionResult[0];
|
||||||
|
logger.info("Finished seeding regions.");
|
||||||
|
|
||||||
|
logger.info("Seeding tax regions...");
|
||||||
|
await createTaxRegionsWorkflow(container).run({
|
||||||
|
input: countries.map((country_code) => ({
|
||||||
|
country_code,
|
||||||
|
provider_id: "tp_system",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
logger.info("Finished seeding tax regions.");
|
||||||
|
|
||||||
|
logger.info("Seeding stock location data...");
|
||||||
|
const { result: stockLocationResult } = await createStockLocationsWorkflow(
|
||||||
|
container
|
||||||
|
).run({
|
||||||
|
input: {
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
name: "European Warehouse",
|
||||||
|
address: {
|
||||||
|
city: "Copenhagen",
|
||||||
|
country_code: "DK",
|
||||||
|
address_1: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stockLocation = stockLocationResult[0];
|
||||||
|
|
||||||
|
await link.create({
|
||||||
|
[Modules.STOCK_LOCATION]: {
|
||||||
|
stock_location_id: stockLocation.id,
|
||||||
|
},
|
||||||
|
[Modules.FULFILLMENT]: {
|
||||||
|
fulfillment_provider_id: "manual_manual",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Seeding fulfillment data...");
|
||||||
|
// This is created by a migration script in core.
|
||||||
|
const { data: shippingProfileResult } = await query.graph({
|
||||||
|
entity: "shipping_profile",
|
||||||
|
fields: ["id"],
|
||||||
|
});
|
||||||
|
const shippingProfile = shippingProfileResult[0];
|
||||||
|
|
||||||
|
const fulfillmentSet = await fulfillmentModuleService.createFulfillmentSets({
|
||||||
|
name: "European Warehouse delivery",
|
||||||
|
type: "shipping",
|
||||||
|
service_zones: [
|
||||||
|
{
|
||||||
|
name: "Europe",
|
||||||
|
geo_zones: [
|
||||||
|
{
|
||||||
|
country_code: "gb",
|
||||||
|
type: "country",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
country_code: "de",
|
||||||
|
type: "country",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
country_code: "dk",
|
||||||
|
type: "country",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
country_code: "se",
|
||||||
|
type: "country",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
country_code: "fr",
|
||||||
|
type: "country",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
country_code: "es",
|
||||||
|
type: "country",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
country_code: "it",
|
||||||
|
type: "country",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await link.create({
|
||||||
|
[Modules.STOCK_LOCATION]: {
|
||||||
|
stock_location_id: stockLocation.id,
|
||||||
|
},
|
||||||
|
[Modules.FULFILLMENT]: {
|
||||||
|
fulfillment_set_id: fulfillmentSet.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createShippingOptionsWorkflow(container).run({
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
name: "Standard Shipping",
|
||||||
|
price_type: "flat",
|
||||||
|
provider_id: "manual_manual",
|
||||||
|
service_zone_id: fulfillmentSet.service_zones[0].id,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
type: {
|
||||||
|
label: "Standard",
|
||||||
|
description: "Ship in 2-3 days.",
|
||||||
|
code: "standard",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
currency_code: "usd",
|
||||||
|
amount: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency_code: "eur",
|
||||||
|
amount: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
region_id: region.id,
|
||||||
|
amount: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
attribute: "enabled_in_store",
|
||||||
|
value: "true",
|
||||||
|
operator: "eq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: "is_return",
|
||||||
|
value: "false",
|
||||||
|
operator: "eq",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Express Shipping",
|
||||||
|
price_type: "flat",
|
||||||
|
provider_id: "manual_manual",
|
||||||
|
service_zone_id: fulfillmentSet.service_zones[0].id,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
type: {
|
||||||
|
label: "Express",
|
||||||
|
description: "Ship in 24 hours.",
|
||||||
|
code: "express",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
currency_code: "usd",
|
||||||
|
amount: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency_code: "eur",
|
||||||
|
amount: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
region_id: region.id,
|
||||||
|
amount: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
attribute: "enabled_in_store",
|
||||||
|
value: "true",
|
||||||
|
operator: "eq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute: "is_return",
|
||||||
|
value: "false",
|
||||||
|
operator: "eq",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
logger.info("Finished seeding fulfillment data.");
|
||||||
|
|
||||||
|
await linkSalesChannelsToStockLocationWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
id: stockLocation.id,
|
||||||
|
add: [defaultSalesChannel.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info("Finished seeding stock location data.");
|
||||||
|
|
||||||
|
logger.info("Seeding product data...");
|
||||||
|
|
||||||
|
const { result: categoryResult } = await createProductCategoriesWorkflow(
|
||||||
|
container
|
||||||
|
).run({
|
||||||
|
input: {
|
||||||
|
product_categories: [
|
||||||
|
{
|
||||||
|
name: "Shirts",
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sweatshirts",
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pants",
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Merch",
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: productOptionsResult } = await createProductOptionsWorkflow(
|
||||||
|
container
|
||||||
|
).run({
|
||||||
|
input: {
|
||||||
|
product_options: [
|
||||||
|
{
|
||||||
|
title: "Size",
|
||||||
|
values: ["S", "M", "L", "XL"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Color",
|
||||||
|
values: ["Black", "White"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sizeOption = productOptionsResult.find((o) => o.title === "Size")!;
|
||||||
|
const colorOption = productOptionsResult.find((o) => o.title === "Color")!;
|
||||||
|
|
||||||
|
await createProductsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
title: "Medusa T-Shirt",
|
||||||
|
category_ids: [
|
||||||
|
categoryResult.find((cat) => cat.name === "Shirts")!.id,
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Reimagine the feeling of a classic T-shirt. With our cotton T-shirts, everyday essentials no longer have to be ordinary.",
|
||||||
|
handle: "t-shirt",
|
||||||
|
weight: 400,
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-front.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-back.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-white-front.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-white-back.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
{ id: sizeOption.id },
|
||||||
|
{ id: colorOption.id },
|
||||||
|
],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "S / Black",
|
||||||
|
sku: "SHIRT-S-BLACK",
|
||||||
|
options: {
|
||||||
|
Size: "S",
|
||||||
|
Color: "Black",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "S / White",
|
||||||
|
sku: "SHIRT-S-WHITE",
|
||||||
|
options: {
|
||||||
|
Size: "S",
|
||||||
|
Color: "White",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "M / Black",
|
||||||
|
sku: "SHIRT-M-BLACK",
|
||||||
|
options: {
|
||||||
|
Size: "M",
|
||||||
|
Color: "Black",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "M / White",
|
||||||
|
sku: "SHIRT-M-WHITE",
|
||||||
|
options: {
|
||||||
|
Size: "M",
|
||||||
|
Color: "White",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "L / Black",
|
||||||
|
sku: "SHIRT-L-BLACK",
|
||||||
|
options: {
|
||||||
|
Size: "L",
|
||||||
|
Color: "Black",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "L / White",
|
||||||
|
sku: "SHIRT-L-WHITE",
|
||||||
|
options: {
|
||||||
|
Size: "L",
|
||||||
|
Color: "White",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "XL / Black",
|
||||||
|
sku: "SHIRT-XL-BLACK",
|
||||||
|
options: {
|
||||||
|
Size: "XL",
|
||||||
|
Color: "Black",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "XL / White",
|
||||||
|
sku: "SHIRT-XL-WHITE",
|
||||||
|
options: {
|
||||||
|
Size: "XL",
|
||||||
|
Color: "White",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sales_channels: [
|
||||||
|
{
|
||||||
|
id: defaultSalesChannel.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Medusa Sweatshirt",
|
||||||
|
category_ids: [
|
||||||
|
categoryResult.find((cat) => cat.name === "Sweatshirts")!.id,
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Reimagine the feeling of a classic sweatshirt. With our cotton sweatshirt, everyday essentials no longer have to be ordinary.",
|
||||||
|
handle: "sweatshirt",
|
||||||
|
weight: 400,
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatshirt-vintage-front.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatshirt-vintage-back.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: [{ id: sizeOption.id }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "S",
|
||||||
|
sku: "SWEATSHIRT-S",
|
||||||
|
options: {
|
||||||
|
Size: "S",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "M",
|
||||||
|
sku: "SWEATSHIRT-M",
|
||||||
|
options: {
|
||||||
|
Size: "M",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "L",
|
||||||
|
sku: "SWEATSHIRT-L",
|
||||||
|
options: {
|
||||||
|
Size: "L",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "XL",
|
||||||
|
sku: "SWEATSHIRT-XL",
|
||||||
|
options: {
|
||||||
|
Size: "XL",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sales_channels: [
|
||||||
|
{
|
||||||
|
id: defaultSalesChannel.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Medusa Sweatpants",
|
||||||
|
category_ids: [
|
||||||
|
categoryResult.find((cat) => cat.name === "Pants")!.id,
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.",
|
||||||
|
handle: "sweatpants",
|
||||||
|
weight: 400,
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: [{ id: sizeOption.id }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "S",
|
||||||
|
sku: "SWEATPANTS-S",
|
||||||
|
options: {
|
||||||
|
Size: "S",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "M",
|
||||||
|
sku: "SWEATPANTS-M",
|
||||||
|
options: {
|
||||||
|
Size: "M",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "L",
|
||||||
|
sku: "SWEATPANTS-L",
|
||||||
|
options: {
|
||||||
|
Size: "L",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "XL",
|
||||||
|
sku: "SWEATPANTS-XL",
|
||||||
|
options: {
|
||||||
|
Size: "XL",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sales_channels: [
|
||||||
|
{
|
||||||
|
id: defaultSalesChannel.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Medusa Shorts",
|
||||||
|
category_ids: [
|
||||||
|
categoryResult.find((cat) => cat.name === "Merch")!.id,
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Reimagine the feeling of classic shorts. With our cotton shorts, everyday essentials no longer have to be ordinary.",
|
||||||
|
handle: "shorts",
|
||||||
|
weight: 400,
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/shorts-vintage-front.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/shorts-vintage-back.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: [{ id: sizeOption.id }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "S",
|
||||||
|
sku: "SHORTS-S",
|
||||||
|
options: {
|
||||||
|
Size: "S",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "M",
|
||||||
|
sku: "SHORTS-M",
|
||||||
|
options: {
|
||||||
|
Size: "M",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "L",
|
||||||
|
sku: "SHORTS-L",
|
||||||
|
options: {
|
||||||
|
Size: "L",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "XL",
|
||||||
|
sku: "SHORTS-XL",
|
||||||
|
options: {
|
||||||
|
Size: "XL",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sales_channels: [
|
||||||
|
{
|
||||||
|
id: defaultSalesChannel.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info("Finished seeding product data.");
|
||||||
|
|
||||||
|
logger.info("Seeding inventory levels.");
|
||||||
|
|
||||||
|
const { data: inventoryItems } = await query.graph({
|
||||||
|
entity: "inventory_item",
|
||||||
|
fields: ["id"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await createInventoryLevelsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
inventory_levels: inventoryItems.map((item) => ({
|
||||||
|
location_id: stockLocation.id,
|
||||||
|
stocked_quantity: 1000000,
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Finished seeding inventory levels data.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Custom Module
|
||||||
|
|
||||||
|
A module is a package of reusable functionalities. It can be integrated into your Medusa application without affecting the overall system. You can create a module as part of a plugin.
|
||||||
|
|
||||||
|
> Learn more about modules in [this documentation](https://docs.medusajs.com/learn/fundamentals/modules).
|
||||||
|
|
||||||
|
To create a module:
|
||||||
|
|
||||||
|
## 1. Create a Data Model
|
||||||
|
|
||||||
|
A data model represents a table in the database. You create a data model in a TypeScript or JavaScript file under the `models` directory of a module.
|
||||||
|
|
||||||
|
For example, create the file `src/modules/blog/models/post.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { model } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
const Post = model.define("post", {
|
||||||
|
id: model.id().primaryKey(),
|
||||||
|
title: model.text(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Post
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Create a Service
|
||||||
|
|
||||||
|
A module must define a service. A service is a TypeScript or JavaScript class holding methods related to a business logic or commerce functionality.
|
||||||
|
|
||||||
|
For example, create the file `src/modules/blog/service.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MedusaService } from "@medusajs/framework/utils"
|
||||||
|
import Post from "./models/post"
|
||||||
|
|
||||||
|
class BlogModuleService extends MedusaService({
|
||||||
|
Post,
|
||||||
|
}){
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogModuleService
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Export Module Definition
|
||||||
|
|
||||||
|
A module must have an `index.ts` file in its root directory that exports its definition. The definition specifies the main service of the module.
|
||||||
|
|
||||||
|
For example, create the file `src/modules/blog/index.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import BlogModuleService from "./service"
|
||||||
|
import { Module } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
export const BLOG_MODULE = "blog"
|
||||||
|
|
||||||
|
export default Module(BLOG_MODULE, {
|
||||||
|
service: BlogModuleService,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Add Module to Medusa's Configurations
|
||||||
|
|
||||||
|
To start using the module, add it to `medusa-config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
module.exports = defineConfig({
|
||||||
|
projectConfig: {
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
resolve: "./src/modules/blog",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Generate and Run Migrations
|
||||||
|
|
||||||
|
To generate migrations for your module, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa db:generate blog
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, to run migrations, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Module
|
||||||
|
|
||||||
|
You can use the module in customizations within the Medusa application, such as workflows and API routes.
|
||||||
|
|
||||||
|
For example, to use the module in an API route:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
|
||||||
|
import BlogModuleService from "../../../modules/blog/service"
|
||||||
|
import { BLOG_MODULE } from "../../../modules/blog"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
): Promise<void> {
|
||||||
|
const blogModuleService: BlogModuleService = req.scope.resolve(
|
||||||
|
BLOG_MODULE
|
||||||
|
)
|
||||||
|
|
||||||
|
const posts = await blogModuleService.listPosts()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
posts
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Custom subscribers
|
||||||
|
|
||||||
|
Subscribers handle events emitted in the Medusa application.
|
||||||
|
|
||||||
|
> Learn more about Subscribers in [this documentation](https://docs.medusajs.com/learn/fundamentals/events-and-subscribers).
|
||||||
|
|
||||||
|
The subscriber is created in a TypeScript or JavaScript file under the `src/subscribers` directory.
|
||||||
|
|
||||||
|
For example, create the file `src/subscribers/product-created.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
type SubscriberConfig,
|
||||||
|
} from "@medusajs/framework"
|
||||||
|
|
||||||
|
// subscriber function
|
||||||
|
export default async function productCreateHandler() {
|
||||||
|
console.log("A product was created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscriber config
|
||||||
|
export const config: SubscriberConfig = {
|
||||||
|
event: "product.created",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A subscriber file must export:
|
||||||
|
|
||||||
|
- The subscriber function that is an asynchronous function executed whenever the associated event is triggered.
|
||||||
|
- A configuration object defining the event this subscriber is listening to.
|
||||||
|
|
||||||
|
## Subscriber Parameters
|
||||||
|
|
||||||
|
A subscriber receives an object having the following properties:
|
||||||
|
|
||||||
|
- `event`: An object holding the event's details. It has a `data` property, which is the event's data payload.
|
||||||
|
- `container`: The Medusa container. Use it to resolve modules' main services and other registered resources.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type {
|
||||||
|
SubscriberArgs,
|
||||||
|
SubscriberConfig,
|
||||||
|
} from "@medusajs/framework"
|
||||||
|
|
||||||
|
export default async function productCreateHandler({
|
||||||
|
event: { data },
|
||||||
|
container,
|
||||||
|
}: SubscriberArgs<{ id: string }>) {
|
||||||
|
const productId = data.id
|
||||||
|
|
||||||
|
const productModuleService = container.resolve("product")
|
||||||
|
|
||||||
|
const product = await productModuleService.retrieveProduct(productId)
|
||||||
|
|
||||||
|
console.log(`The product ${product.title} was created`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config: SubscriberConfig = {
|
||||||
|
event: "product.created",
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Custom Workflows
|
||||||
|
|
||||||
|
A workflow is a series of queries and actions that complete a task.
|
||||||
|
|
||||||
|
The workflow is created in a TypeScript or JavaScript file under the `src/workflows` directory.
|
||||||
|
|
||||||
|
> Learn more about workflows in [this documentation](https://docs.medusajs.com/learn/fundamentals/workflows).
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
WorkflowResponse,
|
||||||
|
StepResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
|
||||||
|
const step1 = createStep("step-1", async () => {
|
||||||
|
return new StepResponse(`Hello from step one!`)
|
||||||
|
})
|
||||||
|
|
||||||
|
type WorkflowInput = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const step2 = createStep(
|
||||||
|
"step-2",
|
||||||
|
async ({ name }: WorkflowInput) => {
|
||||||
|
return new StepResponse(`Hello ${name} from step two!`)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkflowOutput = {
|
||||||
|
message1: string
|
||||||
|
message2: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const helloWorldWorkflow = createWorkflow(
|
||||||
|
"hello-world",
|
||||||
|
(input: WorkflowInput) => {
|
||||||
|
const greeting1 = step1()
|
||||||
|
const greeting2 = step2(input)
|
||||||
|
|
||||||
|
return new WorkflowResponse({
|
||||||
|
message1: greeting1,
|
||||||
|
message2: greeting2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default helloWorldWorkflow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execute Workflow
|
||||||
|
|
||||||
|
You can execute the workflow from other resources, such as API routes, scheduled jobs, or subscribers.
|
||||||
|
|
||||||
|
For example, to execute the workflow in an API route:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type {
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework"
|
||||||
|
import myWorkflow from "../../../workflows/hello-world"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const { result } = await myWorkflow(req.scope)
|
||||||
|
.run({
|
||||||
|
input: {
|
||||||
|
name: req.query.name as string,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.send(result)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 930 KiB |
|
After Width: | Height: | Size: 930 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 849 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 996 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 741 KiB |
|
After Width: | Height: | Size: 930 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 708 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 731 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 231 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"skipDefaultLibCheck": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false,
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"outDir": "./.medusa/server",
|
||||||
|
"rootDir": "./",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"strictNullChecks": true
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"swc": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*",
|
||||||
|
".medusa/types/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
".medusa/server",
|
||||||
|
".medusa/admin",
|
||||||
|
".cache",
|
||||||
|
"eslint.config.*",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { defineConfig } from "eslint/config"
|
||||||
|
import medusa from "@medusajs/eslint-plugin"
|
||||||
|
|
||||||
|
export default defineConfig([...medusa.configs.recommended])
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"private": false,
|
||||||
|
"packageManager": "npm@10.9.2",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo dev",
|
||||||
|
"build": "turbo build",
|
||||||
|
"start": "turbo start",
|
||||||
|
"lint": "turbo lint",
|
||||||
|
"test": "turbo test",
|
||||||
|
"backend:seed": "turbo seed --filter=@dtc/backend",
|
||||||
|
"backend:dev": "turbo dev --filter=@dtc/backend",
|
||||||
|
"storefront:dev": "turbo dev --filter=@dtc/storefront"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "19.0.5",
|
||||||
|
"@types/react-dom": "19.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@medusajs/eslint-plugin": "2.16.0",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"jiti": "^2.7.0",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"turbo": "^2.0.14"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"apps/**",
|
||||||
|
"!apps/backend/.medusa/**"
|
||||||
|
],
|
||||||
|
"overrides": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**", ".next/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"seed": {
|
||||||
|
"outputs": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: medusa
|
||||||
|
POSTGRES_PASSWORD: medusa
|
||||||
|
POSTGRES_DB: medusa
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U medusa"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://medusa:medusa@postgres:5432/medusa
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-supersecret}
|
||||||
|
COOKIE_SECRET: ${COOKIE_SECRET:-supersecret}
|
||||||
|
STORE_CORS: ${STORE_CORS:-https://hhonig.de,https://place4bees.com,http://localhost:4321}
|
||||||
|
ADMIN_CORS: ${ADMIN_CORS:-http://localhost:9001}
|
||||||
|
AUTH_CORS: ${AUTH_CORS:-http://localhost:9001,http://localhost:4321}
|
||||||
|
PORT: 9001
|
||||||
|
ports:
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## Development
|
||||||
|
|
||||||
|
When starting the dev server, use background mode:
|
||||||
|
|
||||||
|
```
|
||||||
|
astro dev --background
|
||||||
|
```
|
||||||
|
|
||||||
|
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full documentation: https://docs.astro.build
|
||||||
|
|
||||||
|
Consult these guides before working on related tasks:
|
||||||
|
|
||||||
|
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
|
||||||
|
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
|
||||||
|
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
|
||||||
|
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
|
||||||
|
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
|
||||||
|
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
AGENTS.md
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create astro@latest -- --template minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
server: {
|
||||||
|
// Lets us test host-based behavior (hhonig.de vs. place4bees.com) locally
|
||||||
|
// by sending a custom Host header against the dev server.
|
||||||
|
allowedHosts: ['hhonig.de', 'place4bees.com']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone'
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^11.0.1",
|
||||||
|
"@fontsource-variable/open-sans": "^5.2.7",
|
||||||
|
"@fontsource/bebas-neue": "^5.2.7",
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
|
"astro": "^7.0.5",
|
||||||
|
"tailwindcss": "^4.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 655 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||||
|
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||||
|
<style>
|
||||||
|
path { fill: #000; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { fill: #FFF; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 749 B |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 8.0 KiB |