diff --git a/.github/workflows/training-demo-deploy.yaml b/.github/workflows/training-demo-deploy.yaml new file mode 100644 index 000000000..fb513f47f --- /dev/null +++ b/.github/workflows/training-demo-deploy.yaml @@ -0,0 +1,124 @@ + +name: Training Demo Deploy +on: + push: + branches: + - training-demo + - main + +jobs: + setup: + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + uses: ./.github/composite/monorepo-install + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Build Packages + run: yarn build + + deploy-storefront: + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + uses: ./.github/composite/monorepo-install + with: + lookup-only: 'true' + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Push Medusa Docker + uses: ./.github/composite/docker/storefront + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_REPOSITORY: ghcr.io/lambda-curry/medusa2-starter/storefront + DOCKER_TAG: ${{ github.sha }} + + - name: Helm Update + uses: WyriHaximus/github-action-helm3@v2 + env: + HELM_NAME: storefront + KUBE_NAMESPACE: 360training + DOCKER_TAG: ${{ github.sha }} + DOCKER_REPOSITORY: ghcr.io/lambda-curry/medusa2-starter/storefront + with: + exec: | + helm upgrade ${HELM_NAME} --reuse-values \ + --set image.tag=${DOCKER_TAG} \ + --set image.repository=${DOCKER_REPOSITORY} \ + ./helm-charts/storefront/ -n ${KUBE_NAMESPACE} + kubeconfig: ${{ secrets.EKS_KUBECONFIG }} + + deploy-medusa: + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + uses: ./.github/composite/monorepo-install + with: + lookup-only: 'true' + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Push Medusa Docker + uses: ./.github/composite/docker/medusa + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_REPOSITORY: ghcr.io/lambda-curry/medusa2-starter/medusa + DOCKER_TAG: ${{ github.sha }} + + - name: Helm Update + uses: WyriHaximus/github-action-helm3@v2 + env: + HELM_NAME: medusa + KUBE_NAMESPACE: 360training + DOCKER_TAG: ${{ github.sha }} + DOCKER_REPOSITORY: ghcr.io/lambda-curry/medusa2-starter/medusa + with: + exec: | + helm upgrade ${HELM_NAME} --reuse-values \ + --set image.tag=${DOCKER_TAG} \ + --set image.repository=${DOCKER_REPOSITORY} \ + ./helm-charts/medusa/ -n ${KUBE_NAMESPACE} + kubeconfig: ${{ secrets.EKS_KUBECONFIG }} diff --git a/apps/medusa/medusa-config.ts b/apps/medusa/medusa-config.ts index 5b83b3fda..0f4212891 100644 --- a/apps/medusa/medusa-config.ts +++ b/apps/medusa/medusa-config.ts @@ -76,6 +76,12 @@ module.exports = defineConfig({ cacheModule, eventBusModule, workflowEngineModule, + { + resolve: './src/api/modules/product-review', + }, + { + resolve: './src/api/mcp', + }, ], admin: { backendUrl: process.env.ADMIN_BACKEND_URL, diff --git a/apps/medusa/package.json b/apps/medusa/package.json index d53b21eab..2a722604f 100644 --- a/apps/medusa/package.json +++ b/apps/medusa/package.json @@ -32,36 +32,70 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@ai-sdk/openai": "^1.1.9", "@lambdacurry/medusa-product-reviews": "0.0.6", - "@medusajs/admin-sdk": "2.5.0", - "@medusajs/cli": "2.5.0", - "@medusajs/framework": "2.5.0", - "@medusajs/js-sdk": "2.5.0", - "@medusajs/medusa": "2.5.0", - "@medusajs/types": "2.5.0", + "@medusajs/admin-sdk": "2.6.0", + "@medusajs/cli": "2.6.0", + "@medusajs/framework": "2.6.0", + "@medusajs/icons": "^2.6.0", + "@medusajs/js-sdk": "2.6.0", + "@medusajs/medusa": "2.6.0", + "@medusajs/medusa-js": "^6.1.10", + "@medusajs/types": "2.6.0", "@mikro-orm/core": "6.4.3", "@mikro-orm/knex": "6.4.3", "@mikro-orm/migrations": "6.4.3", "@mikro-orm/postgresql": "6.4.3", + "@modelcontextprotocol/sdk": "1.4.1", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.57.2", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-logs": "^0.57.2", + "@opentelemetry/sdk-metrics": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^1.30.1", + "@vercel/otel": "^1.10.1", + "ai": "^4.1.34", "awilix": "^8.0.1", - "pg": "^8.13.0" + "gpt-tokenizer": "^2.8.1", + "langfuse-vercel": "^3.35.2", + "luxon": "^3.5.0", + "multer": "^1.4.5-lts.1", + "pg": "^8.13.0", + "react-markdown": "^9.0.3", + "remark-gfm": "^4.0.1", + "uuid": "^11.1.0", + "zod": "^3.24.1" }, "devDependencies": { - "@medusajs/test-utils": "2.5.0", + "@medusajs/test-utils": "2.6.0", + "@medusajs/ui": "4.0.4", "@mikro-orm/cli": "6.4.3", "@mikro-orm/core": "6.4.3", "@mikro-orm/migrations": "6.4.3", "@mikro-orm/postgresql": "6.4.3", "@stdlib/number-float64-base-normalize": "0.0.8", "@swc/core": "1.5.7", - "@swc/jest": "^0.2.36", + "@swc/jest": "^0.2.37", + "@tailwindcss/typography": "^0.5.16", + "@types/cors": "^2.8.17", + "@types/event-source-polyfill": "^1.0.5", "@types/express": "^4.17.13", - "@types/jest": "^29.5.12", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.16", + "@types/luxon": "^3", "@types/mime": "1.3.5", + "@types/multer": "^1.4.12", "@types/node": "^17.0.8", + "@types/node-fetch": "^2.6.11", "@types/react": "^18.3.2", + "@types/uuid": "^10.0.0", + "dotenv": "^16.4.7", + "event-source-polyfill": "^1.0.31", "jest": "^29.7.0", + "node-fetch": "^2.7.0", "prop-types": "^15.8.1", + "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.7.3", "yalc": "^1.0.0-pre.53" diff --git a/apps/medusa/src/admin/components/atoms/action-menu.tsx b/apps/medusa/src/admin/components/atoms/action-menu.tsx new file mode 100644 index 000000000..60d157ce8 --- /dev/null +++ b/apps/medusa/src/admin/components/atoms/action-menu.tsx @@ -0,0 +1,90 @@ +import { EllipsisHorizontal } from '@medusajs/icons'; +import { DropdownMenu, IconButton, clx } from '@medusajs/ui'; +import { Link } from 'react-router-dom'; + +export type Action = { + icon: React.ReactNode; + label: string; + disabled?: boolean; +} & ( + | { + to: string; + onClick?: never; + } + | { + onClick: () => void; + to?: never; + } +); + +export type ActionGroup = { + actions: Action[]; +}; + +export type ActionMenuProps = { + groups: ActionGroup[]; +}; + +export const ActionMenu = ({ groups }: ActionMenuProps) => { + return ( + + + + + + + + {groups.map((group, index) => { + if (!group.actions.length) { + return null; + } + + const isLast = index === groups.length - 1; + + return ( + + {group.actions.map((action, index) => { + if (action.onClick) { + return ( + { + e.stopPropagation(); + action.onClick(); + }} + className={clx('[&_svg]:text-ui-fg-subtle flex items-center gap-x-2', { + '[&_svg]:text-ui-fg-disabled': action.disabled, + })} + > + {action.icon} + {action.label} + + ); + } + + return ( +
+ + e.stopPropagation()}> + {action.icon} + {action.label} + + +
+ ); + })} + {!isLast && } +
+ ); + })} +
+
+ ); +}; diff --git a/apps/medusa/src/admin/components/atoms/container.tsx b/apps/medusa/src/admin/components/atoms/container.tsx new file mode 100644 index 000000000..007659fae --- /dev/null +++ b/apps/medusa/src/admin/components/atoms/container.tsx @@ -0,0 +1,7 @@ +import { Container as UiContainer, clx } from '@medusajs/ui'; + +type ContainerProps = React.ComponentProps; + +export const Container = (props: ContainerProps) => { + return ; +}; diff --git a/apps/medusa/src/admin/components/atoms/header.tsx b/apps/medusa/src/admin/components/atoms/header.tsx new file mode 100644 index 000000000..fb65ec517 --- /dev/null +++ b/apps/medusa/src/admin/components/atoms/header.tsx @@ -0,0 +1,57 @@ +import { Button, Heading, Text } from '@medusajs/ui'; +import React from 'react'; +import { Link, type LinkProps } from 'react-router-dom'; +import { ActionMenu, type ActionMenuProps } from './action-menu'; + +export type HeadingProps = { + title: string; + subtitle?: string; + actions?: ( + | { + type: 'button'; + props: React.ComponentProps; + link?: LinkProps; + } + | { + type: 'action-menu'; + props: ActionMenuProps; + } + | { + type: 'custom'; + children: React.ReactNode; + } + )[]; +}; + +export const Header = ({ title, subtitle, actions = [] }: HeadingProps) => { + return ( +
+
+ {title} + {subtitle && ( + + {subtitle} + + )} +
+ {actions.length > 0 && ( +
+ {actions.map((action, index) => ( + + {action.type === 'button' && ( + + )} + {action.type === 'action-menu' && } + {action.type === 'custom' && action.children} + + ))} +
+ )} +
+ ); +}; diff --git a/apps/medusa/src/admin/components/atoms/review-stars.tsx b/apps/medusa/src/admin/components/atoms/review-stars.tsx new file mode 100644 index 000000000..e1070271e --- /dev/null +++ b/apps/medusa/src/admin/components/atoms/review-stars.tsx @@ -0,0 +1,21 @@ +export const ReviewStars = ({ rating }: { rating: number }) => { + return ( +
+ {[...Array(5)].map((_, index) => ( + + + + ))} +
+ ); +}; diff --git a/apps/medusa/src/admin/components/atoms/section-row.tsx b/apps/medusa/src/admin/components/atoms/section-row.tsx new file mode 100644 index 000000000..6910c26d4 --- /dev/null +++ b/apps/medusa/src/admin/components/atoms/section-row.tsx @@ -0,0 +1,33 @@ +import { Text, clx } from '@medusajs/ui'; + +export type SectionRowProps = { + title: string; + value?: React.ReactNode | string | null; + actions?: React.ReactNode; +}; + +export const SectionRow = ({ title, value, actions }: SectionRowProps) => { + const isValueString = typeof value === 'string' || !value; + + return ( +
+ + {title} + + + {isValueString ? ( + + {value ?? '-'} + + ) : ( +
{value}
+ )} + + {actions &&
{actions}
} +
+ ); +}; diff --git a/apps/medusa/src/admin/components/hooks/product-review.ts b/apps/medusa/src/admin/components/hooks/product-review.ts new file mode 100644 index 000000000..bfecc77ec --- /dev/null +++ b/apps/medusa/src/admin/components/hooks/product-review.ts @@ -0,0 +1,44 @@ +import { + AdminCreateProductReviewResponseDTO, + AdminListProductReviewsQuery, + AdminListProductReviewsResponse, + AdminUpdateProductReviewResponseDTO, +} from '@markethaus/types'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { sdk } from '../../sdk'; + +const QUERY_KEY = ['product-reviews']; + +export const useAdminListProductReviews = (query: AdminListProductReviewsQuery) => { + return useQuery({ + queryKey: [...QUERY_KEY, query], + placeholderData: (previousData) => previousData, + queryFn: async () => { + return sdk.admin.productReviews.list(query); + }, + }); +}; + +export const useAdminCreateProductReviewResponseMutation = (reviewId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: AdminCreateProductReviewResponseDTO) => { + return await sdk.admin.productReviews.createResponse(reviewId, body); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }, + }); +}; + +export const useAdminUpdateProductReviewResponseMutation = (reviewId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: AdminUpdateProductReviewResponseDTO) => { + return await sdk.admin.productReviews.updateResponse(reviewId, body); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }, + }); +}; diff --git a/apps/medusa/src/admin/components/molecules/ProductReviewDataTable.tsx b/apps/medusa/src/admin/components/molecules/ProductReviewDataTable.tsx new file mode 100644 index 000000000..90c7ba7c8 --- /dev/null +++ b/apps/medusa/src/admin/components/molecules/ProductReviewDataTable.tsx @@ -0,0 +1,219 @@ +import { AdminListProductReviewsQuery, AdminProductReview } from '@markethaus/types'; +import { ChatBubble, Eye } from '@medusajs/icons'; +import { DataTable, Heading, createDataTableColumnHelper, useDataTable } from '@medusajs/ui'; +import { DateTime } from 'luxon'; +import { useState } from 'react'; +import { useAdminListProductReviews } from '../hooks/product-review'; +import { ProductReviewResponseDrawer } from './ProductReviewResponseDrawer'; +import { ProductReviewDetailsDrawer } from './ProductReviewDetailsDrawer'; +import { Link } from 'react-router-dom'; +import { ReviewStars } from '../atoms/review-stars'; + +const columnHelper = createDataTableColumnHelper(); + +const getColumns = ( + showColumns: string[] | undefined, + setSelectedReview: (review: AdminProductReview) => void, + setSelectedReviewForDetails: (review: AdminProductReview) => void, +) => { + const allColumns = [ + columnHelper.accessor('product', { + id: 'product', + header: 'Product', + enableSorting: false, + cell: ({ row }) => { + const product = row.original.product; + return ( +
+ {product.thumbnail ? ( + {product.title} + ) : ( +
+ )} +
+ + + {product.title} + + +
+
+ ); + }, + }), + columnHelper.accessor('order', { + id: 'order', + header: 'Order', + enableSorting: false, + cell: ({ row }) => { + return ( + + + #{row.original.order.display_id} + + + ); + }, + }), + columnHelper.accessor('created_at', { + id: 'created_at', + header: 'Created At', + enableSorting: false, + cell: ({ row }) => { + return ( +
+ {DateTime.fromISO(row.original.created_at).toFormat('LLL dd yyyy hh:mm a')} +
+ ); + }, + }), + columnHelper.accessor('name', { + id: 'name', + header: 'Customer', + enableSorting: false, + cell: ({ row }) => { + return ( +
+ {row.original.name} +
+ ); + }, + }), + columnHelper.accessor('content', { + id: 'content', + header: 'Review', + enableSorting: false, + cell: ({ row }) => { + const rating = row.original.rating; + const content = row.original.content; + return ( +
+ +

{content}

+
+ ); + }, + }), + columnHelper.accessor('images', { + id: 'images', + header: 'Images', + enableSorting: false, + cell: ({ row }) => { + return
{row.original.images.length}
; + }, + }), + columnHelper.accessor('response', { + id: 'response', + header: 'Response', + enableSorting: false, + cell: ({ row }) => { + const content = row.original.response?.content; + if (!content) { + return ( +
+ No response +
+ ); + } + return ( +
+

{content}

+
+ ); + }, + }), + columnHelper.action({ + // @ts-ignore + id: 'actions', + actions: ({ row }) => [ + { + icon: , + label: 'View details', + onClick: () => { + setSelectedReviewForDetails(row.original); + }, + }, + { + icon: , + label: row.original.response ? 'Edit response' : 'Add response', + onClick: () => { + setSelectedReview(row.original); + }, + }, + ], + }), + ]; + + if (!showColumns) return allColumns; + + return showColumns.map((c) => allColumns.find((column) => column.id === c)).filter((c) => !!c); +}; + +export const ProductReviewDataTable = ({ + defaultQuery, + showColumns, +}: { defaultQuery: AdminListProductReviewsQuery; showColumns?: string[] }) => { + const defaultLimit = defaultQuery.limit ?? 5; + const defaultOffset = defaultQuery.offset ?? 0; + const [selectedReview, setSelectedReview] = useState(null); + const [selectedReviewForDetails, setSelectedReviewForDetails] = useState(null); + + const [query, setQuery] = useState({ ...defaultQuery, limit: defaultLimit, offset: defaultOffset }); + + const { data, isLoading } = useAdminListProductReviews({ ...query, limit: defaultLimit }); + + const table = useDataTable({ + columns: getColumns(showColumns, setSelectedReview, setSelectedReviewForDetails), + data: data?.product_reviews ?? [], + getRowId: (review) => review.id, + rowCount: data?.count ?? 0, + isLoading, + search: { + state: query.q ?? '', + onSearchChange: (q) => { + setQuery({ ...query, q }); + }, + }, + pagination: { + state: { + pageIndex: Math.floor(query.offset / query.limit), + pageSize: query.limit, + }, + onPaginationChange: (pagination) => { + const newQuery = { ...query, offset: pagination.pageIndex * pagination.pageSize, limit: pagination.pageSize }; + setQuery(newQuery); + }, + }, + }); + + return ( + <> + + + Product Reviews + + + + + + + + + {selectedReview && ( + setSelectedReview(open ? selectedReview : null)} + /> + )} + + {selectedReviewForDetails && ( + setSelectedReviewForDetails(open ? selectedReviewForDetails : null)} + /> + )} + + ); +}; diff --git a/apps/medusa/src/admin/components/molecules/ProductReviewDetailsDrawer.tsx b/apps/medusa/src/admin/components/molecules/ProductReviewDetailsDrawer.tsx new file mode 100644 index 000000000..8ef247488 --- /dev/null +++ b/apps/medusa/src/admin/components/molecules/ProductReviewDetailsDrawer.tsx @@ -0,0 +1,103 @@ +import { AdminProductReview } from '@markethaus/types'; +import { Button, Container, Drawer, Text } from '@medusajs/ui'; +import { DateTime } from 'luxon'; +import { Link } from 'react-router-dom'; +import { ReviewStars } from '../atoms/review-stars'; +import { SectionRow } from '../atoms/section-row'; + +export const ProductReviewDetailsDrawer = ({ + review, + open, + setOpen, +}: { + review: AdminProductReview | null; + open: boolean; + setOpen: (open: boolean) => void; +}) => { + if (!review) return null; + + const ProductValue = () => ( +
+ {review.product.thumbnail ? ( + {review.product.title} + ) : ( +
+ )} + + {review.product.title} + +
+ ); + + const OrderValue = () => ( + + #{review.order.display_id} + + ); + + const CreatedAtValue = () => ( + + {DateTime.fromISO(review.created_at).toFormat('LLL dd yyyy hh:mm a')} + + ); + + const CustomerValue = () => ( +
+ {review.name} +
+ ); + + const ReviewContent = () => ( +
+ {review.content} +
+ ); + + const ImagesValue = () => ( +
+ {review.images.map((image, index) => ( + {`Review + ))} +
+ ); + + const ResponseValue = () => ( +
+ {review.response?.content} +
+ ); + + return ( + + + + Review Details + + + + } /> + } /> + } /> + } /> + } /> + } /> + {review.images.length > 0 && } />} + + : No response} /> + {!!review.response?.created_at && ( + {DateTime.fromISO(review.response.created_at).toFormat('LLL dd yyyy hh:mm a')}} + /> + )} + + + + + + + + + + ); +}; diff --git a/apps/medusa/src/admin/components/molecules/ProductReviewResponseDrawer.tsx b/apps/medusa/src/admin/components/molecules/ProductReviewResponseDrawer.tsx new file mode 100644 index 000000000..5b79fc09d --- /dev/null +++ b/apps/medusa/src/admin/components/molecules/ProductReviewResponseDrawer.tsx @@ -0,0 +1,83 @@ +import { AdminProductReview } from '@markethaus/types'; +import { Button, Label, Text, Textarea } from '@medusajs/ui'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; + +import { Drawer } from '@medusajs/ui'; +import { + useAdminCreateProductReviewResponseMutation, + useAdminUpdateProductReviewResponseMutation, +} from '../hooks/product-review'; + +const schema = z.object({ + content: z + .string() + .min(1) + .refine((val) => val.trim().split(/\s+/).length >= 1, { + message: 'Response must contain at least one word', + }), +}); + +type FormValues = z.infer; + +export const ProductReviewResponseDrawer = ({ + review, + open, + setOpen, +}: { review: AdminProductReview | null; open: boolean; setOpen: (open: boolean) => void }) => { + const title = review?.response ? 'Edit Response' : 'Add Response'; + + const { mutate: createResponse } = useAdminCreateProductReviewResponseMutation(review?.id ?? ''); + const { mutate: updateResponse } = useAdminUpdateProductReviewResponseMutation(review?.id ?? ''); + + const form = useForm({ + defaultValues: { + content: review?.response?.content ?? '', + }, + resolver: zodResolver(schema), + }); + + if (!review) return null; + + const onSubmit = async (data: FormValues) => { + if (review.response) { + await updateResponse(data); + } else { + await createResponse(data); + } + setOpen(false); + }; + + return ( + + + + {title} + + + +
+
+ +