Compare commits
5 Commits
main
...
namds/layo
| Author | SHA1 | Date |
|---|---|---|
|
|
f35fd8b09a | |
|
|
f21a4251e3 | |
|
|
a3c683e62f | |
|
|
b330582ad3 | |
|
|
1bace8a023 |
|
|
@ -1,244 +0,0 @@
|
|||
name: Claude Code Issue Handler
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
env:
|
||||
CLAUDE_MAX_TURNS: "30"
|
||||
|
||||
jobs:
|
||||
handle-issue:
|
||||
# Only run on issue comments, not PR comments
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Classify issue by labels
|
||||
id: classify
|
||||
run: |
|
||||
LABELS='${{ toJson(github.event.issue.labels) }}'
|
||||
echo "Raw labels: $LABELS"
|
||||
IS_BUG=$(echo "$LABELS" | jq '[.[].name | ascii_downcase] | any(. == "bug")' 2>/dev/null || echo "false")
|
||||
IS_ENHANCEMENT=$(echo "$LABELS" | jq '[.[].name | ascii_downcase] | any(. == "enhancement")' 2>/dev/null || echo "false")
|
||||
echo "is_bug=$IS_BUG" >> $GITHUB_OUTPUT
|
||||
echo "is_enhancement=$IS_ENHANCEMENT" >> $GITHUB_OUTPUT
|
||||
echo "is_bug=$IS_BUG | is_enhancement=$IS_ENHANCEMENT"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Authenticate Claude Code CLI
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
claude config set apiKey "$ANTHROPIC_API_KEY"
|
||||
claude config set autoUpdaterStatus disabled
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.email "claude@vibentec-it.io"
|
||||
git config --global user.name "Claude Code Bot"
|
||||
git config --global url."https://oauth2:${{ secrets.GITEA_TOKEN }}@gitea.vibentec-it.io/".insteadOf "https://gitea.vibentec-it.io/"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# BUG FLOW: research + fix + PR
|
||||
# ─────────────────────────────────────────────
|
||||
- name: "[Bug] Checkout fix branch"
|
||||
if: steps.classify.outputs.is_bug == 'true'
|
||||
run: |
|
||||
BRANCH_NAME="fix/issue-${{ github.event.issue.number }}"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: "[Bug] Run Claude Code to research and fix"
|
||||
if: steps.classify.outputs.is_bug == 'true'
|
||||
id: claude-bug
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_COMMENT: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
PROMPT=$(printf '%s\n' \
|
||||
"You are a software engineer fixing a bug reported in issue #${ISSUE_NUMBER}: \"${ISSUE_TITLE}\"." \
|
||||
"Issue description: ${ISSUE_BODY}" \
|
||||
"Comment that triggered this action: ${ISSUE_COMMENT}" \
|
||||
"Your tasks:" \
|
||||
"1. Read and understand the codebase relevant to this bug." \
|
||||
"2. Identify the root cause and which files need to change." \
|
||||
"3. Implement minimal, focused fixes — do not refactor unrelated code." \
|
||||
"4. After making code changes, run \"cd homepage && npm run lint\" via the Bash tool" \
|
||||
" and immediately fix every lint error or syntax warning it reports." \
|
||||
"5. Do NOT run Jenkinsfile or any Jenkins-related checks." \
|
||||
"6. Do NOT run git commands yourself; the CI pipeline will commit your changes." \
|
||||
"After completing your investigation, fixes, and lint pass, output a brief plain-text" \
|
||||
"summary of the root cause, what you changed, and the lint result. Keep it under 200 words." \
|
||||
)
|
||||
claude \
|
||||
--allowedTools "Read,Write,Edit,Bash,Glob,Grep,WebSearch,WebFetch" \
|
||||
--max-turns "$CLAUDE_MAX_TURNS" \
|
||||
--output-format text \
|
||||
-p "$PROMPT" > /tmp/claude_bug_output.txt 2>&1 || true
|
||||
echo "--- Claude output ---"
|
||||
cat /tmp/claude_bug_output.txt
|
||||
|
||||
- name: "[Bug] Check for file changes"
|
||||
if: steps.classify.outputs.is_bug == 'true'
|
||||
id: git-status
|
||||
run: |
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "No file changes detected."
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
git diff --stat
|
||||
fi
|
||||
|
||||
- name: "[Bug] Commit changes"
|
||||
if: steps.classify.outputs.is_bug == 'true' && steps.git-status.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git add -A
|
||||
git commit -m "fix: resolve issue #${{ github.event.issue.number }} - ${{ github.event.issue.title }}"
|
||||
|
||||
- name: "[Bug] Push branch"
|
||||
if: steps.classify.outputs.is_bug == 'true' && steps.git-status.outputs.has_changes == 'true'
|
||||
run: git push origin "$BRANCH_NAME"
|
||||
|
||||
- name: "[Bug] Create Pull Request"
|
||||
if: steps.classify.outputs.is_bug == 'true' && steps.git-status.outputs.has_changes == 'true'
|
||||
id: create-pr
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
BASE_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
CLAUDE_SUMMARY=$(cat /tmp/claude_bug_output.txt | tail -n 50)
|
||||
PR_TITLE="fix: resolve issue #${ISSUE_NUMBER} - ${ISSUE_TITLE}"
|
||||
PR_BODY=$(printf '## Summary\n\nThis PR addresses the bug reported in issue #%s.\n\n### Claude Code Analysis\n%s\n\n---\nCloses #%s' \
|
||||
"$ISSUE_NUMBER" "$CLAUDE_SUMMARY" "$ISSUE_NUMBER")
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$PR_TITLE" \
|
||||
--arg body "$PR_BODY" \
|
||||
--arg head "$BRANCH_NAME" \
|
||||
--arg base "main" \
|
||||
'{title: $title, body: $body, head: $head, base: $base}')
|
||||
PR_RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${BASE_URL}/api/v1/repos/${REPO}/pulls" \
|
||||
-d "$PAYLOAD")
|
||||
PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number // empty')
|
||||
PR_URL=$(echo "$PR_RESPONSE" | jq -r '.html_url // empty')
|
||||
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
|
||||
echo "Created PR #$PR_NUMBER: $PR_URL"
|
||||
|
||||
- name: "[Bug] Comment on issue — PR created"
|
||||
if: >-
|
||||
steps.classify.outputs.is_bug == 'true' &&
|
||||
steps.git-status.outputs.has_changes == 'true' &&
|
||||
steps.create-pr.outputs.pr_url != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
BASE_URL: ${{ github.server_url }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
|
||||
PR_URL: ${{ steps.create-pr.outputs.pr_url }}
|
||||
run: |
|
||||
COMMENT=$(printf "I've analyzed this bug and implemented a fix.\n\n**Pull Request:** [PR #%s](%s)\n\nPlease review the changes and merge when ready. If the fix doesn't fully address the issue, feel free to leave a comment and I'll take another look." \
|
||||
"$PR_NUMBER" "$PR_URL")
|
||||
PAYLOAD=$(jq -n --arg body "$COMMENT" '{body: $body}')
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${BASE_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}/comments" \
|
||||
-d "$PAYLOAD"
|
||||
|
||||
- name: "[Bug] Comment on issue — no code changes needed"
|
||||
if: steps.classify.outputs.is_bug == 'true' && steps.git-status.outputs.has_changes == 'false'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
BASE_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
ANALYSIS=$(cat /tmp/claude_bug_output.txt 2>/dev/null || echo "No analysis output available.")
|
||||
COMMENT=$(printf "After researching this issue, **no code changes appear to be necessary** at this time.\n\n### Analysis\n%s\n\nIf you believe something was missed, please provide more details and I'll investigate further." \
|
||||
"$ANALYSIS")
|
||||
PAYLOAD=$(jq -n --arg body "$COMMENT" '{body: $body}')
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${BASE_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}/comments" \
|
||||
-d "$PAYLOAD"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ENHANCEMENT FLOW: research + comment only
|
||||
# ─────────────────────────────────────────────
|
||||
- name: "[Enhancement] Run Claude Code to research"
|
||||
if: steps.classify.outputs.is_enhancement == 'true'
|
||||
id: claude-enhancement
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_COMMENT: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
PROMPT=$(printf '%s\n' \
|
||||
"You are a software architect reviewing a feature idea from issue #${ISSUE_NUMBER}: \"${ISSUE_TITLE}\"." \
|
||||
"Idea description: ${ISSUE_BODY}" \
|
||||
"Comment that triggered this action: ${ISSUE_COMMENT}" \
|
||||
"Your tasks:" \
|
||||
"1. Explore the existing codebase to understand what already exists related to this idea." \
|
||||
"2. Research best practices and possible implementation approaches." \
|
||||
"3. Identify technical challenges, risks, or dependencies." \
|
||||
"4. Provide a structured Markdown response suitable for posting as an issue comment." \
|
||||
"Format your response with these sections:" \
|
||||
"- **Current State** (what already exists)" \
|
||||
"- **Proposed Approach** (recommended implementation path)" \
|
||||
"- **Considerations** (risks, trade-offs, dependencies)" \
|
||||
"- **Next Steps** (actionable recommendations)" \
|
||||
"Keep the total response under 500 words." \
|
||||
)
|
||||
claude \
|
||||
--allowedTools "Read,Glob,Grep,WebSearch,WebFetch" \
|
||||
--max-turns 15 \
|
||||
--output-format text \
|
||||
-p "$PROMPT" > /tmp/claude_enhancement_output.txt 2>&1 || true
|
||||
echo "--- Claude output ---"
|
||||
cat /tmp/claude_enhancement_output.txt
|
||||
|
||||
- name: "[Enhancement] Comment research findings on issue"
|
||||
if: steps.classify.outputs.is_enhancement == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
BASE_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
RESEARCH=$(cat /tmp/claude_enhancement_output.txt 2>/dev/null || echo "Research output unavailable.")
|
||||
COMMENT=$(printf "## Research Findings\n\nI've explored the codebase and researched this idea. Here's what I found:\n\n%s\n\n---\n*This research was performed automatically by Claude Code. Feel free to discuss further in the comments.*" \
|
||||
"$RESEARCH")
|
||||
PAYLOAD=$(jq -n --arg body "$COMMENT" '{body: $body}')
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${BASE_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}/comments" \
|
||||
-d "$PAYLOAD"
|
||||
|
|
@ -1,592 +1,424 @@
|
|||
{
|
||||
"layout": [
|
||||
[
|
||||
{
|
||||
"Header": {
|
||||
"config": {
|
||||
"sticky": true,
|
||||
"variant": "ticker"
|
||||
},
|
||||
"children": [
|
||||
"AnnouncementBanner": {
|
||||
"props": {
|
||||
"label": [
|
||||
{
|
||||
"Banner": {
|
||||
"config": {
|
||||
"variant": "ticker",
|
||||
"className": "h-12 bg-[#009b93] text-[#fff] gap-12",
|
||||
"speed": 24,
|
||||
"items": [
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "NEU: Overnight Oats – Sallys Nussecke 😍",
|
||||
"href": "/"
|
||||
}
|
||||
}
|
||||
"text": "Free shipping on orders over $100",
|
||||
"className": "font-medium"
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Versandkostenfrei ab 45 € 💛",
|
||||
"href": "/"
|
||||
}
|
||||
}
|
||||
"text": ".",
|
||||
"className": ""
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Gratis Geschenk ab 60 € Warenkorbwert 🎁",
|
||||
"href": "/"
|
||||
"text": "Free gift with every purchase",
|
||||
"className": ""
|
||||
},
|
||||
{
|
||||
"text": ".",
|
||||
"className": ""
|
||||
},
|
||||
{
|
||||
"text": "Free returns",
|
||||
"className": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"className": "sticky top-0 z-20 bg-[#009b93] text-white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Nav": {
|
||||
"config": {
|
||||
"className": "h-24 bg-white text-[#003F31] gap-12",
|
||||
"left": [
|
||||
"props": {},
|
||||
"children": [
|
||||
{
|
||||
"Logo": {
|
||||
"config": {
|
||||
"src": "/3bear-logo.png",
|
||||
"alt": "MyShop",
|
||||
"className": "h-[150px] w-[180px]",
|
||||
"objectFit": "contain"
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full ml-16"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Image": {
|
||||
"props": {
|
||||
"src": "https://3bears.de/cdn/shop/files/3Bears_Logo-Schutzzone_negativ_RGB.png?v=1676382997&width=335",
|
||||
"alt": "Medusa Store",
|
||||
"width": 160,
|
||||
"height": 40
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMegaMenu": {
|
||||
"config": {
|
||||
"navLabel": {
|
||||
"text": "Shop",
|
||||
"className": "font-bold text-[1rem] text-[#003F31] flex items-center mr-8 gap-1 hover:text-[#009b93]",
|
||||
"isShowArrow": true
|
||||
}
|
||||
}
|
||||
}
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full gap-10"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Dropdown": {
|
||||
"config": {
|
||||
"trigger": {
|
||||
"text": "Über Uns",
|
||||
"className": "font-bold text-[1rem] text-[#003F31] flex items-center mr-8 gap-1 hover:text-[#009b93]",
|
||||
"NavMenu": {
|
||||
"props": {
|
||||
"label": "Shop",
|
||||
"className": "",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": true
|
||||
},
|
||||
"items": [
|
||||
"menuItems": [
|
||||
{
|
||||
"text": "Unser Unternehmen",
|
||||
"href": "/"
|
||||
"title": {
|
||||
"text": "Categories",
|
||||
"className": "text-[#003F31] text-xl font-bold"
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"label": "Overnight Oats",
|
||||
"href": "/categories/overnight-oats",
|
||||
"className": "text-red-500"
|
||||
},
|
||||
{
|
||||
"text": "Loren ipsum",
|
||||
"href": "/"
|
||||
"label": "Porridge",
|
||||
"href": "/categories/porridge"
|
||||
},
|
||||
{
|
||||
"text": "Not a Link"
|
||||
"label": "Cereals",
|
||||
"href": "/categories/cereals"
|
||||
},
|
||||
{
|
||||
"label": "Granola",
|
||||
"href": "/categories/granola"
|
||||
},
|
||||
{
|
||||
"label": "Glasses & Bowls",
|
||||
"href": "/categories/glasses-bowls"
|
||||
},
|
||||
{
|
||||
"label": "Oat Bars",
|
||||
"href": "/categories/oat-bars"
|
||||
},
|
||||
{
|
||||
"label": "Nut butters",
|
||||
"href": "/categories/nut-butters"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": {
|
||||
"text": "Specials",
|
||||
"className": "text-[#003F31] text-xl font-bold"
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"label": "Advent calendar ✨",
|
||||
"href": "/collections/advent-calendar"
|
||||
},
|
||||
{
|
||||
"label": "Saver subscription",
|
||||
"href": "/collections/saver-subscription"
|
||||
},
|
||||
{
|
||||
"label": "bestseller",
|
||||
"href": "/collections/bestseller"
|
||||
},
|
||||
{
|
||||
"label": "New 🔥",
|
||||
"href": "/collections/new"
|
||||
},
|
||||
{
|
||||
"label": "Bluey Kidsrange",
|
||||
"href": "/collections/bluey-kidsrange"
|
||||
},
|
||||
{
|
||||
"label": "Value sets",
|
||||
"href": "/collections/value-sets"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": {
|
||||
"text": "All products",
|
||||
"className": "text-[#003F31] text-xl font-bold"
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"label": "Shop all",
|
||||
"href": "/store"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Dropdown": {
|
||||
"config": {
|
||||
"trigger": {
|
||||
"text": "Über unsere Produkte",
|
||||
"className": "font-bold text-[1rem] text-[#003F31] flex items-center mr-8 gap-1 hover:text-[#009b93]",
|
||||
"NavMenu": {
|
||||
"props": {
|
||||
"label": "About us",
|
||||
"className": "",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": true
|
||||
},
|
||||
"items": [
|
||||
"menuItems": [
|
||||
{
|
||||
"text": "Unser Unternehmen",
|
||||
"href": "/"
|
||||
"title": {
|
||||
"text": "About us",
|
||||
"className": "text-[#003F31] text-xl font-bold"
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"label": "Check",
|
||||
"href": "/categories/overnight-oats",
|
||||
"className": "text-red-500"
|
||||
},
|
||||
{
|
||||
"text": "Loren ipsum",
|
||||
"href": "/"
|
||||
"label": "Porridge",
|
||||
"href": "/categories/porridge"
|
||||
},
|
||||
{
|
||||
"text": "Not a Link"
|
||||
"label": "Cereals",
|
||||
"href": "/categories/cereals"
|
||||
},
|
||||
{
|
||||
"label": "Granola",
|
||||
"href": "/categories/granola"
|
||||
},
|
||||
{
|
||||
"label": "Glasses & Bowls",
|
||||
"href": "/categories/glasses-bowls"
|
||||
},
|
||||
{
|
||||
"label": "Oat Bars",
|
||||
"href": "/categories/oat-bars"
|
||||
},
|
||||
{
|
||||
"label": "Nut butters",
|
||||
"href": "/categories/nut-butters"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": {
|
||||
"text": "Specials",
|
||||
"className": "text-[#003F31] text-xl font-bold"
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"label": "Advent calendar ✨",
|
||||
"href": "/collections/advent-calendar"
|
||||
},
|
||||
{
|
||||
"label": "Saver subscription",
|
||||
"href": "/collections/saver-subscription"
|
||||
},
|
||||
{
|
||||
"label": "bestseller",
|
||||
"href": "/collections/bestseller"
|
||||
},
|
||||
{
|
||||
"label": "New 🔥",
|
||||
"href": "/collections/new"
|
||||
},
|
||||
{
|
||||
"label": "Bluey Kidsrange",
|
||||
"href": "/collections/bluey-kidsrange"
|
||||
},
|
||||
{
|
||||
"label": "Value sets",
|
||||
"href": "/collections/value-sets"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": {
|
||||
"text": "All products",
|
||||
"className": "text-[#003F31] text-xl font-bold"
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"label": "Shop all",
|
||||
"href": "/store"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Rezepte",
|
||||
"href": "/",
|
||||
"className": "font-bold text-[1rem] text-[#003F31] flex items-center mr-8 gap-1 hover:text-[#009b93]"
|
||||
"DropdownMenus": {
|
||||
"props": {
|
||||
"label": "About our product",
|
||||
"className": "hover:text-ui-fg-base flex items-center gap-2",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": true
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "All Categories",
|
||||
"className": "hover:text-ui-fg-base",
|
||||
"data-testid": "nav-all-categories-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Triff Harry Kane",
|
||||
"href": "/",
|
||||
"className": "font-bold text-[1rem] text-[#003F31] flex items-center gap-1 hover:text-[#009b93]"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"VtCountryCodeSelect": {
|
||||
"config": {
|
||||
"trigger": {
|
||||
"className": "w-auto font-bold text-[13px] text-[#11314E] flex justify-start items-center gap-1 hover:text-[#009b93] bg-transparent shadow-none hover:bg-transparent",
|
||||
"isFlag": true,
|
||||
"isDisplayFullname": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Button": {
|
||||
"config": {
|
||||
"icon": "MagnifyingGlass",
|
||||
"className": "shadow-none"
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "New Arrivals",
|
||||
"className": "hover:text-ui-fg-base",
|
||||
"data-testid": "nav-new-arrivals-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"AccountButton": {
|
||||
"config": {
|
||||
"icon": "User",
|
||||
"className": " flex items-center gap-1 shadow-none"
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "Best Sellers",
|
||||
"className": "hover:text-ui-fg-base",
|
||||
"data-testid": "nav-best-sellers-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/recipe",
|
||||
"label": "Recipes",
|
||||
"className": "hover:text-ui-fg-base",
|
||||
"data-testid": "nav-register-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCartButton": {
|
||||
"config": {
|
||||
"icon": "ShoppingBag",
|
||||
"className": "shadow-none bg-transparent text-black w-[50px]"
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/meet-harry-kane",
|
||||
"label": "Meet Harry Kane",
|
||||
"className": "hover:text-ui-fg-base",
|
||||
"data-testid": "nav-register-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center gap-x-6 h-full justify-end"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenus": {
|
||||
"props": {
|
||||
"label": "Germany (EUR €)",
|
||||
"className": "hover:text-ui-fg-base flex items-center gap-2",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": true
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "All Categories",
|
||||
"className": "hover:text-ui-fg-base",
|
||||
"data-testid": "nav-all-categories-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "New Arrivals",
|
||||
"className": "hover:text-ui-fg-base flex items-center gap-2",
|
||||
"data-testid": "nav-new-arrivals-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "Best Sellers",
|
||||
"className": "hover:text-ui-fg-base flex items-center gap-2",
|
||||
"data-testid": "nav-best-sellers-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"SearchButton": {
|
||||
"props": {
|
||||
"className": "hover:text-ui-fg-base flex items-center gap-2",
|
||||
"data-testid": "nav-account-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"UserButton": {
|
||||
"props": {
|
||||
"className": "hover:text-ui-fg-base flex items-center gap-2",
|
||||
"data-testid": "nav-account-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Suspense": {
|
||||
"props": {
|
||||
"fallback": [
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/cart",
|
||||
"label": "Cart (0)",
|
||||
"className": "hover:text-ui-fg-base flex gap-2",
|
||||
"data-testid": "nav-cart-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"CartButton": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"CartMismatchBanner": {
|
||||
"show": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"FreeShippingPriceNudge": {
|
||||
"variant": "popup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"PropsChildren": {}
|
||||
},
|
||||
{
|
||||
"Footer": {
|
||||
"config": {
|
||||
"className": "content-container border-none flex w-full bg-[#003f31] text-white border justify-between pb-8 pt-14",
|
||||
"leftClassName": "flex-col ml-3",
|
||||
"centerClassName": "",
|
||||
"rightClassName": "flex gap-[10rem] mr-[80px]",
|
||||
"left": [
|
||||
{
|
||||
"VtFooterHero": {
|
||||
"config": {
|
||||
"logoClassName": "h-[100px] w-[200px]",
|
||||
"logoSrc": "/3bear-white-logo.avif",
|
||||
"logoAlt": "3Bear",
|
||||
"title": "Melde dich für unsere Oatnews an 💛",
|
||||
"email": {
|
||||
"emailInputClassName": "w-[300px] ml-8"
|
||||
},
|
||||
"socials": [
|
||||
{
|
||||
"icon": "Twitter",
|
||||
"href": "/",
|
||||
"className": "w-5 h-5 text-white"
|
||||
},
|
||||
{
|
||||
"icon": "Twitter",
|
||||
"href": "/",
|
||||
"className": "w-5 h-5 text-white"
|
||||
},
|
||||
{
|
||||
"icon": "Twitter",
|
||||
"href": "/",
|
||||
"className": "w-5 h-5 text-white"
|
||||
},
|
||||
{
|
||||
"icon": "Twitter",
|
||||
"href": "/",
|
||||
"className": "w-5 h-5 text-white"
|
||||
}
|
||||
],
|
||||
"socialsClassName": "ml-8 mt-10",
|
||||
"className": "",
|
||||
"ctaClassName": "ml-8",
|
||||
"titleClassName": "ml-8 text-white w-full",
|
||||
"descriptionClassName": "ml-8"
|
||||
"copyrightText": "© 2025 MyShop"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [],
|
||||
"right": [
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Information",
|
||||
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-white hover:text-white",
|
||||
"itemClassName": "text-[1rem] font-[400] opacity-70 hover:text-white",
|
||||
"items": [
|
||||
{
|
||||
"text": "Über Uns",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/shoes"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Kundendienst",
|
||||
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-white hover:text-white",
|
||||
"itemClassName": "text-[1rem] font-[400] flex items-center opacity-70 hover:text-white",
|
||||
"items": [
|
||||
{
|
||||
"text": "Twitter",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"text": "Facebook",
|
||||
"href": "/categories/shoes"
|
||||
},
|
||||
{
|
||||
"text": "Pinterest",
|
||||
"href": "/categories/accessories"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Weiteres",
|
||||
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-white",
|
||||
"itemClassName": "text-[1rem] font-[400] w-[150px] opacity-70 hover:text-white",
|
||||
"items": [
|
||||
{
|
||||
"text": "Karriere",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"text": "Unser Team",
|
||||
"href": "/categories/shoes"
|
||||
},
|
||||
{
|
||||
"text": "B2B",
|
||||
"href": "/categories/accessories"
|
||||
},
|
||||
{
|
||||
"text": "Presse",
|
||||
"href": "/categories/accessories"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Footer": {
|
||||
"config": {
|
||||
"className": "content-container bg-[#003f31] w-full text text-[#11314E] flex items-center justify-between",
|
||||
"leftClassName": "w-full",
|
||||
"left": [],
|
||||
"center": [],
|
||||
"right": [
|
||||
{
|
||||
"VtFooterBottom": {
|
||||
"config": {
|
||||
"className": " mr-[80px]",
|
||||
"icons": [
|
||||
"Mastercard",
|
||||
"PayPal",
|
||||
"Visa",
|
||||
"Mastercard",
|
||||
"Mastercard",
|
||||
"Mastercard",
|
||||
"Mastercard"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": {
|
||||
"Home": [
|
||||
{
|
||||
"Hero": {
|
||||
"config": {
|
||||
"className": "h-[35rem]",
|
||||
"ImageDisplayer": {
|
||||
"config": {
|
||||
"duration": 0,
|
||||
"images": [
|
||||
"./banner-hero.webp"
|
||||
],
|
||||
"links": [
|
||||
"/account"
|
||||
]
|
||||
}
|
||||
},
|
||||
"left": [
|
||||
{
|
||||
"VtCtaBanner": {
|
||||
"config": {
|
||||
"className": "left-[120px] top-[80px] relative w-full p-12 flex flex-col items-start justify-center text-left bg-transperant border-none shadow-none",
|
||||
"buttonTextClassName": "inline-flex items-center justify-center bg-[#FCEE56] hover:bg-[#FCEE56]/90 text-[#0D382E] px-8 py-3 rounded-full font-bold text-lg shadow-none border-none",
|
||||
"tagTextClassName": "text-[#0D382E] text-lg font-medium mb-2 bg-transparent",
|
||||
"tagText": "So einfach kann Frühstück sein – mit unseren leckeren Overnight Oats.",
|
||||
"titleText": "breakfast made easy.",
|
||||
"buttonText": "Jetzt entdecken"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [],
|
||||
"right": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "best-seller",
|
||||
"styles": {
|
||||
"container": "content-container py-12 small:py-20",
|
||||
"header": {
|
||||
"container": "ml-16",
|
||||
"title": "text-2xl mb-12",
|
||||
"isShowViewAll": false
|
||||
},
|
||||
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
|
||||
"productCard": {
|
||||
"badgeText": "Bestseller",
|
||||
"card": "relative flex flex-col items-center bg-transparent shadow-none border-none p-0",
|
||||
"badge": {
|
||||
"container": "absolute top-0 left-0 z-[1]",
|
||||
"text": "px-3 py-1 rounded-full bg-[#009b93] text-white text-[12px] font-semibold"
|
||||
},
|
||||
"thumbnail": {
|
||||
"className": "rounded-2xl bg-white h-[320px] object-contain shadow-none",
|
||||
"size": "full"
|
||||
},
|
||||
"content": "flex flex-col items-center justify-start text-center p-0 mt-6",
|
||||
"title": "text-[#003F31] text-[28px] font-semibold",
|
||||
"description": "order-3 text-[#003f31b3]",
|
||||
"price": "mt-2 text-[#0D382E] text-[24px] font-bold order-2 flex gap-2",
|
||||
"reviews": {
|
||||
"container": "mt-3 flex items-center gap-2 order-1",
|
||||
"stars": "flex gap-1",
|
||||
"star": "text-[#F59E0B] text-xl leading-none",
|
||||
"emptyStar": "text-[#F59E0B] text-xl opacity-30 leading-none",
|
||||
"text": "text-[#003F31] text-[14px]",
|
||||
"rating": 4.5,
|
||||
"count": 38
|
||||
},
|
||||
"button": {
|
||||
"addToCart": "hidden",
|
||||
"moreInfo": "hidden",
|
||||
"isShowIcon": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"CartMismatchBanner": {
|
||||
"config": {
|
||||
"show": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCategoryHighlight": {
|
||||
"config": {
|
||||
"title": "Oder lieber stöbern? Hier findest du sicher deine neuen Hafer-Favoriten.",
|
||||
"className": "content-container py-12 small:py-20",
|
||||
"gridClassName": "grid grid-cols-4 gap-6 w-full",
|
||||
"labelClassName": "absolute left-4 bottom-4 text-[#003F31] text-[18px] font-semibold",
|
||||
"items": [
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"href": "/categories/overnight-oats",
|
||||
"label": "Overnight Oats",
|
||||
"className": "bg-[#CFECD9] col-start-1 col-end-3 row-start-1 row-end-3",
|
||||
"imageClassName": "object-contain"
|
||||
},
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"href": "/categories/porridge",
|
||||
"label": "Porridge",
|
||||
"className": "bg-[#F9E0B0]",
|
||||
"imageClassName": "object-contain"
|
||||
},
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"href": "/categories/cereals",
|
||||
"label": "Cereals",
|
||||
"className": "bg-[#F59E0B]",
|
||||
"imageClassName": "object-contain"
|
||||
},
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"href": "/categories/granola",
|
||||
"label": "Granola",
|
||||
"className": "bg-[#A7D8F0]",
|
||||
"imageClassName": "object-contain"
|
||||
},
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"href": "/categories/oat-bars",
|
||||
"label": "Oat Bars",
|
||||
"className": "bg-[#EED7F2]",
|
||||
"imageClassName": "object-contain"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtBrand": {
|
||||
"config": {
|
||||
"className": "w-full py-12 bg-[#CFECD9]",
|
||||
"innerClassName": "content-container flex flex-col items-center",
|
||||
"title": "Trusted By",
|
||||
"titleClassName": "text-[#003F31] text-[20px] font-semibold mb-8",
|
||||
"brandsClassName": "flex w-full items-center justify-between gap-12",
|
||||
"items": [
|
||||
{
|
||||
"label": "Men'sHealth",
|
||||
"containerClassName": "",
|
||||
"className": "text-[#003F31] text-[36px] font-semibold italic"
|
||||
},
|
||||
{
|
||||
"label": "GQ",
|
||||
"containerClassName": "",
|
||||
"className": "text-[#003F31] text-[44px] font-black tracking-tight"
|
||||
},
|
||||
{
|
||||
"label": "BIRCHBOX",
|
||||
"containerClassName": "",
|
||||
"className": "text-[#003F31] text-[36px] font-semibold tracking-[0.2em]"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtFeedbackCard": {
|
||||
"config": {
|
||||
"className": "content-container py-16 bg-[#CFECD9] mt-16",
|
||||
"title": "Der Hafer-Hype ist real. Finden nicht nur 100.000+ zufriedene 3Bears Fans.",
|
||||
"gridClassName": "grid grid-cols-1 small:grid-cols-2 xl:grid-cols-4 gap-6",
|
||||
"cardClassName": "rounded-2xl overflow-hidden",
|
||||
"imageClassName": "w-full h-[260px] object-cover",
|
||||
"contentClassName": "p-6",
|
||||
"nameClassName": "text-[#003F31] text-[20px] font-bold",
|
||||
"subtitleClassName": "mt-1 text-[#003f31b3] text-[14px]",
|
||||
"quoteClassName": "mt-4 text-[#003F31] text-[16px]",
|
||||
"ctaClassName": "mt-6 inline-flex items-center justify-center bg-[#FCEE56] text-[#0D382E] px-6 py-2 rounded-full font-bold",
|
||||
"items": [
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"name": "Harry Kane",
|
||||
"subtitle": "Profißballer, Kapitän engl. Nationalmannschaft, Stürmer FC Bayern",
|
||||
"quote": "Als Sportler ist das Frühstück die wichtigste Mahlzeit für mich, und natürlich achte ich sehr darauf, was ich esse. Als ich 3Bears entdeckt habe, hat mich nachhaltig beeindruckt, dass die Haferflocken auf ein neues Level heben.",
|
||||
"cta": {
|
||||
"label": "Mehr erfahren",
|
||||
"href": "/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"name": "Sally Özcan",
|
||||
"subtitle": "Foodcreatorin & Unternehmerin",
|
||||
"quote": "Ich liebe Frühstück, weil es für mich der Start in einen guten Tag ist, mit meiner Familie, meinem Team oder unterwegs. Ich mag Produkte, die einfach einen Sinn ergeben, natürlich, lecker und ohne Schnickschnack. Genau das ist 3Bears für mich."
|
||||
},
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"name": "Sarah Harrison",
|
||||
"subtitle": "Unternehmerin & Influencerin",
|
||||
"quote": "3Bears teilt meine Leidenschaft für hochwertige Lebensmittel, die nicht nur mega lecker, sondern auch vollwertig sind. Deswegen war ich so begeistert von der Idee, gemeinsam ein Granola zu entwickeln.",
|
||||
"cta": {
|
||||
"label": "Mehr erfahren",
|
||||
"href": "/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"name": "Hendrik Pfeiffer",
|
||||
"subtitle": "Profi-Läufer & German Champion",
|
||||
"quote": "Als Profisportler spielt meine bewusste Ernährung eine absolute Schlüsselrolle, um vorne mitmischen zu können. Die Produkte von 3Bears passen dabei wie die Faust aufs Auge.",
|
||||
"cta": {
|
||||
"label": "Mehr erfahren",
|
||||
"href": "/"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtSubcription": {
|
||||
"config": {
|
||||
"className": "content-container py-12 flex justify-center",
|
||||
"cardClassName": "rounded-2xl overflow-hidden bg-[#CFECD9] w-[800px] p-10",
|
||||
"title": "10% für dich!",
|
||||
"titleClassName": "text-[#003F31] text-[28px] font-bold text-center",
|
||||
"highlightClassName": "text-[#003F31] font-bold",
|
||||
"description": true,
|
||||
"formClassName": "mt-8 flex flex-col items-center gap-4",
|
||||
"descriptionClassName": "text-[#003F31] text-[16px] text-center",
|
||||
"fieldsClassName": "grid grid-cols-1 small:grid-cols-2 gap-4 w-full",
|
||||
"descriptionPrefix": "Melde dich jetzt zum 3Bears Newsletter an und sichere dir",
|
||||
"descriptionHighlight": "10% Rabatt auf deinen nächsten Einkauf!",
|
||||
"subtextClassName": "text-[#003F31] text-[16px] text-center",
|
||||
"descriptionSuffix": "",
|
||||
"subtext": "Deinen Rabattcode bekommst du von uns per Mail.",
|
||||
"firstName": { "placeholder": "Vorname" },
|
||||
"email": { "placeholder": "E-Mail-Adresse" },
|
||||
"policyLabel": "Ich habe die DSGVO gelesen und akzeptiere sie.",
|
||||
"cta": { "label": "Anmelden", "className": "bg-[#FCEE56] text-[#0D382E] px-6 py-2 rounded-full w-fit font-bold" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"FreeShippingPriceNudge": {
|
||||
"config": {
|
||||
"variant": "popup"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,571 +1,286 @@
|
|||
{
|
||||
"layout": [
|
||||
[
|
||||
{
|
||||
"Header": {
|
||||
"config": {
|
||||
"sticky": true
|
||||
},
|
||||
"children": [
|
||||
"AnnouncementBanner": {
|
||||
"props": {
|
||||
"label": [
|
||||
{
|
||||
"Banner": {
|
||||
"config": {
|
||||
"variant": "nav",
|
||||
"className": "h-12 bg-[#e6c981] text-black gap-12",
|
||||
"center": [
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "BLACK FRIDAY",
|
||||
"href": "/",
|
||||
"className": "font-bold text-[1rem] flex items-center gap-1"
|
||||
}
|
||||
}
|
||||
"text": "BLACK FRIDAY PRE-SALE",
|
||||
"className": " #b31b1f font-bold"
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Up to 55% off Bundles",
|
||||
"href": "/",
|
||||
"className": "text-[1rem] flex items-center gap-1"
|
||||
}
|
||||
}
|
||||
"text": "FREE MYSTERY GIFT on $70",
|
||||
"className": ""
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "SHOP NOW",
|
||||
"href": "/",
|
||||
"className": "font-bold text-[1rem] flex items-center gap-1 underline"
|
||||
"text": "SHOP NOW",
|
||||
"className": "font-bold text-sm underline"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"className": "bg-[#b31b1f] text-white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Nav": {
|
||||
"config": {
|
||||
"className": "h-24 bg-[#1f3521] text-white gap-12",
|
||||
"left": [
|
||||
"props": {
|
||||
"className": "bg-[#1f3621] text-white"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Logo": {
|
||||
"config": {
|
||||
"src": "/drsquatch-logo.webp",
|
||||
"alt": "MyShop",
|
||||
"className": "h-auto w-40 mr-24",
|
||||
"objectFit": "contain"
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Image": {
|
||||
"props": {
|
||||
"src": "https://www.drsquatch.com/cdn/shop/files/2drsq_horiz_logo_white_3_1.svg?v=1743539246",
|
||||
"alt": "Medusa Store",
|
||||
"width": 120,
|
||||
"height": 40,
|
||||
"className": "cursor-pointer ml-8"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full gap-16 font-bold"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenus": {
|
||||
"props": {
|
||||
"label": "SUBCRIBE",
|
||||
"href": "/",
|
||||
"className": "font-bold text-[1rem] text-white flex items-center mr-8 gap-1 hover:underline hover:text-white"
|
||||
"className": "hover:underline flex items-center gap-2 ml-16",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": false
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "All Categories",
|
||||
"className": "",
|
||||
"data-testid": "nav-all-categories-link"
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "New Arrivals",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-new-arrivals-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "Best Sellers",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-best-sellers-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenus": {
|
||||
"props": {
|
||||
"label": "REWARD",
|
||||
"href": "/",
|
||||
"className": "font-bold text-[1rem] text-white flex items-center mr-8 gap-1 hover:underline hover:text-white"
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-categories-link"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "All Categories",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-all-categories-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMegaMenu": {
|
||||
"config": {
|
||||
"navLabel": {
|
||||
"text": "SHOP",
|
||||
"className": "font-bold text-[1rem] flex items-center mr-8 gap-1 hover:bg-transparent hover:underline hover:text-white"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMegaMenu": {
|
||||
"config": {
|
||||
"navLabel": {
|
||||
"text": "OUR STORY",
|
||||
"className": "font-bold text-[1rem] text-white flex items-center hover:bg-transparent hover:underline hover:text-white"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"VtCountryCodeSelect": {
|
||||
"config": {
|
||||
"trigger": {
|
||||
"className": "w-auto font-bold text-[13px] text-white flex justify-start items-center gap-1 hover:text-[#009b93] bg-transparent shadow-none hover:bg-transparent",
|
||||
"isFlag": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Button": {
|
||||
"config": {
|
||||
"icon": "User",
|
||||
"className": "shadow-none bg-transparent text-white hover:text-black"
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "New Arrivals",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-new-arrivals-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCartButton": {
|
||||
"config": {
|
||||
"icon": "ShoppingCart",
|
||||
"className": "shadow-none bg-transparent text-black w-[50px]"
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "Best Sellers",
|
||||
"className": "hover:underline",
|
||||
"data-testid": "nav-best-sellers-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenus": {
|
||||
"props": {
|
||||
"label": "SHOP",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": false
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "All Categories",
|
||||
"className": "hover:underline",
|
||||
"data-testid": "nav-all-categories-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "New Arrivals",
|
||||
"className": "hover:underline",
|
||||
"data-testid": "nav-new-arrivals-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "Best Sellers",
|
||||
"className": "hover:underline",
|
||||
"data-testid": "nav-best-sellers-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenus": {
|
||||
"props": {
|
||||
"label": "OUR STORY",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": false
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "OUR STORY",
|
||||
"className": "hover:underline",
|
||||
"data-testid": "nav-all-categories-link",
|
||||
"isShowArrow": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "New Arrivals",
|
||||
"className": "hover:underline",
|
||||
"data-testid": "nav-new-arrivals-link",
|
||||
"isShowArrow": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "Best Sellers",
|
||||
"className": "hover:underline",
|
||||
"data-testid": "nav-best-sellers-link",
|
||||
"isShowArrow": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center gap-x-6 h-full justify-end"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"SearchButton": {
|
||||
"props": {
|
||||
"className": "hover:underline hover:text-black flex items-center gap-2 text-white",
|
||||
"data-testid": "nav-account-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"UserButton": {
|
||||
"props": {
|
||||
"className": "hover:underline hover:text-black flex items-center gap-2 text-white",
|
||||
"data-testid": "nav-account-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Suspense": {
|
||||
"props": {
|
||||
"fallback": [
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/cart",
|
||||
"label": "Cart (0)",
|
||||
"className": "hover:underline flex gap-2",
|
||||
"data-testid": "nav-cart-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"CartButton": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"CartMismatchBanner": {
|
||||
"show": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"FreeShippingPriceNudge": {
|
||||
"variant": "popup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"PropsChildren": {}
|
||||
},
|
||||
{
|
||||
"Footer": {
|
||||
"config": {
|
||||
"className": "content-container border-none bg-[#1f3621] flex w-full border justify-between pb-8 gap-10 pt-16 px-[120px]",
|
||||
"leftClassName": "flex ml-6 gap-x-24",
|
||||
"centerClassName": "flex",
|
||||
"rightClassName": "flex",
|
||||
"left": [
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Help",
|
||||
"className": "flex flex-col gap-y-2 text-[16px] font-semibold text-white gap-8",
|
||||
"itemClassName": "text-[14px] font-[400] mt-3",
|
||||
"items": [
|
||||
{
|
||||
"text": "FAQ",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"text": "Track my order",
|
||||
"href": "/categories/shoes"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
}
|
||||
]
|
||||
"copyrightText": "© 2025 MyShop"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Shop",
|
||||
"className": "flex flex-col gap-y-2 text-[16px] font-semibold text-white",
|
||||
"itemClassName": "text-[14px] font-[400] flex items-center mt-3",
|
||||
"items": [
|
||||
{
|
||||
"text": "Twitter",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"text": "Facebook",
|
||||
"href": "/categories/shoes"
|
||||
},
|
||||
{
|
||||
"text": "Pinterest",
|
||||
"href": "/categories/accessories"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
},
|
||||
{
|
||||
"text": "Placeholder",
|
||||
"href": "/categories/accessories"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Info",
|
||||
"className": "flex flex-col gap-y-2 text-[16px] font-semibold text-white",
|
||||
"itemClassName": "text-[14px] font-[400] w-[200px] mt-3",
|
||||
"items": [
|
||||
{
|
||||
"text": "The Squatch Difference",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"text": "Why Natural Products",
|
||||
"href": "/categories/shoes"
|
||||
},
|
||||
{
|
||||
"text": "No Harmful Ingredients",
|
||||
"href": "/categories/accessories"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [
|
||||
{
|
||||
"Logo": {
|
||||
"config": {
|
||||
"src": "/b-corp-logo.webp",
|
||||
"alt": "MyShop",
|
||||
"className": "h-auto w-[90px] mr-24",
|
||||
"objectFit": "contain"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"VtFooterSignUp": {
|
||||
"config": {
|
||||
"title": "Don't miss out on hot deals! Sign up and get up to 30% off.",
|
||||
"className": "max-w-[760px]",
|
||||
"titleClassName": "text-white text-[18px]",
|
||||
"formClassName": "mt-2 w-full",
|
||||
"inputClassName": "w-full",
|
||||
"buttonClassName": "bg-[#C4622C] w-[90px]",
|
||||
"socialsClassName": "mt-4 gap-8",
|
||||
"socials": [
|
||||
{
|
||||
"icon": "Twitter",
|
||||
"href": "/",
|
||||
"className": "w-5 h-5 text-white"
|
||||
},
|
||||
{
|
||||
"icon": "Twitter",
|
||||
"href": "/",
|
||||
"className": "w-5 h-5 text-white"
|
||||
},
|
||||
{
|
||||
"icon": "Twitter",
|
||||
"href": "/",
|
||||
"className": "w-5 h-5 text-white"
|
||||
},
|
||||
{
|
||||
"icon": "Twitter",
|
||||
"href": "/",
|
||||
"className": "w-5 h-5 text-white"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtFooterBottom": {
|
||||
"config": {
|
||||
"className": "text-white text-[14px] font-[400] bg-[#1f3621] flex items-center justify-center",
|
||||
"text": "©2025 Vibentec IT. All rights reserved",
|
||||
"linksClassName": "flex items-center text-orange-500 mt-2 pl-2",
|
||||
"links": [
|
||||
{
|
||||
"label": "Privacy Policy",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"label": "Terms of Service",
|
||||
"href": "/categories/shoes"
|
||||
},
|
||||
{
|
||||
"label": "Cookie Policy",
|
||||
"href": "/categories/accessories"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": {
|
||||
"Home": [
|
||||
{
|
||||
"Hero": {
|
||||
"config": {
|
||||
"className": "h-[35rem]",
|
||||
"ImageDisplayer": {
|
||||
"config": {
|
||||
"duration": 0,
|
||||
"images": [
|
||||
"./drsquatch-banner.jpg"
|
||||
],
|
||||
"links": [
|
||||
"/account"
|
||||
]
|
||||
}
|
||||
},
|
||||
"left": [
|
||||
{
|
||||
"VtCtaBanner": {
|
||||
"config": {
|
||||
"variant": "default",
|
||||
"className": "left-[120px] top-[120px] bg-transparent border-none text-white text-center",
|
||||
"tagTextClassName": "text-white bg-transparent",
|
||||
"titleTextClassName": "text-white font-bold leading-normal text-[30px]",
|
||||
"descriptionTextClassName": "text-white text-[1rem] w-[300px] ml-[6.6rem]",
|
||||
"buttonTextClassName": "text-white bg-orange-500 w-[300px]",
|
||||
"tagText": "ALL NEW!",
|
||||
"titleText": "LUMBERJACK LODGE",
|
||||
"descriptionText": "Step into the lodge with the warm, sweet scent of maple and vanilla.",
|
||||
"buttonText": "SHOP NOW"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [],
|
||||
"right": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "drsquatch-best-seller",
|
||||
"styles": {
|
||||
"container": "content-container py-12 px-[100px] small:py-24",
|
||||
"header": {
|
||||
"container": "flex justify-center mb-8",
|
||||
"title": "text-[28px] font-bold text-[#1f3521]",
|
||||
"isShowViewAll": false
|
||||
},
|
||||
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
|
||||
"productCard": {
|
||||
"card": "shadow-none border-none",
|
||||
"className": "relative overflow-hidden rounded-2xl bg-white shadow-elevation-card-rest h-full flex flex-col",
|
||||
"badgeText": "LIMITED EDITION",
|
||||
"badge": {
|
||||
"container": "absolute z-[1] top-0 left-0 pt-4",
|
||||
"text": "uppercase px-4 py-2 bg-[#3B6F47] text-white"
|
||||
},
|
||||
"thumbnail": {
|
||||
"className": "rounded-none h-[300px] shadow-none",
|
||||
"size": "full"
|
||||
},
|
||||
"content": " flex flex-col flex-1",
|
||||
"title": "mt-2 text-[#1f3521] text-[22px] font-bold",
|
||||
"price": "mt-2 text-[#3B6F47] font-bold text-[20px] flex gap-3 flex-row-reverse justify-end",
|
||||
"description": "mt-2",
|
||||
"reviews": {
|
||||
"container": "mt-3 flex items-center gap-2",
|
||||
"stars": "flex gap-1",
|
||||
"star": "text-[#C4622C] text-xl leading-none",
|
||||
"emptyStar": "text-[#C4622C] text-xl opacity-30 leading-none",
|
||||
"text": "text-[#1f3521]",
|
||||
"rating": 3.6,
|
||||
"count": 59
|
||||
},
|
||||
"button": {
|
||||
"addToCart": "mt-6 w-full bg-[#C4622C] hover:bg-[#C4622C]/90 shadow-none text-white rounded-none h-fit font-bold",
|
||||
"moreInfo": "hidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCategoryHighlight": {
|
||||
"config": {
|
||||
"title": "Oder lieber stöbern? Hier findest du sicher deine neuen Hafer-Favoriten.",
|
||||
"className": "content-container py-12 small:py-20",
|
||||
"gridClassName": "grid grid-cols-2 gap-6 w-full",
|
||||
"labelClassName": "absolute left-4 bottom-4 text-[#003F31] text-[18px] font-semibold",
|
||||
"items": [
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"href": "/categories/overnight-oats",
|
||||
"label": "Overnight Oats",
|
||||
"className": "bg-[#CFECD9] h-[250px]",
|
||||
"imageClassName": "object-contain"
|
||||
},
|
||||
{
|
||||
"headingLabel": "The Squatch Difference",
|
||||
"descriptionLabel": "Learn why men everywhere are loving Dr. Squatch.",
|
||||
"buttonLabel": "Learn more",
|
||||
"className": "flex-col bg-[#F9E0B0] p-6 justify-center",
|
||||
"headingClassName": "text-[#003F31] text-[28px] font-semibold",
|
||||
"descriptionClassName": "text-[#003f31b3]",
|
||||
"buttonClassName": "mt-4 text-[#003F31] text-[18px] font-semibold bg-orange-500 py-2 px-16 rounded text-white"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtBrand": {
|
||||
"config": {
|
||||
"className": "w-full py-12 bg-[#CFECD9]",
|
||||
"innerClassName": "content-container flex flex-col items-center",
|
||||
"title": "",
|
||||
"titleClassName": "text-[#1f3521] text-[20px] font-bold mb-8",
|
||||
"brandsClassName": "flex w-full items-center justify-between gap-12",
|
||||
"items": [
|
||||
{
|
||||
"imageSrc": "/brand-logo.png",
|
||||
"alt": "Men's Health",
|
||||
"containerClassName": "",
|
||||
"imageClassName": "h-[40px] object-contain"
|
||||
},
|
||||
{
|
||||
"imageSrc": "/brand-logo.png",
|
||||
"alt": "GQ",
|
||||
"containerClassName": "",
|
||||
"imageClassName": "h-[40px] object-contain"
|
||||
},
|
||||
{
|
||||
"imageSrc": "/brand-logo.png",
|
||||
"alt": "Birchbox",
|
||||
"containerClassName": "",
|
||||
"imageClassName": "h-[40px] object-contain"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtSubcription": {
|
||||
"config": {
|
||||
"className": "w-full",
|
||||
"cardClassName": "overflow-hidden bg-[#F3EDE3] p-10",
|
||||
"title": "SUBSCRIBE & SAVE",
|
||||
"titleClassName": "text-[#003F31] text-[28px] font-bold text-center",
|
||||
"description": true,
|
||||
"policyLabel": "Ich habe die DSGVO gelesen und akzeptiere sie.",
|
||||
"benefits": [
|
||||
{
|
||||
"icon": "🗓",
|
||||
"title": "Ships Every 3 Months",
|
||||
"description": "Customize your picks and scents, upgrade anytime, or hit snooze if you want. You're in control."
|
||||
},
|
||||
{
|
||||
"icon": "🚚",
|
||||
"title": "Free Delivery",
|
||||
"description": "Subscribe once and relax. All your shipping expenses are covered by Squatch."
|
||||
},
|
||||
{
|
||||
"icon": "⭐",
|
||||
"title": "Exclusive Benefits",
|
||||
"description": "Gain exclusive, subscriber-only benefits. Enjoy early access to new products and limited edition releases!"
|
||||
}
|
||||
],
|
||||
"formClassName": "flex justify-center",
|
||||
"cta": {
|
||||
"label": "SUBSCRIBE & SAVE",
|
||||
"className": "w-fit mt-12 px-[30px] h-[56px] rounded-full bg-orange-500 text-white font-bold"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtFeedback": {
|
||||
"config": {
|
||||
"title": "100,000+ Reviews From Squatchers",
|
||||
"className": "content-container py-16",
|
||||
"titleClassName": "text-[#1f3521] text-[28px] font-bold text-center mb-10",
|
||||
"duration": 5,
|
||||
"options": {
|
||||
"loop": true
|
||||
},
|
||||
"itemClassName": "min-w-full px-6",
|
||||
"starsClassName": "text-[#C4622C] text-xl leading-none",
|
||||
"reviewTitleClassName": "text-[#1f3521] font-bold",
|
||||
"reviewTextClassName": "text-[#1f3521]",
|
||||
"authorClassName": "italic text-[#1f3521]",
|
||||
"controls": "mt-6 flex items-center justify-center gap-4",
|
||||
"items": [
|
||||
{
|
||||
"rating": 5,
|
||||
"title": "Ah-freaking-amazing!",
|
||||
"text": "So I just had my first shower with Dr. Squatch Cool Fresh Aloe. Holy sh*t this stuff is Ah-freaking-amazing! Talk about a life hack!",
|
||||
"author": "Stephen B."
|
||||
},
|
||||
{
|
||||
"rating": 5,
|
||||
"title": "Best damn soap ever…period.",
|
||||
"text": "Best Damn Soap I EVER bought! Super smooth on the skin, smells awesome, makes you feel good showering, and yes…the wife approves.",
|
||||
"author": "Chris H."
|
||||
},
|
||||
{
|
||||
"rating": 5,
|
||||
"title": "Hilarious…products awesome too",
|
||||
"text": "Ok…the Dr. Squatch commercials are just freakin hilarious…plus the products are awesome too! So yes, buy it now and subscribe to it!",
|
||||
"author": "Mike C."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"CartMismatchBanner": {
|
||||
"config": {
|
||||
"show": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"FreeShippingPriceNudge": {
|
||||
"config": {
|
||||
"variant": "popup"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"Product": [
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "drsquatch-best-seller"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"StorePage": [
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "drsquatch-best-seller"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,264 +0,0 @@
|
|||
{
|
||||
"layout": [
|
||||
{
|
||||
"Header": {
|
||||
"config": {
|
||||
"sticky": true
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Banner": {
|
||||
"config": {
|
||||
"variant": "nav",
|
||||
"className": "h-12 bg-[#E6EFFC] text-[#11314E] gap-12 pl-16",
|
||||
"left": [
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Über Uns",
|
||||
"href": "/",
|
||||
"className": "text-[13px] flex items-center gap-1 cursor-pointer"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Kontaktieren Uns",
|
||||
"href": "/",
|
||||
"className": "text-[13px] flex items-center gap-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Einsparung durch Digitalisierung in der Arztpraxis",
|
||||
"href": "/",
|
||||
"className": "text-[13px] flex items-center gap-1 "
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Button": {
|
||||
"config": {
|
||||
"label": "Mehr Info",
|
||||
"href": "/",
|
||||
"className": "text-[13px] flex items-center bg-[#112638] gap-1 "
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"Dropdown": {
|
||||
"config": {
|
||||
"trigger": {
|
||||
"text": "EURO",
|
||||
"className": "font-bold text-[13px] text-[#11314E] flex items-center gap-1 hover:text-[#009b93]",
|
||||
"isShowArrow": true
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Flag_of_Germany.svg/1200px-Flag_of_Germany.svg.png",
|
||||
"text": "EURO",
|
||||
"href": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCountryCodeSelect": {
|
||||
"config": {
|
||||
"trigger": {
|
||||
"className": "w-auto font-bold text-[13px] text-[#11314E] flex justify-start items-center gap-1 hover:text-[#009b93] bg-transparent shadow-none hover:bg-transparent",
|
||||
"isFlag": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Nav": {
|
||||
"config": {
|
||||
"left": [
|
||||
{
|
||||
"VtSideMenu": {}
|
||||
},
|
||||
{
|
||||
"VtMegaMenu": {
|
||||
"config": {
|
||||
"navLabel": {
|
||||
"text": "Sale",
|
||||
"className": "text-[13px] text-[#11314E] flex items-center mr-8 gap-1 hover:bg-transparent hover:underline hover:text-[#009b93]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [
|
||||
{
|
||||
"HomeButton": {
|
||||
"config": {
|
||||
"label": "Medusa Store"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"AccountButton": {
|
||||
"config": {
|
||||
"label": "Account",
|
||||
"className": "hover:text-ui-fg-base"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCartButton": {
|
||||
"config": {
|
||||
"variant": "link",
|
||||
"className": "hover:text-ui-fg-base"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{ "PropsChildren": {} },
|
||||
{
|
||||
"Footer": {
|
||||
"config": {
|
||||
"className": "content-container flex w-full border h-[300px] justify-between",
|
||||
"left": [
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "category",
|
||||
"className": "flex flex-col gap-y-2",
|
||||
"itemClassName": "text-ui-fg-subtle txt-small ml-3",
|
||||
"items": [
|
||||
{ "text": "Clothing", "href": "/" },
|
||||
{ "text": "Shoes", "href": "/categories/shoes" },
|
||||
{ "text": "Accessories", "href": "/categories/accessories" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "category",
|
||||
"className": "flex flex-col gap-y-2",
|
||||
"itemClassName": "text-ui-fg-subtle txt-small ml-3",
|
||||
"items": [
|
||||
{ "text": "Clothing", "href": "/" },
|
||||
{ "text": "Shoes", "href": "/categories/shoes" },
|
||||
{ "text": "Accessories", "href": "/categories/accessories" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"Text": {
|
||||
"config": {
|
||||
"label": "Medusa Check",
|
||||
"className": "text-[13px] text-[#A6A6A6]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": {
|
||||
"Home": [
|
||||
{
|
||||
"Hero": {
|
||||
"config": {
|
||||
"variant": "default",
|
||||
"className": "bg-custom-gradient"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "best-seller"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCategoryHighlight": {
|
||||
"config": {
|
||||
"title": "Oder lieber stöbern? Hier findest du sicher deine neuen Hafer-Favoriten.",
|
||||
"className": "content-container py-12 small:py-20",
|
||||
"gridClassName": "grid grid-cols-2 gap-6 w-full",
|
||||
"labelClassName": "absolute left-4 bottom-4 text-[#003F31] text-[18px] font-semibold",
|
||||
"items": [
|
||||
{
|
||||
"imageSrc": "/overnight-oat.webp",
|
||||
"href": "/categories/overnight-oats",
|
||||
"label": "Overnight Oats",
|
||||
"className": "bg-[#CFECD9] h-[250px]",
|
||||
"imageClassName": "object-contain"
|
||||
},
|
||||
{
|
||||
"headingLabel": "The Squatch Difference",
|
||||
"descriptionLabel": "Learn why men everywhere are loving Dr. Squatch.",
|
||||
"buttonLabel": "Learn more",
|
||||
"className": "flex-col bg-[#F9E0B0] p-6 justify-center",
|
||||
"headingClassName": "text-[#003F31] text-[28px] font-semibold",
|
||||
"descriptionClassName": "text-[#003f31b3]",
|
||||
"buttonClassName": "mt-4 text-[#003F31] text-[18px] font-semibold bg-orange-500 py-2 px-16 rounded text-white"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"CartMismatchBanner": {
|
||||
"config": { "show": true }
|
||||
}
|
||||
},
|
||||
{
|
||||
"FreeShippingPriceNudge": {
|
||||
"config": { "variant": "popup" }
|
||||
}
|
||||
}
|
||||
],
|
||||
"Product": [
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "best-seller"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"StorePage": [
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "best-seller"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,412 +1,290 @@
|
|||
{
|
||||
"layout": [
|
||||
[
|
||||
{
|
||||
"Header": {
|
||||
"config": {
|
||||
"sticky": true
|
||||
"AnnouncementBanner": {
|
||||
"props": {
|
||||
"className": "bg-[#E6EFFC] text-[#285A86] flex items-center text-xs"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Banner": {
|
||||
"config": {
|
||||
"variant": "nav",
|
||||
"className": "h-12 bg-[#E6EFFC] text-[#11314E] gap-12 pl-16",
|
||||
"left": [
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full gap-8 "
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Über Uns",
|
||||
"href": "/",
|
||||
"className": "text-[13px] flex items-center gap-1 cursor-pointer"
|
||||
"Text": {
|
||||
"props": {
|
||||
"className": "text-sm font-medium",
|
||||
"label": "Über Uns"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Kontaktieren Uns",
|
||||
"href": "/",
|
||||
"className": "text-[13px] flex items-center gap-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Einsparung durch Digitalisierung in der Arztpraxis",
|
||||
"href": "/",
|
||||
"className": "text-[13px] flex items-center gap-1 "
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Mehr Info",
|
||||
"href": "/",
|
||||
"className": "text-[13px] rounded-md hover:text-white w-[80px] text-white text-center flex items-center bg-[#112638] flex justify-center h-[28px] "
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"Dropdown": {
|
||||
"config": {
|
||||
"trigger": {
|
||||
"text": "EURO",
|
||||
"className": "font-bold text-[13px] text-[#11314E] flex items-center gap-1 hover:text-[#009b93]",
|
||||
"isShowArrow": true
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Flag_of_Germany.svg/1200px-Flag_of_Germany.svg.png",
|
||||
"text": "EURO",
|
||||
"href": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCountryCodeSelect": {
|
||||
"config": {
|
||||
"trigger": {
|
||||
"className": "w-auto font-bold text-[13px] text-[#11314E] flex justify-start items-center gap-1 hover:text-[#009b93] bg-transparent shadow-none hover:bg-transparent",
|
||||
"isFlag": false
|
||||
}
|
||||
"Text": {
|
||||
"props": {
|
||||
"className": "text-sm font-medium",
|
||||
"label": "Kontaktieren Uns"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Nav": {
|
||||
"config": {
|
||||
"className": "h-24 bg-[white] text-[#11314E] gap-12 pl-16",
|
||||
"left": [
|
||||
{
|
||||
"Logo": {
|
||||
"config": {
|
||||
"src": "/VibentecIT-logo.svg",
|
||||
"alt": "MyShop",
|
||||
"className": "h-full w-[180px] mr-4",
|
||||
"objectFit": "contain"
|
||||
}
|
||||
}
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full gap-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Home",
|
||||
"href": "/",
|
||||
"className": "text-[13px] text-[#11314E] flex items-center mr-8 gap-1 hover:underline hover:text-[#009b93]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Link": {
|
||||
"config": {
|
||||
"label": "Shop",
|
||||
"href": "/",
|
||||
"className": "text-[13px] text-[#11314E] flex items-center mr-6 gap-1 hover:underline hover:text-[#009b93]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMegaMenu": {
|
||||
"config": {
|
||||
"navLabel": {
|
||||
"text": "Sale",
|
||||
"className": "text-[13px] text-[#11314E] flex items-center mr-8 gap-1 hover:bg-transparent hover:underline hover:text-[#009b93]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMegaMenu": {
|
||||
"config": {
|
||||
"navLabel": {
|
||||
"text": "OUR STORY",
|
||||
"className": "font-bold text-[1rem] text-white flex items-center mr-8 gap-1 hover:bg-transparent hover:underline hover:text-white"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"SearchInput": {
|
||||
"config": {
|
||||
"placeholder": "Search"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"AccountButton": {
|
||||
"config": {
|
||||
"icon": "User",
|
||||
"className": " flex items-center gap-1 shadow-none"
|
||||
"Text": {
|
||||
"props": {
|
||||
"className": "text-sm font-medium",
|
||||
"label": "Einsparung durch Digitalisierung in der Arztpraxis"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Button": {
|
||||
"config": {
|
||||
"icon": "Heart",
|
||||
"className": " flex items-center gap-1 shadow-none w-[50px]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtCartButton": {
|
||||
"config": {
|
||||
"icon": "ShoppingCart",
|
||||
"className": "shadow-none bg-transparent text-black w-[50px]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
"props": {
|
||||
"className": "text-xs font-medium",
|
||||
"label": "Mehr Info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{ "PropsChildren": {} },
|
||||
{
|
||||
"Footer": {
|
||||
"config": {
|
||||
"className": "content-container flex w-full border justify-between pb-8",
|
||||
"leftClassName": "flex-col ml-6",
|
||||
"centerClassName": "flex mt-[130px] gap-24",
|
||||
"rightClassName": "flex mt-[160px]",
|
||||
"left": [
|
||||
{
|
||||
"VtFooterHero": {
|
||||
"config": {
|
||||
"logoClassName": "h-[100px] w-[255px]",
|
||||
"logoSrc": "/VibentecIT-logo.svg",
|
||||
"logoAlt": "Vibentec IT",
|
||||
"title": "Der Wegbereiter für innovative IT-Lösungen",
|
||||
"description": "Tauchen Sie ein in eine Welt modernster Technologien, zuverlässiger Support und proaktiver Innovation – gemeinsam gestalten wir die digitale Zukunft Ihres Unternehmens.",
|
||||
"cta": {
|
||||
"label": "Kontaktieren Sie uns",
|
||||
"href": "/"
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full gap-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenus": {
|
||||
"props": {
|
||||
"label": "EURO",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": true
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "USD",
|
||||
"className": "",
|
||||
"ctaClassName": "ml-8",
|
||||
"titleClassName": "ml-8",
|
||||
"descriptionClassName": "ml-8 w-[320px]"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Unternehmen",
|
||||
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-[#11314E]",
|
||||
"itemClassName": "text-[1rem] font-[400]",
|
||||
"items": [
|
||||
{ "text": "Über Uns", "href": "/" },
|
||||
{ "text": "Placeholder", "href": "/categories/shoes" },
|
||||
{ "text": "Placeholder", "href": "/categories/accessories" }
|
||||
]
|
||||
"data-testid": "nav-all-categories-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Social Media",
|
||||
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-[#11314E]",
|
||||
"itemClassName": "text-[1rem] font-[400] flex items-center",
|
||||
"items": [
|
||||
{ "text": "Twitter", "href": "/", "icon": "X" },
|
||||
{ "text": "Facebook", "href": "/categories/shoes", "icon": "X" },
|
||||
{ "text": "Pinterest", "href": "/categories/accessories", "icon": "X" }
|
||||
]
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "VND",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-new-arrivals-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"title": "Addresse",
|
||||
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-[#11314E]",
|
||||
"itemClassName": "text-[1rem] font-[400] w-[150px]",
|
||||
"items": [
|
||||
{ "text": "Hopfenstr. 10c76185 Karlsruhe Deutschland", "href": "/" },
|
||||
{ "text": "+497271 5970098", "href": "/categories/shoes" },
|
||||
{ "text": "info@vibentec-it.io", "href": "/categories/accessories" }
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "EURO",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-best-sellers-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
},
|
||||
{
|
||||
"VtMenuItem": {
|
||||
"config": {
|
||||
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-[#11314E]",
|
||||
"itemClassName": "text-[1rem] font-[400] w-[150px]",
|
||||
"items": [
|
||||
{ "text": "Datenschutz", "href": "/" },
|
||||
{ "text": "Impressum", "href": "/categories/shoes" },
|
||||
{ "text": "Installation Info", "href": "/categories/accessories" }
|
||||
]
|
||||
"DropdownMenus": {
|
||||
"props": {
|
||||
"label": "DE",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-categories-link",
|
||||
"isShowArrow": true
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "EN",
|
||||
"className": "",
|
||||
"data-testid": "nav-all-categories-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "KR",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-new-arrivals-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"DropdownMenuItems": {
|
||||
"props": {
|
||||
"label": "DE",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-best-sellers-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Nav": {
|
||||
"props": {
|
||||
"className": "bg-white text-black text-sm pr-16"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"Image": {
|
||||
"props": {
|
||||
"src": "/VibentecIT-logo.svg",
|
||||
"alt": "Medusa Store",
|
||||
"width": 150,
|
||||
"height": 80,
|
||||
"className": "cursor-pointer ml-14"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center h-full gap-8 ml-16"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/",
|
||||
"label": "Home",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-home-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/collections/all",
|
||||
"label": "Shop",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-collections-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/pages/about-us",
|
||||
"label": "Sale",
|
||||
"className": "hover:underline flex items-center gap-2",
|
||||
"data-testid": "nav-about-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Div": {
|
||||
"props": {
|
||||
"className": "flex items-center gap-x-6 h-full justify-end"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"InputSearchButton": {
|
||||
"props": {
|
||||
"className": "flex items-center",
|
||||
"data-testid": "nav-account-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"UserButton": {
|
||||
"props": {
|
||||
"className": "hover:underline hover:text-black flex items-center",
|
||||
"data-testid": "nav-account-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"FavoriteButton": {
|
||||
"props": {
|
||||
"className": "hover:underline hover:text-black flex items-center",
|
||||
"data-testid": "nav-account-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Suspense": {
|
||||
"props": {
|
||||
"fallback": [
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/cart",
|
||||
"label": "Cart (0)",
|
||||
"className": "hover:underline flex gap-2",
|
||||
"data-testid": "nav-cart-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"CartButton": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"CartMismatchBanner": {
|
||||
"show": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"FreeShippingPriceNudge": {
|
||||
"variant": "popup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"PropsChildren": {}
|
||||
},
|
||||
{
|
||||
"Footer": {
|
||||
"config": {
|
||||
"className": "content-container h-[128px] w-full text text-[#11314E] flex items-center justify-between px-20 mt-2",
|
||||
"leftClassName": "w-full",
|
||||
"left": [
|
||||
{
|
||||
"VtFooterBottom": {
|
||||
"config": {
|
||||
"text": "©2025 Vibentec IT. All rights reserved",
|
||||
"icons": ["Mastercard", "PayPal", "Visa"]
|
||||
"copyrightText": "© 2025 MyShop"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": {
|
||||
"Home": [
|
||||
{
|
||||
"Hero": {
|
||||
"config": {
|
||||
"className": "h-[35rem]",
|
||||
"ImageDisplayer": {
|
||||
"config": {
|
||||
"duration": 5,
|
||||
"images": ["./banner-hero.webp", "./banner-hero-1.webp", "./banner-hero-2.webp"],
|
||||
"links": ["/", "/account", "/product"]
|
||||
}
|
||||
},
|
||||
"left": [
|
||||
{
|
||||
"VtCtaBanner": {
|
||||
"config": {
|
||||
"variant": "default",
|
||||
"className": "left-[120px] top-[80px]",
|
||||
"titleTextClassName": "leading-normal",
|
||||
"tagText": "Besonders Aktion",
|
||||
"titleText": "Willkommen in Vibentec IT Shop",
|
||||
"descriptionText": "Insert the accordion description here. It would look better as two lines of text or more.",
|
||||
"buttonText": "Zum Einkaufen"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"center": [],
|
||||
"right": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "best-seller",
|
||||
"styles": {
|
||||
"container": "content-container py-12 px-[100px] small:py-24",
|
||||
"header": {
|
||||
"container": "flex justify-between mb-8",
|
||||
"title": "text-[56px] text-[#11314E]",
|
||||
"isShowViewAll": false
|
||||
},
|
||||
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
|
||||
"productCard": {
|
||||
"card": "relative overflow-hidden rounded-2xl border border-[#285A86] bg-ui-bg-base shadow-elevation-card-rest h-full flex flex-col",
|
||||
"badge": {
|
||||
"container": "p-4",
|
||||
"text": "z-20 px-3 py-1 border-[0.5px] rounded bg-[#c9e0f5] txt-compact-small-plus shadow-borders-base text-[#285A86]"
|
||||
},
|
||||
"thumbnail": { "className": "rounded-none h-[240px]", "size": "full" },
|
||||
"subtitle": "text-ui-fg-subtle text-[14px]",
|
||||
"content": "flex flex-col flex-1 justify-between p-4",
|
||||
"title": "text-ui-fg-subtle text-[18px]",
|
||||
"description": "mt-2 text-ui-fg-subtle text-[14px]",
|
||||
"price": "flex items-center gap-x-1 text-[#285A86] font-bold border-b pb-4",
|
||||
"button": {
|
||||
"addToCart": "w-fit h-[40px] bg-black text-white rounded-md",
|
||||
"moreInfo": "w-fit h-[40px] border border-[#285A86] text-[#285A86] rounded-md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtFeaturedProducts": {
|
||||
"config": {
|
||||
"title": "produkten",
|
||||
"styles": {
|
||||
"container": "content-container py-12 px-[100px] small:py-24",
|
||||
"header": {
|
||||
"container": "flex justify-between mb-8",
|
||||
"title": "text-[56px] text-[#11314E]",
|
||||
"isShowViewAll": false
|
||||
},
|
||||
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
|
||||
"productCard": {
|
||||
"card": "relative overflow-hidden rounded-2xl border border-[#285A86] bg-ui-bg-base shadow-elevation-card-rest h-full flex flex-col",
|
||||
"badge": {
|
||||
"container": "p-4",
|
||||
"text": "z-20 px-3 py-1 border-[0.5px] rounded bg-[#c9e0f5] txt-compact-small-plus shadow-borders-base text-[#285A86]"
|
||||
},
|
||||
"thumbnail": { "className": "rounded-none h-[240px]", "size": "full" },
|
||||
"subtitle": "text-ui-fg-subtle text-[14px]",
|
||||
"content": "flex flex-col flex-1 justify-between p-4",
|
||||
"title": "text-ui-fg-subtle text-[18px]",
|
||||
"price": "flex items-center gap-x-1 text-[#285A86] font-bold border-b pb-4",
|
||||
"button": {
|
||||
"addToCart": "w-fit h-[40px] bg-black text-white rounded-md",
|
||||
"moreInfo": "w-fit h-[40px] border border-[#285A86] text-[#285A86] rounded-md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"VtSubcription": {
|
||||
"config": {
|
||||
"className": "content-container py-12 flex justify-center",
|
||||
"leftClassName": "w-1/2 bg-[#132437]",
|
||||
"cardClassName": "overflow-hidden bg-[#132437] w-1/2 p-10 text-left flex flex-col items-start",
|
||||
"title": "Subscribe our newsletter!",
|
||||
"titleClassName": "text-white text-[28px] font-bold border-b-2 border-white",
|
||||
"description": true,
|
||||
"descriptionPrefix": "Subscribe to our newsletter and be the first to receive insights, updates, and expert tips",
|
||||
"subtext": "Stay up to date!",
|
||||
"email": { "placeholder": "E-Mail-Adresse", "className": "border border-white w-full h-[40px] mt-4" },
|
||||
"policyLabel": "Ich habe die DSGVO gelesen und akzeptiere sie.",
|
||||
"formClassName": "flex gap-4",
|
||||
"fieldsClassName": "w-[390px]",
|
||||
"cta": { "label": "Subcribe", "className": "w-fit h-[40px] px-6 mt-4 bg-white text-[#132437] font-bold rounded-md" },
|
||||
"subtextSubcribe": { "label": "By subscribing, you agree to our terms.!", "className": "text-white text-[13px]" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "CartMismatchBanner": { "config": { "show": true } } },
|
||||
{ "FreeShippingPriceNudge": { "config": { "variant": "popup" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,35 +1,68 @@
|
|||
[
|
||||
{
|
||||
"Header" : {
|
||||
"config" : {"sticky": true},
|
||||
"children" : [
|
||||
{
|
||||
"Nav": {
|
||||
"config": {
|
||||
"left": [
|
||||
{ "VtSideMenu": {} }
|
||||
],
|
||||
"center": [
|
||||
{ "HomeButton": { "config" : {"label":"Medusa Store"}} }
|
||||
],
|
||||
"right": [
|
||||
{ "AccountButton": {
|
||||
"config": {
|
||||
"props": {},
|
||||
"children": [
|
||||
{
|
||||
"Div": {
|
||||
"props": { "className": "flex items-center h-full" },
|
||||
"children": [
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/",
|
||||
"label": "Medusa Store",
|
||||
"className": "bg-black txt-compact-xlarge-plus hover:text-ui-fg-base uppercase",
|
||||
"data-testid": "nav-store-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Div": {
|
||||
"props": { "className": "flex items-center gap-x-6 h-full flex-1 basis-0 justify-end" },
|
||||
"children": [
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/account",
|
||||
"label": "Account",
|
||||
"className": "hover:text-ui-fg-base"
|
||||
"className": "hover:text-ui-fg-base bg-black",
|
||||
"data-testid": "nav-account-link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "VtCartButton" : {} }
|
||||
{
|
||||
"Suspense": {
|
||||
"props": {
|
||||
"fallback": [
|
||||
{
|
||||
"LocalizedClientLink": {
|
||||
"props": {
|
||||
"href": "/cart",
|
||||
"label": "Cart (0)",
|
||||
"className": "bg-black hover:text-ui-fg-base flex gap-2",
|
||||
"data-testid": "nav-cart-link"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"children": [
|
||||
{ "CartButton": {} }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{ "CartMismatchBanner": { "config": {"show": true} } },
|
||||
{ "FreeShippingPriceNudge": { "config": {"variant": "popup" }} },
|
||||
{ "CartMismatchBanner": { "show": true } },
|
||||
{ "FreeShippingPriceNudge": { "variant": "popup" } },
|
||||
{ "PropsChildren" : {}},
|
||||
{ "Footer": { "config" : {"copyrightText": "© 2025 MyShop"} } }
|
||||
{ "Footer": { "copyrightText": "© 2025 MyShop" } }
|
||||
]
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
[
|
||||
{
|
||||
"Header" : {
|
||||
"config" : {"sticky": true},
|
||||
"children" : [
|
||||
{
|
||||
"Banner": {
|
||||
"config": {
|
||||
"variant": "nav",
|
||||
"className": "h-12 bg-[#E6EFFC] text-[#285A86]",
|
||||
"left":[ { "Link": { "config" : {"label":"About us", "href":"/vt-about"}} }],
|
||||
"center":[ { "Link": { "config" : {"label":"Contact", "href":"/vt-contact"}} }],
|
||||
"right":[ { "HomeButton": { "config" : {"label":"Vibentec IT"}} }]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Banner": {
|
||||
"config": {
|
||||
"variant": "ticker",
|
||||
"speed":24,
|
||||
"className": "h-12 bg-[#E6EFFC] text-[#285A86]",
|
||||
"items":[ {
|
||||
"Link": { "config" : {"label":"Vibentec IT"}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Banner": {
|
||||
"config": {
|
||||
"variant": "cta",
|
||||
"text": "**Black Friday Vorverkauf**\nKOSTENLOSES MISTERY GESCHENK ab 60€ <u>jetzt shoppen</u>",
|
||||
"href" : "/",
|
||||
"className": "h-12 bg-[#E6EFFC] text-[#285A86]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Nav": {
|
||||
"config": {
|
||||
"left": [
|
||||
{ "VtMegaMenu": {} } ,
|
||||
{ "HomeButton": { "config" : {"label":"Vibentec IT"}} }
|
||||
],
|
||||
"center": [
|
||||
{ "AccountButton": {
|
||||
"config": {
|
||||
"label": "Accounto",
|
||||
"bgColor": "#123456",
|
||||
"textColor": "#abcdef",
|
||||
"className": "hover:text-ui-fg-base"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{ "VtCartButton" : {} }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{ "CartMismatchBanner": { "config": {"show": true} } },
|
||||
{ "FreeShippingPriceNudge": { "config": {"variant": "popup" }} },
|
||||
{ "PropsChildren" : {}},
|
||||
{ "Footer": { "config" : {"copyrightText": "© 2025 MyShop"} } }
|
||||
]
|
||||
340
docs/PRD.md
340
docs/PRD.md
|
|
@ -1,340 +0,0 @@
|
|||
# Shop-Storefront — Current State Document
|
||||
|
||||
> **Generated:** 2026-03-27
|
||||
> **Role:** Brownfield analysis by Senior Staff Engineer / Technical Lead
|
||||
> **Branch at time of analysis:** `namds/refactor-base-layout`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Phase 1 — Discovery & Scaffolding](#phase-1--discovery--scaffolding)
|
||||
2. [Phase 2 — Codebase Triage](#phase-2--codebase-triage)
|
||||
3. [Phase 3 — Documentation](#phase-3--documentation)
|
||||
- [High-Level System Overview](#1-high-level-system-overview)
|
||||
- [Architecture Diagram](#2-architecture-diagram)
|
||||
- [Local Setup Guide](#3-local-setup-guide)
|
||||
- [Key Domain Entities](#4-key-domain-entities)
|
||||
4. [Phase 4 — Maintenance Strategy](#phase-4--maintenance-strategy)
|
||||
- [Immediate Risks](#1-immediate-risks)
|
||||
- [Refactoring Opportunities](#2-refactoring-opportunities-low-hanging-fruit)
|
||||
- [Testing Strategy](#3-testing-strategy)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Discovery & Scaffolding
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ----------------------- | --------------------------------------------------------- |
|
||||
| **Framework** | Next.js 15 (App Router, Turbopack) |
|
||||
| **Language** | TypeScript 5.3 |
|
||||
| **UI Runtime** | React 19 RC (`19.0.0-rc-66855b96-20241106`) |
|
||||
| **Styling** | Tailwind CSS 3 + `@medusajs/ui` + Radix UI |
|
||||
| **Commerce Backend** | Medusa V2 (`@medusajs/js-sdk`) |
|
||||
| **Payments** | Stripe (`@stripe/react-stripe-js`), PayPal |
|
||||
| **Package Manager** | Yarn 3 (Berry) |
|
||||
| **Database (indirect)** | `pg` listed as dependency — Medusa owns the DB connection |
|
||||
|
||||
### Entry Points & Critical Files
|
||||
|
||||
| File | Role |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/middleware.ts` | **True entry point.** Intercepts every request, resolves country code from Vercel geo-header or URL, and redirects to `/{countryCode}/...` |
|
||||
| `src/app/layout.tsx` | Root Next.js layout (HTML shell, global styles) |
|
||||
| `src/app/[countryCode]/(main)/layout.tsx` | **Primary layout shell.** Loads the JSON design config, fetches cart + customer, and hands everything to `DynamicLayoutRenderer` |
|
||||
| `src/vibentec/configloader.ts` | Reads active design JSON from `config/` at request time |
|
||||
| `src/vibentec/devJsonFileNames.ts` | Hardcoded switch that selects which tenant/design JSON is active |
|
||||
| `src/lib/config.ts` | Instantiates the Medusa JS SDK singleton |
|
||||
| `src/lib/data/` | All server-side data fetching (cart, customer, products, orders, regions, etc.) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Codebase Triage
|
||||
|
||||
### Architectural Pattern
|
||||
|
||||
This is a **modular monolith storefront** using Next.js App Router with a custom **JSON-driven dynamic layout layer** on top. There are two distinct halves:
|
||||
|
||||
1. **Standard Medusa Storefront** — The base is a Medusa V2 Next.js starter. It follows a clear `modules/` → `templates/` → `components/` hierarchy, with server-side data fetching in `lib/data/` feeding React Server Components.
|
||||
|
||||
2. **Vibentec UI Builder** — A bespoke system in `src/vibentec/` that allows the entire page shell (header, nav, footer, banners) to be defined declaratively in JSON config files (`/config/`). At runtime, `DynamicLayoutRenderer` walks the JSON node tree and maps keys to React components via `componentMap`. Multiple tenant/brand configs exist: `3bear`, `drsquatch`, `vibentec`, `mds-starter`, `medusa-starter`, `playground`.
|
||||
|
||||
### Core Domain
|
||||
|
||||
`src/lib/data/` is the data layer. `src/modules/` holds all business-domain UI (account, cart, checkout, products, orders, collections, store). The custom differentiator is `src/vibentec/` — this is Vibentec's intellectual property layered on top of the OSS Medusa starter.
|
||||
|
||||
### Technical Debt & Health Assessment
|
||||
|
||||
| Severity | Issue |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| **High** | `REVALIDATE_SECRET=supersecret` is committed in `.env` — must be rotated before production |
|
||||
| **High** | A real publishable API key (`pk_65b8a...`) is hardcoded in the committed `.env` file |
|
||||
| **Medium** | React 19 **RC** (release candidate) is pinned — not a stable release |
|
||||
| **Medium** | Active tenant config is hardcoded in `src/vibentec/devJsonFileNames.ts` — switching brands requires a code change |
|
||||
| **Medium** | `LayoutContext` uses `any` for `customer` and `cart` — kills type safety at the renderer boundary |
|
||||
| **Medium** | `pg` is listed as a direct dependency but no direct DB usage exists in this storefront |
|
||||
| **Low** | Zero test files found anywhere in the project |
|
||||
| **Low** | `@types/react-instantsearch-dom` in devDeps but no instantsearch code exists — unused dependency |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Documentation
|
||||
|
||||
### 1. High-Level System Overview
|
||||
|
||||
This is a **multi-tenant e-commerce storefront** built on Next.js 15 (App Router), serving as the customer-facing frontend for a Medusa V2 headless commerce backend. The application handles the full retail customer journey: browsing products by category or collection, managing a cart, checking out with Stripe or PayPal, and managing account details and order history. All routes are prefixed with a country code (e.g., `/us/`, `/de/`), which is resolved at the edge by middleware that consults Medusa's region API and Vercel's geo-IP headers to deliver region-appropriate pricing and shipping rules.
|
||||
|
||||
The most significant custom layer is the **Vibentec UI Builder** (`src/vibentec/`). Rather than hard-coding the page shell (header, nav, banner bar, footer), the entire layout is declared as a JSON component tree stored in `config/*.design.json` files. At request time, the primary layout server component reads the active JSON file, passes it to `DynamicLayoutRenderer`, which walks the tree and resolves component names to actual React components via `componentMap`. This makes the full layout of the storefront configurable without touching React code — a white-label capability designed to serve multiple brands from a single codebase.
|
||||
|
||||
Data flows unidirectionally from the Medusa backend through server-side `"use server"` functions in `src/lib/data/`, which use the Medusa JS SDK with Next.js `force-cache` and tag-based revalidation. There is no client-side global state management (no Redux, Zustand, or React Query). The only React Context used is a minimal `ModalContext` scoped to the modal component. All authentication state is conveyed via cookies and forwarded as HTTP headers to Medusa.
|
||||
|
||||
---
|
||||
|
||||
### 2. Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Browser"
|
||||
Browser["Customer Browser"]
|
||||
end
|
||||
|
||||
subgraph "Next.js 15 App (Port 8000)"
|
||||
Middleware["src/middleware.ts\n(Edge Runtime)\nGeo-IP → Country Code\nRegion redirect"]
|
||||
|
||||
subgraph "App Router"
|
||||
RootLayout["app/layout.tsx\nRoot HTML shell"]
|
||||
MainLayout["app/[countryCode]/(main)/layout.tsx\nLoads design JSON\nFetches cart + customer\nDynamicLayoutRenderer"]
|
||||
CheckoutLayout["app/[countryCode]/(checkout)/layout.tsx\nCheckout shell"]
|
||||
|
||||
subgraph "Pages"
|
||||
Home["/ (main) page.tsx"]
|
||||
Store["/store page.tsx"]
|
||||
Product["/products/[handle]"]
|
||||
Cart["/cart"]
|
||||
Checkout["/checkout"]
|
||||
Account["/account (Parallel Routes)"]
|
||||
Order["/order/[id]"]
|
||||
Collections["/collections/[handle]"]
|
||||
Categories["/categories/[...category]"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph "Vibentec UI Builder (src/vibentec/)"
|
||||
ConfigLoader["configloader.ts\nReads config/*.design.json"]
|
||||
Renderer["renderer.tsx\nDynamicLayoutRenderer"]
|
||||
ComponentMap["component-map.tsx\ncomponentMap registry"]
|
||||
end
|
||||
|
||||
subgraph "Modules (src/modules/)"
|
||||
Layout["layout/\nHeader, Nav, Footer,\nCart Button, Side Menu,\nMega Menu, Country Select"]
|
||||
Products["products/\nGallery, Actions, Price,\nPreview, Tabs, Related"]
|
||||
CartMod["cart/\nItems, Summary, Preview"]
|
||||
CheckoutMod["checkout/\nAddresses, Payment,\nShipping, Review"]
|
||||
AccountMod["account/\nProfile, Orders,\nAddresses, Login"]
|
||||
OrderMod["order/\nConfirmation, Details,\nTransfer"]
|
||||
end
|
||||
|
||||
subgraph "Data Layer (src/lib/data/)"
|
||||
DataFunctions["cart.ts · products.ts\ncustomer.ts · orders.ts\ncategories.ts · collections.ts\nregions.ts · payment.ts\nfulfillment.ts · cookies.ts"]
|
||||
end
|
||||
|
||||
subgraph "Config Files (config/)"
|
||||
DesignJSON["nam.vibentec.design.json\nnam.3bear.design.json\nnam.drsquatch.design.json\nste.medusa-starter.design.json\n(+ others)"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph "External Services"
|
||||
MedusaBackend["Medusa V2 Backend\n(localhost:9000)\nProducts, Orders, Carts,\nCustomers, Regions,\nPayment Sessions"]
|
||||
Stripe["Stripe\nPayment Processing"]
|
||||
PayPal["PayPal\nPayment Processing"]
|
||||
S3["AWS S3\nProduct Images"]
|
||||
end
|
||||
|
||||
Browser -->|"HTTP Request"| Middleware
|
||||
Middleware -->|"Resolves region\nSets _medusa_cache_id cookie"| RootLayout
|
||||
RootLayout --> MainLayout
|
||||
MainLayout --> ConfigLoader
|
||||
ConfigLoader -->|"Reads active .design.json"| DesignJSON
|
||||
MainLayout --> Renderer
|
||||
Renderer --> ComponentMap
|
||||
ComponentMap --> Layout
|
||||
MainLayout -->|"props.children"| Pages
|
||||
Pages --> Products
|
||||
Pages --> CartMod
|
||||
Pages --> CheckoutMod
|
||||
Pages --> AccountMod
|
||||
Pages --> OrderMod
|
||||
DataFunctions -->|"Medusa JS SDK\nforce-cache + tag revalidation"| MedusaBackend
|
||||
CheckoutMod -->|"Stripe Elements"| Stripe
|
||||
CheckoutMod -->|"PayPal SDK"| PayPal
|
||||
MedusaBackend -->|"Image URLs"| S3
|
||||
Products & CartMod & CheckoutMod & AccountMod & OrderMod --> DataFunctions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Local Setup Guide
|
||||
|
||||
**Prerequisites:** Node.js ≥ 20, Yarn 3 (Berry), a running Medusa V2 backend with at least one Region configured.
|
||||
|
||||
```bash
|
||||
# 1. Clone and install
|
||||
git clone <repo-url>
|
||||
cd Shop-Storefront
|
||||
yarn install
|
||||
|
||||
# 2. Configure environment
|
||||
cp .env .env.local
|
||||
# Edit .env.local and set:
|
||||
# MEDUSA_BACKEND_URL=http://localhost:9000
|
||||
# NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=<your key from Medusa Admin>
|
||||
# NEXT_PUBLIC_BASE_URL=http://localhost:8000
|
||||
# NEXT_PUBLIC_DEFAULT_REGION=us
|
||||
# REVALIDATE_SECRET=<generate a strong random string — never use "supersecret">
|
||||
|
||||
# 3. Select the active brand design (defaults to namVibentec)
|
||||
# Edit src/vibentec/devJsonFileNames.ts:
|
||||
# const fileName = jsonFileNames.namVibentec; ← change to desired brand key
|
||||
|
||||
# 4. Run development server (Turbopack, port 8000)
|
||||
yarn dev
|
||||
|
||||
# 5. Open browser — middleware will redirect to /{DEFAULT_REGION}/
|
||||
open http://localhost:8000
|
||||
```
|
||||
|
||||
**Build for production:**
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
> `next.config.js` calls `checkEnvVariables()` at startup. Missing required env vars will cause a descriptive build failure.
|
||||
|
||||
---
|
||||
|
||||
### 4. Key Domain Entities
|
||||
|
||||
Inferred from `@medusajs/types` (`HttpTypes`) as used by the data layer, plus local types in `src/types/global.ts`.
|
||||
|
||||
| Entity | Key Fields | Source |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| **StoreProduct** | `id`, `title`, `handle`, `thumbnail`, `variants[]`, `tags[]`, `metadata`, `collection`, `categories[]` | `lib/data/products.ts` |
|
||||
| **StoreProductVariant** | `id`, `calculated_price`, `inventory_quantity`, `options[]` | Fetched with `*variants.calculated_price` field selector |
|
||||
| **StoreCart** | `id`, `region_id`, `items[]`, `shipping_address`, `billing_address`, `email`, `promotions[]`, `shipping_methods[]`, `payment_collection` | `lib/data/cart.ts` |
|
||||
| **StoreLineItem** | `id`, `variant_id`, `quantity`, `total`, `product`, `variant`, `thumbnail`, `metadata` | Part of StoreCart |
|
||||
| **StoreRegion** | `id`, `countries[]` (each with `iso_2`) | `middleware.ts`, `lib/data/regions.ts` |
|
||||
| **StoreCustomer** | `id`, `email`, `first_name`, `last_name`, `addresses[]`, `phone` | `lib/data/customer.ts` |
|
||||
| **StoreOrder** | `id`, `status`, `items[]`, `shipping_address`, `payment_collections[]`, `fulfillments[]` | `lib/data/orders.ts` |
|
||||
| **StoreCartShippingOption** | `id`, `name`, `amount`, `provider_id` | `lib/data/cart.ts` → `listCartOptions()` |
|
||||
| **FeaturedProduct** _(local)_ | `id`, `title`, `handle`, `thumbnail` | `src/types/global.ts` |
|
||||
| **VariantPrice** _(local)_ | `calculated_price`, `original_price`, `currency_code`, `price_type`, `percentage_diff` | `src/types/global.ts` |
|
||||
| **StoreFreeShippingPrice** _(local)_ | extends `StorePrice` + `target_reached`, `target_remaining`, `remaining_percentage` | `src/types/global.ts` |
|
||||
| **LayoutComponentNode** _(local)_ | `{ [ComponentName]: { config?, children? } }` | `src/vibentec/component-map.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Maintenance Strategy
|
||||
|
||||
### 1. Immediate Risks
|
||||
|
||||
| Severity | Risk | Location | Action Required |
|
||||
| ------------ | ---------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Critical** | Secrets committed to repository | `.env` | Rotate `NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY` immediately. Regenerate `REVALIDATE_SECRET`. Use `.env.local` (already `.gitignore`'d) for real values. |
|
||||
| **Critical** | Silent bug: missing `await` | `src/lib/data/cart.ts:339` | `const cartId = getCartId()` is missing `await`. `cartId` resolves to a `Promise` (truthy), so the guard `if (!cartId)` never fires. `setAddresses` silently fails to validate cart existence on cold sessions. Fix: `const cartId = await getCartId()` |
|
||||
| **High** | Empty stub functions silently do nothing | `src/lib/data/cart.ts:279–318` | `applyGiftCard`, `removeDiscount`, `removeGiftCard` are exported but completely empty. Any UI that calls them will silently succeed with no effect. Either implement for Medusa V2 or remove the exports. |
|
||||
| **High** | TypeScript & ESLint errors suppressed at build | `next.config.js:24–27` | `ignoreBuildErrors: true` and `ignoreDuringBuilds: true` ship broken types silently. Re-enable both before production and resolve resulting errors. |
|
||||
| **Medium** | React 19 RC pinned | `package.json` | Pre-release runtime in production. Migrate to stable React 19 once available, or document the risk explicitly. |
|
||||
|
||||
---
|
||||
|
||||
### 2. Refactoring Opportunities (Low-Hanging Fruit)
|
||||
|
||||
Listed in priority order — highest impact for lowest effort first.
|
||||
|
||||
**1. Externalize active brand config to an environment variable**
|
||||
|
||||
- File: `src/vibentec/devJsonFileNames.ts`
|
||||
- Change the hardcoded `const fileName = jsonFileNames.namVibentec` to read from `process.env.VIBENTEC_DESIGN_CONFIG`.
|
||||
- Impact: Enables true multi-tenant deployment (different brands on different deployments) without any code changes. Currently requires a code commit to switch brands.
|
||||
|
||||
**2. Type the `LayoutContext` properly**
|
||||
|
||||
- File: `src/vibentec/component-map.tsx:34–38`
|
||||
- Replace `customer: any` and `cart: any` with `HttpTypes.StoreCustomer | null` and `HttpTypes.StoreCart | null`.
|
||||
- Impact: Propagates type safety through the entire renderer pipeline, catching prop mismatches at compile time.
|
||||
|
||||
**3. Remove dead dependencies**
|
||||
|
||||
- `pg` and `@types/pg` — no direct DB access exists in this storefront.
|
||||
- `@types/react-instantsearch-dom` — no instantsearch code exists.
|
||||
- Run: `yarn remove pg @types/pg @types/react-instantsearch-dom`
|
||||
- Impact: Cleaner dependency graph, faster installs, less confusion for new developers.
|
||||
|
||||
**4. Fix hardcoded `data-mode="light"`**
|
||||
|
||||
- File: `src/app/layout.tsx:11`
|
||||
- If dark mode is planned: drive this from a cookie or `prefers-color-scheme`. If not: add a comment documenting the intentional lock to light mode.
|
||||
|
||||
**5. Delete commented-out V1 cart code**
|
||||
|
||||
- File: `src/lib/data/cart.ts:279–318`
|
||||
- The commented blocks are Medusa V1 patterns. They will never be un-commented as-is. Remove them to reduce noise and cognitive load.
|
||||
|
||||
---
|
||||
|
||||
### 3. Testing Strategy
|
||||
|
||||
Given zero existing tests and the RSC-heavy architecture, the recommended approach is **outside-in, starting at the data boundaries**.
|
||||
|
||||
**Step 1 — Integration tests for the data layer** (`src/lib/data/`)
|
||||
|
||||
These are pure server functions with no React involved — the easiest place to start. Use **Vitest** with a real (or Docker-composed) Medusa backend in test mode.
|
||||
|
||||
```
|
||||
Priority order: cart.ts → customer.ts → products.ts → regions.ts
|
||||
```
|
||||
|
||||
Start with `cart.ts` — it has the most mutations, the most business logic, and already has a confirmed `await` bug. Writing a test for `setAddresses` would have caught that immediately.
|
||||
|
||||
**Step 2 — Component tests for the Vibentec renderer** (`src/vibentec/`)
|
||||
|
||||
The `DynamicLayoutRenderer` and `componentMap` are the highest-risk custom code with no safety net. Use **React Testing Library** with `@testing-library/react` (RSC-compatible via Next.js jest transform).
|
||||
|
||||
Write snapshot tests that pass a known JSON fixture through the full renderer pipeline. This creates a regression guard before any design JSON changes.
|
||||
|
||||
```typescript
|
||||
// Example fixture test shape
|
||||
it("renders a Header with a Banner child from JSON", () => {
|
||||
const nodes = [
|
||||
{ Header: { children: [{ Banner: { config: { variant: "nav" } } }] } },
|
||||
]
|
||||
const { container } = render(
|
||||
<DynamicLayoutRenderer nodes={nodes} context={mockContext} />
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
**Step 3 — E2E tests for critical user journeys** using **Playwright**
|
||||
|
||||
Two journeys that must never break:
|
||||
|
||||
1. `Browse Store → Product Detail → Add to Cart → Checkout → Order Confirmed`
|
||||
2. `Login → Account Dashboard → View Order History`
|
||||
|
||||
**Step 4 — CI enforcement**
|
||||
|
||||
The `.github/` directory currently only contains an issue template. Add a GitHub Actions workflow that runs on every PR:
|
||||
|
||||
```yaml
|
||||
- yarn lint # re-enable ignoreDuringBuilds: false first
|
||||
- tsc --noEmit # re-enable ignoreBuildErrors: false first
|
||||
- vitest run # data layer integration tests
|
||||
- playwright test # critical path E2E
|
||||
```
|
||||
|
||||
No PR merges to `main` without all four passing.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,8 +21,6 @@
|
|||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@stripe/react-stripe-js": "^1.7.2",
|
||||
"@stripe/stripe-js": "^1.29.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^15.3.1",
|
||||
"pg": "^8.11.3",
|
||||
|
|
@ -30,9 +28,6 @@
|
|||
"react": "19.0.0-rc-66855b96-20241106",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"tailwindcss-radix": "^2.8.0",
|
||||
"webpack": "^5"
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 9.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 403 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 195 KiB |
|
|
@ -0,0 +1,18 @@
|
|||
export const DESIGN_JSON_FILE = [
|
||||
{
|
||||
id: "drsquatch",
|
||||
file: "nam.drsquatch.design.json",
|
||||
},
|
||||
{
|
||||
id: "3bear",
|
||||
file: "nam.3bear.design.json",
|
||||
},
|
||||
{
|
||||
id: "vibentec",
|
||||
file: "nam.vibentec.design.json",
|
||||
},
|
||||
{
|
||||
id: "medusa-starter",
|
||||
file: "ste.medusa-starter.design.json",
|
||||
}
|
||||
]
|
||||
|
|
@ -4,23 +4,17 @@ import { listCartOptions, retrieveCart } from "@lib/data/cart"
|
|||
import { retrieveCustomer } from "@lib/data/customer"
|
||||
import { getBaseURL } from "@lib/util/env"
|
||||
import { StoreCartShippingOption } from "@medusajs/types"
|
||||
import { DynamicLayoutRenderer } from "../../../vibentec/renderer"
|
||||
import { LayoutContext, LayoutComponentNode } from "../../../vibentec/component-map"
|
||||
import { loadLayoutConfig } from "vibentec/configloader"
|
||||
|
||||
import { getRegion } from "@lib/data/regions"
|
||||
import { LayoutContext, LayoutComponentNode } from "vibentec/component-map"
|
||||
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||
import { loadDesignConfig } from "vibentec/configloader"
|
||||
import { DESIGN_JSON_FILE } from "./config-json-file"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(getBaseURL()),
|
||||
}
|
||||
|
||||
export default async function PageLayout(props: {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ countryCode: string }>
|
||||
}) {
|
||||
const params = await props.params
|
||||
const { countryCode } = params
|
||||
const region = await getRegion(countryCode)
|
||||
export default async function PageLayout(props: { children: React.ReactNode }) {
|
||||
// Choose which design JSON to load. Swap this constant as needed.
|
||||
const customer = await retrieveCustomer()
|
||||
const cart = await retrieveCart()
|
||||
let shippingOptions: StoreCartShippingOption[] = []
|
||||
|
|
@ -31,16 +25,16 @@ export default async function PageLayout(props: {
|
|||
shippingOptions = shipping_options
|
||||
}
|
||||
|
||||
const nodes: LayoutComponentNode[] = await loadLayoutConfig()
|
||||
const nodes: LayoutComponentNode[] = await loadDesignConfig(
|
||||
DESIGN_JSON_FILE[1].file
|
||||
)
|
||||
const context: LayoutContext = {
|
||||
customer,
|
||||
cart,
|
||||
shippingOptions,
|
||||
contentChildren: props.children,
|
||||
countryCode,
|
||||
region,
|
||||
designId: DESIGN_JSON_FILE[1].id,
|
||||
}
|
||||
|
||||
|
||||
return <DynamicLayoutRenderer nodes={nodes} context={context} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ import FeaturedProducts from "@modules/home/components/featured-products"
|
|||
import Hero from "@modules/home/components/hero"
|
||||
import { listCollections } from "@lib/data/collections"
|
||||
import { getRegion } from "@lib/data/regions"
|
||||
import VtFeaturedProducts from "@modules/home/components/vt-featured-products"
|
||||
import { DynamicLayoutRenderer } from "@vibentec/renderer"
|
||||
import { LayoutContext, LayoutComponentNode } from "@vibentec/component-map"
|
||||
import { loadPageConfig } from "@vibentec/configloader"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Medusa Next.js Starter Template",
|
||||
|
|
@ -28,25 +24,18 @@ export default async function Home(props: {
|
|||
fields: "id, handle, title",
|
||||
})
|
||||
|
||||
console.log('collections:',collections)
|
||||
|
||||
if (!collections || !region) {
|
||||
return null
|
||||
}
|
||||
const nodes: LayoutComponentNode[] = await loadPageConfig("Home")
|
||||
|
||||
if (!region) {
|
||||
return null
|
||||
}
|
||||
|
||||
const context: LayoutContext = {
|
||||
customer: null,
|
||||
cart: null,
|
||||
shippingOptions: [],
|
||||
contentChildren: null,
|
||||
countryCode,
|
||||
region,
|
||||
}
|
||||
|
||||
return <DynamicLayoutRenderer nodes={nodes} context={context} />
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<div className="py-12">
|
||||
<ul className="flex flex-col gap-x-6">
|
||||
<FeaturedProducts collections={collections} region={region} />
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ import { notFound } from "next/navigation"
|
|||
import { listProducts } from "@lib/data/products"
|
||||
import { getRegion, listRegions } from "@lib/data/regions"
|
||||
import ProductTemplate from "@modules/products/templates"
|
||||
import { DynamicLayoutRenderer } from "@vibentec/renderer"
|
||||
import { LayoutContext, LayoutComponentNode } from "@vibentec/component-map"
|
||||
import { loadPageConfig } from "@vibentec/configloader"
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ countryCode: string; handle: string }>
|
||||
|
|
@ -99,25 +96,11 @@ export default async function ProductPage(props: Props) {
|
|||
notFound()
|
||||
}
|
||||
|
||||
const nodes: LayoutComponentNode[] = await loadPageConfig("Product")
|
||||
|
||||
const context: LayoutContext = {
|
||||
customer: null,
|
||||
cart: null,
|
||||
shippingOptions: [],
|
||||
contentChildren: null,
|
||||
countryCode: params.countryCode,
|
||||
region,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProductTemplate
|
||||
product={pricedProduct}
|
||||
region={region}
|
||||
countryCode={params.countryCode}
|
||||
/>
|
||||
<DynamicLayoutRenderer nodes={nodes} context={context} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@ import { Metadata } from "next"
|
|||
|
||||
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
||||
import StoreTemplate from "@modules/store/templates"
|
||||
import { LayoutComponentNode, LayoutContext } from "@vibentec/component-map"
|
||||
import { getRegion } from "@lib/data/regions"
|
||||
import { loadPageConfig } from "@vibentec/configloader"
|
||||
import { DynamicLayoutRenderer } from "@vibentec/renderer"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Store",
|
||||
|
|
@ -23,28 +19,15 @@ type Params = {
|
|||
}
|
||||
|
||||
export default async function StorePage(props: Params) {
|
||||
const params = await props.params
|
||||
const searchParams = await props.searchParams
|
||||
const region = await getRegion(params.countryCode)
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const { sortBy, page } = searchParams
|
||||
const nodes: LayoutComponentNode[] = await loadPageConfig("Store")
|
||||
|
||||
const context: LayoutContext = {
|
||||
customer: null,
|
||||
cart: null,
|
||||
shippingOptions: [],
|
||||
contentChildren: null,
|
||||
countryCode: params.countryCode,
|
||||
region,
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<StoreTemplate
|
||||
sortBy={sortBy}
|
||||
page={page}
|
||||
countryCode={params.countryCode}
|
||||
/>
|
||||
<DynamicLayoutRenderer nodes={nodes} context={context} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="content-container py-16">
|
||||
<h1 className="text-3xl font-bold mb-4">Über uns</h1>
|
||||
<p>
|
||||
Willkommen bei Vibentec IT! Wir bieten maßgeschneiderte Softwarelösungen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<div className="content-container py-16">
|
||||
<h1 className="text-3xl font-bold mb-4">Kontakt</h1>
|
||||
<p>
|
||||
Schreibe uns: <a href="mailto:info@vibentec-it.de">info@vibentec-it.de</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ export const listCollections = async (
|
|||
{
|
||||
query: queryParams,
|
||||
next,
|
||||
cache: "force-cache",
|
||||
}
|
||||
)
|
||||
.then(({ collections }) => ({ collections, count: collections.length }))
|
||||
|
|
|
|||
|
|
@ -63,11 +63,12 @@ export const listProducts = async ({
|
|||
offset,
|
||||
region_id: region?.id,
|
||||
fields:
|
||||
"*variants.calculated_price,+variants.inventory_quantity,*metadata,+tags",
|
||||
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
|
||||
...queryParams,
|
||||
},
|
||||
headers,
|
||||
next,
|
||||
cache: "force-cache",
|
||||
}
|
||||
)
|
||||
.then(({ products, count }) => {
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
export { default as Back } from "./back"
|
||||
export { default as Bancontact } from "./bancontact"
|
||||
export { default as ChevronDown } from "./chevron-down"
|
||||
export { default as Eye } from "./eye"
|
||||
export { default as EyeOff } from "./eye-off"
|
||||
export { default as FastDelivery } from "./fast-delivery"
|
||||
export { default as Ideal } from "./ideal"
|
||||
export { default as MapPin } from "./map-pin"
|
||||
export { default as Medusa } from "./medusa"
|
||||
export { default as Nextjs } from "./nextjs"
|
||||
export { default as Package } from "./package"
|
||||
export { default as PayPal } from "./paypal"
|
||||
export { default as PlaceholderImage } from "./placeholder-image"
|
||||
export { default as Refresh } from "./refresh"
|
||||
export { default as Spinner } from "./spinner"
|
||||
export { default as Trash } from "./trash"
|
||||
export { default as User } from "./user"
|
||||
export { default as X } from "./x"
|
||||
export { default as Twitter } from "./twitter"
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Twitter: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 15 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M12.8069 2.87388C12.8069 3.01339 12.8069 3.125 12.8069 3.26451C12.8069 7.14286 9.8772 11.5792 4.49215 11.5792C2.81805 11.5792 1.28345 11.1049 -3.52859e-05 10.2679C0.223179 10.2958 0.446393 10.3237 0.697509 10.3237C2.0647 10.3237 3.32028 9.84933 4.32474 9.06808C3.04126 9.04018 1.95309 8.20312 1.59037 7.03125C1.78568 7.05915 1.95309 7.08705 2.1484 7.08705C2.39952 7.08705 2.67854 7.03125 2.90175 6.97545C1.56246 6.69643 0.558 5.52455 0.558 4.10156V4.07366C0.948625 4.29687 1.42296 4.40848 1.89729 4.43638C1.08813 3.90625 0.585902 3.01339 0.585902 2.00893C0.585902 1.45089 0.725411 0.94866 0.976527 0.530133C2.42742 2.28795 4.60376 3.45982 7.03122 3.59933C6.97541 3.37612 6.94751 3.1529 6.94751 2.92969C6.94751 1.31138 8.25889 -6.37025e-07 9.8772 -6.37025e-07C10.7143 -6.37025e-07 11.4676 0.334821 12.0256 0.920758C12.6674 0.781249 13.3091 0.530133 13.8672 0.195312C13.6439 0.892857 13.1975 1.45089 12.5837 1.81362C13.1696 1.75781 13.7555 1.5904 14.2578 1.36719C13.8672 1.95312 13.3649 2.45536 12.8069 2.87388Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Twitter
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { clx } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
|
||||
export default async function VtBrand({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
|
||||
const title: string = props.title ?? "Trusted By"
|
||||
const items = props.items ?? []
|
||||
|
||||
const classes = {
|
||||
container: props.className ?? "w-full py-12 bg-[#CFECD9]",
|
||||
inner: props.innerClassName ?? "content-container flex flex-col items-center",
|
||||
title: props.titleClassName ?? "text-[#1f3521] text-[20px] font-bold mb-8",
|
||||
brands: props.brandsClassName ?? "flex w-full items-center justify-between gap-12",
|
||||
item: props.itemClassName ?? "opacity-90",
|
||||
image: props.imageClassName ?? "h-[48px] w-auto object-contain",
|
||||
label: props.labelClassName ?? "text-[#1f3521] text-[36px] font-semibold",
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderItem = (brand: any, idx: number) => {
|
||||
const content = brand.imageSrc ? (
|
||||
<img
|
||||
src={brand.imageSrc}
|
||||
alt={brand.alt ?? brand.label ?? `brand-${idx}`}
|
||||
className={clx(classes.image, brand.imageClassName)}
|
||||
/>
|
||||
) : (
|
||||
<span className={clx(classes.label, brand.className)}>
|
||||
{brand.label ?? ""}
|
||||
</span>
|
||||
)
|
||||
|
||||
return brand.href ? (
|
||||
<LocalizedClientLink
|
||||
key={`brand-${idx}`}
|
||||
href={brand.href}
|
||||
className={clx(classes.item, brand.containerClassName)}
|
||||
>
|
||||
{content}
|
||||
</LocalizedClientLink>
|
||||
) : (
|
||||
<div className={clx(classes.item, brand.containerClassName)} key={`brand-${idx}`}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classes.container}>
|
||||
<div className={classes.inner}>
|
||||
{title && <div className={classes.title}>{title}</div>}
|
||||
<div className={classes.brands}>
|
||||
{items.map((brand: any, idx: number) => renderItem(brand, idx))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import { clx } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
|
||||
export default async function VtCategoryHighlight({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
|
||||
const title: string = props.title ?? ""
|
||||
const items = props.items ?? []
|
||||
|
||||
const classes = {
|
||||
container: props.className ?? "content-container py-12",
|
||||
title:
|
||||
props.titleClassName ?? "text-[#003F31] text-[28px] font-semibold mb-6",
|
||||
grid: props.gridClassName ?? "grid grid-cols-3 gap-6 w-full",
|
||||
tile:
|
||||
props.tileClassName ??
|
||||
"relative rounded-2xl overflow-hidden bg-white aspect-square w-full",
|
||||
label: props.labelClassName ?? "text-[#003F31] text-[18px] font-semibold",
|
||||
image: props.imageClassName ?? "w-full h-full object-contain",
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderTile = (tile: any, idx: number) => {
|
||||
const imageEl = tile.imageSrc ? (
|
||||
<img
|
||||
src={tile.imageSrc}
|
||||
alt={tile.label ?? `category-${idx}`}
|
||||
className={clx(classes.image, tile.imageClassName)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clx(
|
||||
"w-full h-full flex items-center justify-center",
|
||||
tile.className
|
||||
)}
|
||||
>
|
||||
<div className={clx(tile.headingClassName)}>{tile.headingLabel}</div>
|
||||
<div className={tile.descriptionClassName}>{tile.descriptionLabel}</div>
|
||||
<button className={tile.buttonClassName}>{tile.buttonLabel}</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<div className={clx("relative w-full h-full")}>
|
||||
{imageEl}
|
||||
{tile.label && <span className={classes.label}>{tile.label}</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
return tile.href ? (
|
||||
<LocalizedClientLink
|
||||
key={`tile-${idx}`}
|
||||
href={tile.href}
|
||||
className={clx("w-full h-full", tile.className)}
|
||||
>
|
||||
{content}
|
||||
</LocalizedClientLink>
|
||||
) : (
|
||||
<div className={clx(tile.className, "w-full h-full")} key={`tile-${idx}`}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classes.container}>
|
||||
{title && <h2 className={classes.title}>{title}</h2>}
|
||||
<div className={classes.grid}>
|
||||
{items.map((tile: any, idx: number) => renderTile(tile, idx))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { HttpTypes } from "@medusajs/types"
|
||||
import ProductRail from "./product-rail"
|
||||
import { listCollections } from "@lib/data/collections"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "@vibentec/component-map"
|
||||
|
||||
export default async function VtFeaturedProducts(props: {
|
||||
collections?: HttpTypes.StoreCollection[]
|
||||
region?: HttpTypes.StoreRegion
|
||||
countryCode?: string
|
||||
nodes?: LayoutComponentDefinition
|
||||
context?: LayoutContext
|
||||
}) {
|
||||
let { collections, region, countryCode } = props
|
||||
const { nodes, context } = props
|
||||
|
||||
if (context) {
|
||||
if (!region) region = context.region
|
||||
if (!countryCode) countryCode = context.countryCode
|
||||
}
|
||||
|
||||
if (!collections && region) {
|
||||
const result = await listCollections({
|
||||
fields: "id, handle, title",
|
||||
})
|
||||
collections = result.collections
|
||||
}
|
||||
|
||||
if (!collections || !region || !countryCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const configTitle = nodes?.config?.title
|
||||
const styles = nodes?.config?.styles
|
||||
|
||||
let displayCollections = collections
|
||||
if (configTitle) {
|
||||
displayCollections = collections.filter(
|
||||
(c) => c.handle === configTitle || c.title === configTitle
|
||||
)
|
||||
}
|
||||
|
||||
return displayCollections.map((collection) => (
|
||||
<li key={collection.id}>
|
||||
<ProductRail
|
||||
collection={collection}
|
||||
region={region}
|
||||
countryCode={countryCode}
|
||||
styles={styles}
|
||||
/>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { listProducts } from "@lib/data/products"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
|
||||
import InteractiveLink from "@modules/common/components/interactive-link"
|
||||
import ProductCard from "@modules/products/components/vt-product-card"
|
||||
|
||||
export default async function ProductRail({
|
||||
collection,
|
||||
region,
|
||||
countryCode,
|
||||
styles,
|
||||
}: {
|
||||
collection: HttpTypes.StoreCollection
|
||||
region: HttpTypes.StoreRegion
|
||||
countryCode: string
|
||||
styles?: any
|
||||
}) {
|
||||
const {
|
||||
response: { products: pricedProducts },
|
||||
} = await listProducts({
|
||||
regionId: region.id,
|
||||
queryParams: {
|
||||
collection_id: collection.id,
|
||||
fields: "*variants.calculated_price",
|
||||
},
|
||||
})
|
||||
|
||||
if (!pricedProducts) {
|
||||
return null
|
||||
}
|
||||
|
||||
const classes = {
|
||||
container: styles?.container ?? "content-container py-12 px-[100px] small:py-24",
|
||||
header: {
|
||||
container: styles?.header?.container ?? "flex justify-between mb-8",
|
||||
title: styles?.header?.title ?? "txt-xlarge",
|
||||
isShowViewAll: styles?.header.isShowViewAll ?? true,
|
||||
},
|
||||
list: styles?.list ?? "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<div className={classes.header.container}>
|
||||
<Text className={classes.header.title}>{collection.title}</Text>
|
||||
{classes.header.isShowViewAll && (
|
||||
<InteractiveLink href={`/collections/${collection.handle}`}>
|
||||
View all
|
||||
</InteractiveLink>
|
||||
)}
|
||||
</div>
|
||||
<ul className={classes.list}>
|
||||
{pricedProducts &&
|
||||
pricedProducts.map((product) => (
|
||||
<li key={product.id}>
|
||||
<ProductCard
|
||||
product={product}
|
||||
countryCode={countryCode}
|
||||
styles={styles?.productCard}
|
||||
badgeText={styles?.productCard?.badgeText}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { clx } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
|
||||
export default async function VtFeedbackCard({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
|
||||
const title: string = props.title ?? ""
|
||||
const items = props.items ?? []
|
||||
|
||||
const classes = {
|
||||
container: props.className ?? "",
|
||||
title: props.titleClassName ?? "text-[#003F31] text-[28px] font-semibold mb-10",
|
||||
grid: props.gridClassName ?? "grid grid-cols-1 small:grid-cols-2 xl:grid-cols-4 gap-6",
|
||||
card: props.cardClassName ?? "rounded-2xl overflow-hidden bg-[#CFECD9]",
|
||||
image: props.imageClassName ?? "w-full h-[260px] object-cover",
|
||||
content: props.contentClassName ?? "p-6",
|
||||
name: props.nameClassName ?? "text-[#003F31] text-[20px] font-bold",
|
||||
subtitle: props.subtitleClassName ?? "mt-1 text-[#003f31b3] text-[14px]",
|
||||
quote: props.quoteClassName ?? "mt-4 text-[#003F31] text-[16px]",
|
||||
cta: props.ctaClassName ?? "mt-6 inline-flex items-center justify-center bg-[#FCEE56] text-[#0D382E] px-6 py-2 rounded-full font-bold",
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
const renderCard = (entry: any, idx: number) => {
|
||||
const imageEl = entry.imageSrc ? (
|
||||
<img
|
||||
src={entry.imageSrc}
|
||||
alt={entry.imageAlt ?? entry.name ?? `feedback-card-${idx}`}
|
||||
className={classes.image}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const ctaEl = entry.cta?.href ? (
|
||||
<LocalizedClientLink href={entry.cta.href} className={clx(classes.cta, entry.cta?.className)}>
|
||||
{entry.cta.label ?? "Mehr erfahren"}
|
||||
</LocalizedClientLink>
|
||||
) : entry.cta?.label ? (
|
||||
<button className={clx(classes.cta, entry.cta?.className)}>{entry.cta.label}</button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className={clx(classes.card, entry.className)} key={`vt-feedback-card-${idx}`}>
|
||||
{imageEl}
|
||||
<div className={clx(classes.content)}>
|
||||
{entry.name && <div className={clx(classes.name, entry.nameClassName)}>{entry.name}</div>}
|
||||
{entry.subtitle && (
|
||||
<div className={clx(classes.subtitle, entry.subtitleClassName)}>{entry.subtitle}</div>
|
||||
)}
|
||||
{entry.quote && <div className={clx(classes.quote, entry.quoteClassName)}>{entry.quote}</div>}
|
||||
{ctaEl}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classes.container}>
|
||||
{title && <h2 className={classes.title}>{title}</h2>}
|
||||
<div className={classes.grid}>{items.map((it: any, idx: number) => renderCard(it, idx))}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"use client"
|
||||
import useEmblaCarousel from "embla-carousel-react"
|
||||
import Autoplay from "embla-carousel-autoplay"
|
||||
import { useMemo } from "react"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import { NextButton, PrevButton, usePrevNextButtons } from "@modules/layout/templates/vt-carousel/carousel-arrow-button"
|
||||
|
||||
export default function VtFeedback({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
|
||||
const props = nodes.config ?? {}
|
||||
|
||||
const title: string = props.title ?? ""
|
||||
const items = props.items ?? []
|
||||
const durationSeconds: number = props.duration ?? 5
|
||||
const options = props.options ?? { loop: true }
|
||||
const plugins = useMemo(() => {
|
||||
if (!durationSeconds || durationSeconds <= 0) return []
|
||||
return [
|
||||
Autoplay({
|
||||
delay: durationSeconds * 1000,
|
||||
stopOnInteraction: false,
|
||||
stopOnMouseEnter: true,
|
||||
}),
|
||||
]
|
||||
}, [durationSeconds])
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(options, plugins)
|
||||
const { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick } = usePrevNextButtons(emblaApi)
|
||||
|
||||
const classes = {
|
||||
container: props.className ?? "content-container py-16",
|
||||
title: props.titleClassName ?? "text-[#1f3521] text-[28px] font-bold text-center mb-10",
|
||||
viewport: "relative overflow-hidden",
|
||||
containerInner: "flex",
|
||||
slide: props.itemClassName ?? "min-w-full px-6",
|
||||
slideInner: "flex flex-col items-center text-center gap-3",
|
||||
stars: props.starsClassName ?? "text-[#C4622C] text-xl leading-none",
|
||||
reviewTitle: props.reviewTitleClassName ?? "text-[#1f3521] font-bold",
|
||||
reviewText: props.reviewTextClassName ?? "text-[#1f3521]",
|
||||
author: props.authorClassName ?? "italic text-[#1f3521]",
|
||||
controls: props.controls,
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
const showControls = items.length > 1 && classes.controls
|
||||
|
||||
const renderStars = (rating?: number) => {
|
||||
const count = Math.max(0, Math.min(5, Math.round(rating ?? 5)))
|
||||
return "★★★★★".slice(0, count)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classes.container}>
|
||||
{title && <h2 className={classes.title}>{title}</h2>}
|
||||
<div className={classes.viewport} ref={emblaRef}>
|
||||
<div className={classes.containerInner}>
|
||||
{items.map((it: any, idx: number) => (
|
||||
<div className={classes.slide} key={`feedback-${idx}`}>
|
||||
<div className={classes.slideInner}>
|
||||
<div className={classes.stars}>{renderStars(it.rating)}</div>
|
||||
{it.title && <div className={classes.reviewTitle}>{it.title}</div>}
|
||||
{it.text && <div className={classes.reviewText}>{it.text}</div>}
|
||||
{it.author && <div className={classes.author}>{it.author}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showControls && (
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 flex items-center justify-between px-4">
|
||||
<div className="pointer-events-auto">
|
||||
<PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
"use client"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import React, { useState } from "react"
|
||||
|
||||
interface BenefitItem {
|
||||
icon?: string
|
||||
imgSrc?: string
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
titleClassName?: string
|
||||
descriptionClassName?: string
|
||||
}
|
||||
|
||||
export default function VtSubcription({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
|
||||
const [firstName, setFirstName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const classes = {
|
||||
container: props.className ?? "content-container",
|
||||
left: props.leftClassName ?? "",
|
||||
card: props.cardClassName ?? "rounded-2xl bg-[#CFECD9] p-8 small:p-12",
|
||||
title:
|
||||
props.titleClassName ??
|
||||
"text-white text-[28px] font-bold text-center",
|
||||
description:
|
||||
props.descriptionClassName ?? "mt-2 text-white",
|
||||
highlight: props.highlightClassName ?? "font-bold",
|
||||
form: props.formClassName ?? "mt-8 flex flex-col gap-6",
|
||||
fields: props.fieldsClassName ?? "grid grid-cols-1 small:grid-cols-2 gap-4",
|
||||
input:
|
||||
props.inputClassName ??
|
||||
"h-[52px] rounded-md border border-[#003F31]/40 px-4 bg-transparent text-white",
|
||||
checkboxRow: props.checkboxRowClassName ?? "flex items-center gap-3",
|
||||
checkbox:
|
||||
props.checkboxClassName ??
|
||||
"w-5 h-5 rounded-md border border-[#003F31]/60",
|
||||
checkboxLabel: props.checkboxLabelClassName ?? "text-white text-[16px]",
|
||||
subtextClass: props.subtextClassName ?? "text-white",
|
||||
submit:
|
||||
props.submitClassName ?? "",
|
||||
success: props.successClassName ?? "mt-4 text-center text-white",
|
||||
benefits:
|
||||
props.benefitsClassName ??
|
||||
"mt-8 grid grid-cols-1 small:grid-cols-3 gap-8",
|
||||
benefitItem:
|
||||
props.benefitItemClassName ??
|
||||
"flex flex-col items-center text-center gap-3",
|
||||
benefitIcon:
|
||||
props.benefitIconClassName ??
|
||||
"w-12 h-12 rounded-full bg-[#003F31] text-white flex items-center justify-center",
|
||||
benefitTitle: props.benefitTitleClassName ?? "text-white font-semibold",
|
||||
benefitDesc: props.benefitDescClassName ?? "text-white opacity-80",
|
||||
subtextSubcribe: props.subtextSubcribe ?? {},
|
||||
}
|
||||
|
||||
const submitConfig = props.cta ?? {}
|
||||
const policyLabel: string =
|
||||
props.policyLabel ?? "Ich habe die DSGVO gelesen und akzeptiere sie."
|
||||
const firstNameField = props.firstName ?? null
|
||||
const emailField = props.email ?? null
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!accepted) return
|
||||
setSubmitted(true)
|
||||
console.log("subscription_submit", { firstName, email, accepted })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classes.container}>
|
||||
{classes.left && <div className={classes.left}>
|
||||
half
|
||||
</div>}
|
||||
<div className={classes.card}>
|
||||
{props.title && <h2 className={classes.title}>{props.title}</h2>}
|
||||
{props.description && (
|
||||
<p className={classes.description}>
|
||||
{props.descriptionPrefix}{" "}
|
||||
<span className={classes.highlight}>
|
||||
{props.descriptionHighlight}
|
||||
</span>{" "}
|
||||
{props.descriptionSuffix}
|
||||
</p>
|
||||
)}
|
||||
{Array.isArray(props.benefits) && props.benefits.length > 0 && (
|
||||
<div className={classes.benefits}>
|
||||
{props.benefits.map((b: BenefitItem, i: number) => (
|
||||
<div
|
||||
key={`benefit-${i}`}
|
||||
className={clx(classes.benefitItem, b.className)}
|
||||
>
|
||||
{b.imgSrc ? (
|
||||
<img
|
||||
src={b.imgSrc}
|
||||
alt={b.title ?? `benefit-${i}`}
|
||||
className={clx(classes.benefitIcon, b.iconClassName)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clx(classes.benefitIcon, b.iconClassName)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{b.icon ?? ""}
|
||||
</div>
|
||||
)}
|
||||
{b.title && (
|
||||
<div className={clx(classes.benefitTitle, b.titleClassName)}>
|
||||
{b.title}
|
||||
</div>
|
||||
)}
|
||||
{b.description && (
|
||||
<div
|
||||
className={clx(classes.benefitDesc, b.descriptionClassName)}
|
||||
>
|
||||
{b.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{props.subtext && (
|
||||
<p
|
||||
className={clx(
|
||||
"mt-2 text-center", classes?.subtextClass ?? "text-[#003F31]"
|
||||
)}
|
||||
>
|
||||
{props.subtext}
|
||||
</p>
|
||||
)}
|
||||
<form className={classes.form} onSubmit={onSubmit}>
|
||||
<div className={classes.fields}>
|
||||
{firstNameField && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={firstNameField.placeholder ?? "Vorname"}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className={clx(classes.input, firstNameField.className)}
|
||||
/>
|
||||
)}
|
||||
{emailField && (
|
||||
<input
|
||||
type="email"
|
||||
placeholder={emailField.placeholder ?? "E-Mail-Adresse"}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={clx(classes.input, emailField.className)}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{props.newCheckboxRowClassName && (
|
||||
<label className={classes.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accepted}
|
||||
onChange={(e) => setAccepted(e.target.checked)}
|
||||
className={classes.checkbox}
|
||||
/>
|
||||
<span className={classes.checkboxLabel}>{policyLabel}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={clx(classes.submit, submitConfig.className)}
|
||||
>
|
||||
{submitConfig.label ?? "Anmelden"}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
{classes?.subtextSubcribe && (
|
||||
<div className={props.subtextSubcribe?.className}>
|
||||
{props.subtextSubcribe?.label}
|
||||
</div>
|
||||
)}
|
||||
{submitted && (
|
||||
<div className={classes.success}>
|
||||
{props.successMessage ??
|
||||
"Danke! Prüfe deine E-Mails für den Rabattcode."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { retrieveCart } from "@lib/data/cart"
|
||||
import CartDropdown from "../cart-dropdown"
|
||||
|
||||
export default async function CartButton({ iconName }: { iconName?: string }) {
|
||||
export default async function CartButton() {
|
||||
const cart = await retrieveCart().catch(() => null)
|
||||
|
||||
return <CartDropdown cart={cart} iconName={iconName} />
|
||||
return <CartDropdown cart={cart} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import {
|
|||
PopoverPanel,
|
||||
Transition,
|
||||
} from "@headlessui/react"
|
||||
import { ShoppingBag } from "@medusajs/icons"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import * as MedusaIcons from "@medusajs/icons"
|
||||
import DeleteButton from "@modules/common/components/delete-button"
|
||||
import LineItemOptions from "@modules/common/components/line-item-options"
|
||||
import LineItemPrice from "@modules/common/components/line-item-price"
|
||||
|
|
@ -20,10 +20,8 @@ import { Fragment, useEffect, useRef, useState } from "react"
|
|||
|
||||
const CartDropdown = ({
|
||||
cart: cartState,
|
||||
iconName,
|
||||
}: {
|
||||
cart?: HttpTypes.StoreCart | null
|
||||
iconName?: string
|
||||
}) => {
|
||||
const [activeTimer, setActiveTimer] = useState<NodeJS.Timer | undefined>(
|
||||
undefined
|
||||
|
|
@ -76,10 +74,6 @@ const CartDropdown = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [totalItems, itemRef.current])
|
||||
|
||||
const Icon = iconName
|
||||
? (MedusaIcons as Record<string, React.ComponentType<any>>)[iconName]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full z-50"
|
||||
|
|
@ -88,15 +82,13 @@ const CartDropdown = ({
|
|||
>
|
||||
<Popover className="relative h-full">
|
||||
<PopoverButton className="h-full">
|
||||
{Icon ? (
|
||||
<Icon />
|
||||
) : (
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base"
|
||||
className="hover:text-ui-fg-base mr-10 flex items-center"
|
||||
href="/cart"
|
||||
data-testid="nav-cart-link"
|
||||
>{`Cart (${totalItems})`}</LocalizedClientLink>
|
||||
)}
|
||||
>
|
||||
<ShoppingBag />
|
||||
</LocalizedClientLink>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
show={cartDropdownOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
"use client"
|
||||
import * as React from "react"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ChevronDownMini } from "@medusajs/icons"
|
||||
|
||||
interface NavigationMenuProps {
|
||||
props: {
|
||||
label: string,
|
||||
className: string,
|
||||
"data-testid": string,
|
||||
isShowArrow: boolean
|
||||
},
|
||||
menuItems: MenuSection[]
|
||||
}
|
||||
type MenuSection = {
|
||||
title: { text: string; className?: string }
|
||||
links: MenuLink[]
|
||||
}
|
||||
type MenuLink = { label: string; href: string; className?: string }
|
||||
|
||||
// Structured menu data used to map UI
|
||||
const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: { text: "Categories", className: "text-red" },
|
||||
links: [
|
||||
{
|
||||
label: "Overnight Oats",
|
||||
href: "/categories/overnight-oats",
|
||||
className: "text-red",
|
||||
},
|
||||
{ label: "Porridge", href: "/categories/porridge" },
|
||||
{ label: "Cereals", href: "/categories/cereals" },
|
||||
{ label: "Granola", href: "/categories/granola" },
|
||||
{ label: "Glasses & Bowls", href: "/categories/glasses-bowls" },
|
||||
{ label: "Oat Bars", href: "/categories/oat-bars" },
|
||||
{ label: "Nut butters", href: "/categories/nut-butters" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: { text: "Specials" },
|
||||
links: [
|
||||
{ label: "Advent calendar ✨", href: "/collections/advent-calendar" },
|
||||
{ label: "Saver subscription", href: "/collections/saver-subscription" },
|
||||
{ label: "bestseller", href: "/collections/bestseller" },
|
||||
{ label: "New 🔥", href: "/collections/new" },
|
||||
{ label: "Bluey Kidsrange", href: "/collections/bluey-kidsrange" },
|
||||
{ label: "Value sets", href: "/collections/value-sets" },
|
||||
{ label: "Sale", href: "/collections/sale" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: { text: "All products" },
|
||||
links: [{ label: "Shop all", href: "/store" }],
|
||||
},
|
||||
]
|
||||
|
||||
const MenuLinkItem = ({ href, label, className }: MenuLink) => (
|
||||
<li key={label}>
|
||||
<LocalizedClientLink
|
||||
href={href}
|
||||
className={clx(
|
||||
"block rounded-md py-2 hover:bg-ui-bg-subtle ",
|
||||
className ?? ""
|
||||
)}
|
||||
role="menuitem"
|
||||
>
|
||||
<div className="txt-small text-ui-fg-muted">{label}</div>
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
)
|
||||
|
||||
const MenuSectionItem = ({ title, links }: MenuSection) => (
|
||||
<div key={title.text} className="space-y-1">
|
||||
<div className={clx("txt-small text-ui-fg-muted", title.className ?? "")}>
|
||||
{title.text}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{links.map((link) => (
|
||||
<MenuLinkItem
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
label={link.label}
|
||||
className={link.className ?? ""}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
export default function NavigationMenu({
|
||||
props,
|
||||
menuItems = menuSections,
|
||||
}: NavigationMenuProps) {
|
||||
return (
|
||||
<div className="group relative h-full flex items-center">
|
||||
<LocalizedClientLink
|
||||
href="/"
|
||||
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base group-hover:text-ui-fg-base flex items-center gap-2 transition-colors duration-200"
|
||||
>
|
||||
{props.label} {props?.isShowArrow ? (
|
||||
<span className="transition-transform duration-400 ease-out group-hover:rotate-180">
|
||||
<ChevronDownMini />
|
||||
</span>
|
||||
) : null}
|
||||
</LocalizedClientLink>
|
||||
|
||||
<div
|
||||
role="menu"
|
||||
className="pointer-events-none left-0 fixed top-[180px] invisible opacity-0 transform-gpu translate-y-2 transition-all duration-600 ease-out group-hover:visible group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto group-focus-within:visible group-focus-within:opacity-100 group-focus-within:translate-y-0 group-focus-within:pointer-events-auto"
|
||||
>
|
||||
<div className="w-[100vw] bg-white shadow-borders-base py-4 px-20">
|
||||
<div className="grid grid-cols-1 small:grid-cols-3 gap-6">
|
||||
{menuItems.map((section) => (
|
||||
<MenuSectionItem
|
||||
key={section.title.text}
|
||||
title={section.title}
|
||||
links={section.links}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { MagnifyingGlassMini } from "@medusajs/icons"
|
||||
|
||||
type SearchButtonProps = {
|
||||
label?: string
|
||||
shortcut?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SearchButton({
|
||||
label = "Search",
|
||||
shortcut = "⌘K",
|
||||
onClick,
|
||||
className,
|
||||
}: SearchButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={
|
||||
[
|
||||
"w-[250px] h-11 justify-between gap-3 border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-field-hover rounded-lg",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<MagnifyingGlassMini className="text-ui-fg-base" />
|
||||
<span className="text-ui-fg-base">{label}</span>
|
||||
</span>
|
||||
<span className="flex items-center rounded-md border border-ui-border-base px-2 py-0.5 bg-white text-xs text-ui-fg-muted">
|
||||
{shortcut}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "vibentec/component-map"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import * as MedusaIcons from "@medusajs/icons"
|
||||
export const AccountButton = ({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) => {
|
||||
const props = nodes.config ?? {}
|
||||
const className = clx("hover:text-ui-fg-base", props.className)
|
||||
const style: React.CSSProperties = {}
|
||||
if (props.bgColor) style.backgroundColor = props.bgColor
|
||||
if (props.textColor) style.color = props.textColor
|
||||
const href = props.href ?? "/account"
|
||||
const label = props.label ?? "Account"
|
||||
const iconName = props.icon
|
||||
const Icon = iconName
|
||||
? (MedusaIcons as Record<string, React.ComponentType<any>>)[iconName]
|
||||
: undefined
|
||||
return (
|
||||
<div className="flex items-center h-full">
|
||||
<LocalizedClientLink
|
||||
href={href}
|
||||
className={className}
|
||||
data-testid="nav-account-link"
|
||||
>
|
||||
{Icon ? <Icon /> : label}
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountButton
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "vibentec/component-map"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { Suspense } from "react"
|
||||
import CartButton from "@modules/layout/components/cart-button"
|
||||
|
||||
export const VtCartButton = ({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) => {
|
||||
const props = nodes.config ?? {}
|
||||
const className = clx("hover:text-ui-fg-base flex gap-2", props.className)
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<LocalizedClientLink
|
||||
className={className}
|
||||
href="/cart"
|
||||
data-testid="nav-cart-link"
|
||||
>
|
||||
Cart (0)
|
||||
</LocalizedClientLink>
|
||||
}
|
||||
>
|
||||
<CartButton iconName={props.icon} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default VtCartButton
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||
import { clx } from "@medusajs/ui"
|
||||
|
||||
export const HomeButton = ({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) => {
|
||||
const props = nodes.config ?? {}
|
||||
const className = clx("txt-compact-xlarge-plus hover:text-ui-fg-base uppercase", props.className)
|
||||
const href = props.href ?? "/"
|
||||
const label = props.label ?? "Medusa Store"
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full">
|
||||
<LocalizedClientLink
|
||||
href={href}
|
||||
className={className}
|
||||
data-testid="nav-store-link"
|
||||
>
|
||||
{label}
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeButton
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||
import { clx } from "@medusajs/ui"
|
||||
|
||||
interface VtLinkProps {
|
||||
className?: string
|
||||
href?: string
|
||||
label?: string
|
||||
}
|
||||
export const VtLink = ({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) => {
|
||||
const props = nodes.config as VtLinkProps ?? {}
|
||||
const className = clx("txt-compact-xlarge-plus hover:text-ui-fg-base", props.className)
|
||||
const href = props.href ?? "/"
|
||||
const label = props.label ?? "Medusa Store"
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full">
|
||||
<LocalizedClientLink
|
||||
href={href}
|
||||
className={className}
|
||||
>
|
||||
{label}
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VtLink
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "vibentec/component-map"
|
||||
import React, { Suspense } from "react"
|
||||
import SkeletonMegaMenu from "@modules/skeletons/components/vt-skeleton-mega-menu"
|
||||
import MegaMenuWrapper from "@modules/layout/components/vt-mega-menu/mega-menu-wrapper"
|
||||
|
||||
interface MegaMenuProps {
|
||||
navLabel: {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
}
|
||||
export default function VtMegaMenu({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const { navLabel } = nodes.config as MegaMenuProps ?? {}
|
||||
return (
|
||||
<nav>
|
||||
<ul className="space-x-4 hidden small:flex">
|
||||
<li>
|
||||
<Suspense fallback={<SkeletonMegaMenu />}>
|
||||
<MegaMenuWrapper navLabel={navLabel} />
|
||||
</Suspense>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { listCategories } from "@lib/data/categories"
|
||||
import MegaMenu from "./mega-menu"
|
||||
|
||||
|
||||
|
||||
export async function MegaMenuWrapper({ navLabel }: { navLabel: { text: string; className?: string } }) {
|
||||
const categories = await listCategories().catch(() => [])
|
||||
|
||||
return <MegaMenu navLabel={navLabel} categories={categories} />
|
||||
}
|
||||
|
||||
export default MegaMenuWrapper
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
|
||||
const MegaMenu = ({
|
||||
navLabel,
|
||||
categories,
|
||||
}: {
|
||||
navLabel: { text: string; className?: string, isShowArrow?: boolean },
|
||||
categories: HttpTypes.StoreProductCategory[]
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [selectedCategory, setSelectedCategory] = useState<
|
||||
HttpTypes.StoreProductCategory["id"] | null
|
||||
>(null)
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
const mainCategories = categories.filter(
|
||||
(category) => !category.parent_category_id
|
||||
)
|
||||
|
||||
const getSubCategories = (categoryId: string) => {
|
||||
return categories.filter(
|
||||
(category) => category.parent_category_id === categoryId
|
||||
)
|
||||
}
|
||||
|
||||
let menuTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
const handleMenuHover = () => {
|
||||
if (menuTimeout) {
|
||||
clearTimeout(menuTimeout)
|
||||
}
|
||||
setIsHovered(true)
|
||||
}
|
||||
|
||||
const handleMenuLeave = () => {
|
||||
menuTimeout = setTimeout(() => {
|
||||
setIsHovered(false)
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (menuTimeout) {
|
||||
clearTimeout(menuTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let categoryTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
const handleCategoryHover = (categoryId: string) => {
|
||||
categoryTimeout = setTimeout(() => {
|
||||
setSelectedCategory(categoryId)
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
if (categoryTimeout) {
|
||||
clearTimeout(categoryTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCategoryLeave = () => {
|
||||
if (categoryTimeout) {
|
||||
clearTimeout(categoryTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsHovered(false)
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onMouseEnter={handleMenuHover}
|
||||
onMouseLeave={handleMenuLeave}
|
||||
className="z-50"
|
||||
>
|
||||
<LocalizedClientLink
|
||||
className={clx(
|
||||
"hover:text-ui-fg-base hover:bg-neutral-100 rounded-full px-3 py-2",
|
||||
navLabel?.className
|
||||
)}
|
||||
href="/store"
|
||||
>
|
||||
{navLabel?.text ?? "Product"} {navLabel?.isShowArrow && <ChevronDown />}
|
||||
</LocalizedClientLink>
|
||||
{isHovered && (
|
||||
<div className="absolute top-full left-0 right-0 flex gap-32 py-10 px-20 bg-white border-b border-neutral-200 ">
|
||||
<div className="flex flex-col gap-2">
|
||||
{mainCategories.map((category) => (
|
||||
<LocalizedClientLink
|
||||
key={category.id}
|
||||
href={`/categories/${category.handle}`}
|
||||
className={clx(
|
||||
"hover:bg-neutral-100 text-black hover:cursor-pointer rounded-full px-3 py-2 w-fit font-medium",
|
||||
selectedCategory === category.id && "bg-neutral-100"
|
||||
)}
|
||||
onMouseEnter={() => handleCategoryHover(category.id)}
|
||||
onMouseLeave={handleCategoryLeave}
|
||||
>
|
||||
{category.name}
|
||||
</LocalizedClientLink>
|
||||
))}
|
||||
</div>
|
||||
{selectedCategory && (
|
||||
<div className="grid grid-cols-4 gap-16">
|
||||
{getSubCategories(selectedCategory).map((category) => (
|
||||
<div key={category.id} className="flex flex-col gap-2">
|
||||
<LocalizedClientLink
|
||||
className="font-medium text-zinc-500 hover:underline"
|
||||
href={`/categories/${category.handle}`}
|
||||
>
|
||||
{category.name}
|
||||
</LocalizedClientLink>
|
||||
<div className="flex flex-col gap-2">
|
||||
{getSubCategories(category.id).map((subCategory) => (
|
||||
<LocalizedClientLink
|
||||
key={subCategory.id}
|
||||
className="hover:underline"
|
||||
href={`/categories/${subCategory.handle}`}
|
||||
>
|
||||
{subCategory.name}
|
||||
</LocalizedClientLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isHovered && (
|
||||
<div className="fixed inset-0 mt-[60px] blur-sm backdrop-blur-sm z-[-1]" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MegaMenu
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||
import React from "react";
|
||||
import SideMenu from "../side-menu";
|
||||
import { listRegions } from "@lib/data/regions"
|
||||
import { StoreRegion } from "@medusajs/types"
|
||||
|
||||
export default async function VtSideMenu({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
|
||||
const regions = await listRegions().then((regions: StoreRegion[]) => regions)
|
||||
return (
|
||||
<div className="flex-1 basis-0 h-full flex items-center">
|
||||
<div className="h-full">
|
||||
<SideMenu regions={regions} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
type TextItem = { text: string; className?: string }
|
||||
|
||||
export default async function AnnouncementBanner({
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: { className: string; label: TextItem[] }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="container mx-auto flex justify-center items-center py-2">
|
||||
<div className="flex items-center gap-20">
|
||||
|
||||
{label.map((item, index) => {
|
||||
return (
|
||||
<div key={`${index}-${item.text}`} className={`last:mr-0 ${item.className || ""}`}>
|
||||
{item.text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
'use client'
|
||||
import { DropdownMenu } from "@medusajs/ui"
|
||||
import { ChevronDownMini } from "@medusajs/icons"
|
||||
|
||||
interface DropdownMenuProps {
|
||||
className?: string
|
||||
"data-testid"?: string
|
||||
label?: string
|
||||
isShowArrow?: boolean
|
||||
}
|
||||
export default function DropdownMenuComponent({
|
||||
children,
|
||||
props,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
props?: DropdownMenuProps
|
||||
}) {
|
||||
const itemNodes = Array.isArray(children) ? children : []
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
className={props?.className}
|
||||
data-testid={props?.["data-testid"]}
|
||||
>
|
||||
{props?.label ?? "Menu"} {props?.isShowArrow ? <ChevronDownMini /> : null}
|
||||
</button>
|
||||
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content className="abc">
|
||||
{itemNodes.map((item, index) => {
|
||||
const props = item?.DropdownMenuItems?.props
|
||||
return props ? <DropdownMenu.Item key={props.label ?? index} className={props.className} data-testid={props["data-testid"]}>{props.label}</DropdownMenu.Item> : null
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
type TextItem = { text: string; className?: string }
|
||||
|
||||
export default async function AnnouncementBanner({
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: { className: string; label: TextItem[] }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="container mx-auto flex justify-center items-center py-2">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
{label.map((item, index) => {
|
||||
return (
|
||||
<div key={`${index}-${item.text}`} className={`last:mr-0 ${item.className || ""}`}>
|
||||
{item.text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { Github } from "@medusajs/icons"
|
||||
import { Button, Heading } from "@medusajs/ui"
|
||||
import { VtCarousel } from "../vt-carousel"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map"
|
||||
|
||||
export default function HeroDefault({ node, context }: { node: LayoutComponentDefinition; context: LayoutContext }) {
|
||||
const props = node.config ?? {}
|
||||
const imageDisplayer = props.ImageDisplayer
|
||||
|
||||
if (imageDisplayer) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-auto w-full h-full">
|
||||
<VtCarousel nodes={{ config: imageDisplayer.config }} context={context} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-auto flex flex-col justify-center items-center text-center small:p-32 gap-6">
|
||||
<span>
|
||||
<Heading
|
||||
level="h1"
|
||||
className="text-3xl leading-10 text-ui-fg-base font-normal"
|
||||
>
|
||||
Ecommerce Starter Template
|
||||
</Heading>
|
||||
<Heading
|
||||
level="h2"
|
||||
className="text-3xl leading-10 text-ui-fg-subtle font-normal"
|
||||
>
|
||||
Powered by Medusa and Next.js
|
||||
</Heading>
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/medusajs/nextjs-starter-medusa"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="secondary">
|
||||
View on GitHub
|
||||
<Github />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "vibentec/component-map"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import BannerHero from "./banner-hero"
|
||||
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||
|
||||
export default async function Hero({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
const left = nodes.config?.left ?? []
|
||||
const center = nodes.config?.center ?? []
|
||||
const right = nodes.config?.right ?? []
|
||||
const heroClassName = clx(
|
||||
"min-h-[30rem] w-full border-b border-ui-border-base relative",
|
||||
props.className
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={heroClassName}>
|
||||
<BannerHero node={nodes} context={context} />
|
||||
<div className="absolute z-20">
|
||||
<nav className="content-container txt-xsmall-plus flex items-center justify-between text-small-regular">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{left && <DynamicLayoutRenderer nodes={left} context={context} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
{center && (
|
||||
<DynamicLayoutRenderer nodes={center} context={context} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 justify-end">
|
||||
{right && <DynamicLayoutRenderer nodes={right} context={context} />}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { DynamicLayoutRenderer, DynamicLayoutRendererProps } from "vibentec/renderer"
|
||||
|
||||
type TextItem = { text: string; className?: string }
|
||||
|
||||
export default async function AnnouncementBanner({
|
||||
className,
|
||||
nodes,
|
||||
context,
|
||||
}: DynamicLayoutRendererProps & { className: string; label: TextItem[] }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="w-full mx-auto flex justify-between items-center py-2 px-20">
|
||||
{nodes && <DynamicLayoutRenderer nodes={nodes} context={context} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
|
||||
interface BannerCTAProps {
|
||||
node: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}
|
||||
|
||||
interface BannerConfigProps {
|
||||
text?: string
|
||||
href?: string
|
||||
iconLeft?: string
|
||||
iconRight?: string
|
||||
className?: string
|
||||
}
|
||||
export default function BannerCTA({ node, context }: BannerCTAProps) {
|
||||
const props = node.config as BannerConfigProps ?? {}
|
||||
const text = props.text ?? ""
|
||||
const href = props.href ?? undefined
|
||||
const iconLeft = props.iconLeft
|
||||
const iconRight = props.iconRight
|
||||
|
||||
const className = clx(
|
||||
"content-container flex justify-center items-center text-center w-full h-full text-xs font-medium",
|
||||
props.className
|
||||
)
|
||||
|
||||
const content = (
|
||||
<div className="flex items-center gap-2">
|
||||
{iconLeft && <DynamicIcon name={iconLeft} />}
|
||||
<div className="leading-none">
|
||||
<ReactMarkdown
|
||||
children={text}
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
/>
|
||||
</div>
|
||||
{iconRight && <DynamicIcon name={iconRight} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
return href ? (
|
||||
<LocalizedClientLink href={href} className={className}>
|
||||
{content}
|
||||
</LocalizedClientLink>
|
||||
) : (
|
||||
<div className={className}>{content}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DynamicIcon({ name }: { name: string }) {
|
||||
return <span className="material-symbols-outlined text-sm">{name}</span>
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||
|
||||
interface BannerNavProps {
|
||||
left?: LayoutComponentDefinition[]
|
||||
center?: LayoutComponentDefinition[]
|
||||
right?: LayoutComponentDefinition[]
|
||||
}
|
||||
export default function BannerNav({ node, context }: { node: LayoutComponentDefinition; context: LayoutContext }) {
|
||||
const props = node.config as BannerNavProps ?? {};
|
||||
|
||||
return (
|
||||
<nav className="content-container txt-xsmall-plus flex items-center justify-between w-full h-full text-small-regular">
|
||||
<div className="flex items-center gap-x-4 w-full h-full">
|
||||
{props.left && <DynamicLayoutRenderer nodes={props.left} context={context} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 w-full h-full">
|
||||
{props.center && <DynamicLayoutRenderer nodes={props.center} context={context} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 w-full h-full justify-end">
|
||||
{props.right && <DynamicLayoutRenderer nodes={props.right} context={context} />}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
@keyframes bannerTicker {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.ticker {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
animation: bannerTicker linear infinite;
|
||||
height: 100%;
|
||||
gap: 3rem
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import styles from "./banner-ticker.module.css"
|
||||
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map"
|
||||
|
||||
interface BannerTickerProps {
|
||||
node: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}
|
||||
interface BannerConfigProps {
|
||||
items?: LayoutComponentDefinition[]
|
||||
speed?: number
|
||||
}
|
||||
export default function BannerTicker({ node, context }: BannerTickerProps) {
|
||||
const props = node.config as BannerConfigProps ?? {}
|
||||
const speed = props.speed ?? 10;
|
||||
return (
|
||||
<div className="relative overflow-hidden w-full h-full">
|
||||
<div className={styles.ticker} style={{ animationDuration: `${speed}s` }} >
|
||||
<DynamicLayoutRenderer nodes={props.items ?? []} context={context} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "vibentec/component-map"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import BannerNav from "./banner-nav"
|
||||
import BannerCTA from "./banner-cta"
|
||||
import BannerTicker from "./banner-ticker"
|
||||
|
||||
interface BannerProps {
|
||||
variant: "nav" | "cta" | "ticker"
|
||||
className?: string
|
||||
speed?: number
|
||||
}
|
||||
export default async function Banner({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = (nodes.config as BannerProps) ?? {}
|
||||
const bannerClassName = clx(
|
||||
"relative h-8 mx-auto border-b duration-200 bg-white border-ui-border-base",
|
||||
props.className
|
||||
)
|
||||
if (!props.variant) return null
|
||||
|
||||
const variants = {
|
||||
nav: BannerNav,
|
||||
cta: BannerCTA,
|
||||
ticker: BannerTicker,
|
||||
}
|
||||
|
||||
const Component = variants[props.variant]
|
||||
|
||||
return (
|
||||
<div className={bannerClassName}>
|
||||
<Component node={nodes} context={context} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { Button, IconButton } from "@medusajs/ui"
|
||||
import * as MedusaIcons from "@medusajs/icons"
|
||||
import * as CustomIcons from "@modules/common/icons"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
|
||||
export default function VtButton({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config || {}
|
||||
const iconName = props.icon as string | undefined
|
||||
const IconComponent = iconName
|
||||
? (MedusaIcons as Record<string, any>)[iconName] ??
|
||||
(CustomIcons as Record<string, any>)[iconName]
|
||||
: undefined
|
||||
return (
|
||||
<IconButton className={props?.className ?? ""}>
|
||||
{IconComponent && (
|
||||
<IconComponent className={props?.iconClassName ?? ""} />
|
||||
)}
|
||||
{props?.label && (
|
||||
<span className={props?.labelClassName ?? ""}>{props.label}</span>
|
||||
)}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import style from './index.module.css'
|
||||
export const usePrevNextButtons = (emblaApi: any) => {
|
||||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
|
||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
|
||||
|
||||
const onPrevButtonClick = useCallback(() => {
|
||||
if (!emblaApi) return
|
||||
emblaApi.scrollPrev()
|
||||
}, [emblaApi])
|
||||
|
||||
const onNextButtonClick = useCallback(() => {
|
||||
if (!emblaApi) return
|
||||
emblaApi.scrollNext()
|
||||
}, [emblaApi])
|
||||
|
||||
const onSelect = useCallback((emblaApi: any) => {
|
||||
setPrevBtnDisabled(!emblaApi.canScrollPrev())
|
||||
setNextBtnDisabled(!emblaApi.canScrollNext())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
|
||||
onSelect(emblaApi)
|
||||
emblaApi.on('reInit', onSelect).on('select', onSelect)
|
||||
}, [emblaApi, onSelect])
|
||||
|
||||
return {
|
||||
prevBtnDisabled,
|
||||
nextBtnDisabled,
|
||||
onPrevButtonClick,
|
||||
onNextButtonClick
|
||||
}
|
||||
}
|
||||
|
||||
export const PrevButton = (props: any) => {
|
||||
const { children, ...restProps } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
className={style['embla__button']}
|
||||
type="button"
|
||||
{...restProps}
|
||||
>
|
||||
<svg className={style['embla__button__svg']} viewBox="0 0 532 532">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M355.66 11.354c13.793-13.805 36.208-13.805 50.001 0 13.785 13.804 13.785 36.238 0 50.034L201.22 266l204.442 204.61c13.785 13.805 13.785 36.239 0 50.044-13.793 13.796-36.208 13.796-50.002 0a5994246.277 5994246.277 0 0 0-229.332-229.454 35.065 35.065 0 0 1-10.326-25.126c0-9.2 3.393-18.26 10.326-25.2C172.192 194.973 332.731 34.31 355.66 11.354Z"
|
||||
/>
|
||||
</svg>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const NextButton = (props: any) => {
|
||||
const { children, ...restProps } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
className={style['embla__button']}
|
||||
type="button"
|
||||
{...restProps}
|
||||
>
|
||||
<svg className={style['embla__button__svg']} viewBox="0 0 532 532">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M176.34 520.646c-13.793 13.805-36.208 13.805-50.001 0-13.785-13.804-13.785-36.238 0-50.034L330.78 266 126.34 61.391c-13.785-13.805-13.785-36.239 0-50.044 13.793-13.796 36.208-13.796 50.002 0 22.928 22.947 206.395 206.507 229.332 229.454a35.065 35.065 0 0 1 10.326 25.126c0 9.2-3.393 18.26-10.326 25.2-45.865 45.901-206.404 206.564-229.332 229.52Z"
|
||||
/>
|
||||
</svg>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export const useDotButton = (emblaApi: any) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [scrollSnaps, setScrollSnaps] = useState([])
|
||||
|
||||
const onDotButtonClick = useCallback(
|
||||
(index: number) => {
|
||||
if (!emblaApi) return
|
||||
emblaApi.scrollTo(index)
|
||||
},
|
||||
[emblaApi]
|
||||
)
|
||||
|
||||
const onInit = useCallback((emblaApi: any) => {
|
||||
setScrollSnaps(emblaApi.scrollSnapList())
|
||||
}, [])
|
||||
|
||||
const onSelect = useCallback((emblaApi: any) => {
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
|
||||
onInit(emblaApi)
|
||||
onSelect(emblaApi)
|
||||
emblaApi.on('reInit', onInit).on('reInit', onSelect).on('select', onSelect)
|
||||
}, [emblaApi, onInit, onSelect])
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
scrollSnaps,
|
||||
onDotButtonClick
|
||||
}
|
||||
}
|
||||
|
||||
export const DotButton = (props: any) => {
|
||||
const { children, ...restProps } = props
|
||||
|
||||
return (
|
||||
<button type="button" {...restProps}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
.embla {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
--slide-height: 19rem;
|
||||
--slide-spacing: 1rem;
|
||||
--slide-size: 100%;
|
||||
}
|
||||
.embla__viewport {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.embla__container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
margin-left: calc(var(--slide-spacing) * -1);
|
||||
--slide-spacing: 1rem;
|
||||
}
|
||||
.embla__slide {
|
||||
--slide-size: 100%;
|
||||
--slide-spacing: 1rem;
|
||||
transform: translate3d(0, 0, 0);
|
||||
flex: 0 0 var(--slide-size);
|
||||
min-width: 0;
|
||||
padding-left: var(--slide-spacing);
|
||||
}
|
||||
.embla__slide__number {
|
||||
height: 100%;
|
||||
font-size: 4rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
.embla__slide__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.embla__controls {
|
||||
display: grid;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
grid-template-columns: auto 1fr;
|
||||
justify-content: space-between;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
.embla__buttons {
|
||||
position: absolute;
|
||||
top: 45%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.embla__button {
|
||||
--text-high-contrast-rgb-value: 49, 49, 49;
|
||||
--detail-high-contrast: rgb(192, 192, 192);
|
||||
--text-body: rgb(54, 49, 61);
|
||||
-webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
touch-action: manipulation;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 3.6rem;
|
||||
height: 3.6rem;
|
||||
z-index: 1001;
|
||||
border-radius: 50%;
|
||||
color: var(--text-body);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.embla__button:disabled {
|
||||
--detail-high-contrast: rgb(192, 192, 192);
|
||||
color: var(--detail-high-contrast);
|
||||
}
|
||||
.embla__button__svg {
|
||||
width: 35%;
|
||||
height: 35%;
|
||||
}
|
||||
.embla__dots {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 48%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.embla__dot {
|
||||
--text-high-contrast-rgb-value: 49, 49, 49;
|
||||
--text-body: rgb(54, 49, 61);
|
||||
-webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
touch-action: manipulation;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.embla__dot:after {
|
||||
--detail-medium-contrast: rgb(234, 234, 234);
|
||||
--detail-medium-contrast-rgb-value: 234, 234, 234;
|
||||
box-shadow: inset 0 0 0 0.2rem var(--detail-medium-contrast);
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
content: "";
|
||||
}
|
||||
.embla__dot--selected:after {
|
||||
--text-body: black;
|
||||
box-shadow: inset 0 0 0 0.2rem var(--text-body);
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"use client"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import styles from "./index.module.css"
|
||||
import useEmblaCarousel from "embla-carousel-react"
|
||||
import Autoplay from "embla-carousel-autoplay"
|
||||
import { useMemo } from "react"
|
||||
import { DotButton, useDotButton } from "./carousel-dot-button"
|
||||
import { NextButton, PrevButton, usePrevNextButtons } from "./carousel-arrow-button"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
|
||||
export function VtCarousel({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
const { options } = props as any
|
||||
const images: string[] = props.images ?? props.slides ?? []
|
||||
const links: (string | undefined)[] = props.links ?? []
|
||||
const durationSeconds: number = props.duration ?? 4
|
||||
const showControls = images.length > 1
|
||||
|
||||
const plugins = useMemo(() => {
|
||||
if (!durationSeconds || durationSeconds <= 0) return []
|
||||
return [
|
||||
Autoplay({
|
||||
delay: durationSeconds * 1000,
|
||||
stopOnInteraction: false,
|
||||
stopOnMouseEnter: true,
|
||||
}),
|
||||
]
|
||||
}, [durationSeconds])
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(options, plugins)
|
||||
const { selectedIndex, scrollSnaps, onDotButtonClick } =
|
||||
useDotButton(emblaApi)
|
||||
|
||||
const {
|
||||
prevBtnDisabled,
|
||||
nextBtnDisabled,
|
||||
onPrevButtonClick,
|
||||
onNextButtonClick,
|
||||
} = usePrevNextButtons(emblaApi)
|
||||
return (
|
||||
<section className={styles["embla"]}>
|
||||
<div className={styles["embla__viewport"]} ref={emblaRef}>
|
||||
<div className={styles["embla__container"]}>
|
||||
{images && images.map((src: string, index: number) => (
|
||||
<div className={styles["embla__slide"]} key={index + src}>
|
||||
<div className={styles["embla__slide__number"]}>
|
||||
{links[index] ? (
|
||||
<LocalizedClientLink href={links[index]}>
|
||||
<img src={src} alt={`slide-${index + 1}`} className={styles["embla__slide__image"]} />
|
||||
</LocalizedClientLink>
|
||||
) : (
|
||||
<img src={src} alt={`slide-${index + 1}`} className={styles["embla__slide__image"]} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showControls && (
|
||||
<div className={styles["embla__controls"]}>
|
||||
<div className={styles["embla__buttons"]}>
|
||||
<PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} />
|
||||
<NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} />
|
||||
</div>
|
||||
|
||||
<div className={styles["embla__dots"]}>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<DotButton
|
||||
key={index}
|
||||
onClick={() => onDotButtonClick(index)}
|
||||
className={[
|
||||
styles["embla__dot"],
|
||||
index === selectedIndex ? styles["embla__dot--selected"] : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
"use client"
|
||||
import { Select } from "@medusajs/ui"
|
||||
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import { useMemo, useState, useEffect } from "react"
|
||||
import { useParams, usePathname, useRouter } from "next/navigation"
|
||||
import ReactCountryFlag from "react-country-flag"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
export default function VtCountrySelectClient({
|
||||
nodes,
|
||||
context,
|
||||
regions,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
regions?: HttpTypes.StoreRegion[]
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
const triggerText = props?.trigger?.text
|
||||
const [items, setItems] = useState<{ text: string; label?: string }[]>([])
|
||||
const { countryCode } = useParams()
|
||||
useEffect(() => {
|
||||
if (!regions || regions.length === 0) {
|
||||
setItems([])
|
||||
return
|
||||
}
|
||||
const opts = regions
|
||||
.map((r) =>
|
||||
(r.countries || []).map((c) => ({
|
||||
text: c.iso_2 ?? "",
|
||||
label: c.display_name,
|
||||
}))
|
||||
)
|
||||
.flat()
|
||||
.filter((o) => o.text)
|
||||
.sort((a, b) => (a.label ?? "").localeCompare(b.label ?? ""))
|
||||
setItems(opts)
|
||||
}, [regions])
|
||||
|
||||
const initialValue = (countryCode as string) || triggerText || ""
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
setValue(next)
|
||||
if (!pathname || !next) return
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1) {
|
||||
parts[1] = next.toLowerCase()
|
||||
const newPath = parts.join("/")
|
||||
router.replace(newPath)
|
||||
}
|
||||
}
|
||||
const selectedItem = useMemo(() => {
|
||||
return items.find((i) => i.text === value)
|
||||
}, [items, value])
|
||||
if (!triggerText && items.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Select value={value} onValueChange={handleChange}>
|
||||
<Select.Trigger
|
||||
className={
|
||||
(props.trigger?.className ?? "") +
|
||||
"flex items-center gap-1 [&_svg:not(:first-of-type)]:hidden"
|
||||
}
|
||||
>
|
||||
<span className="txt-compact-small flex items-center">
|
||||
{/* @ts-ignore */}
|
||||
{props.trigger?.isFlag && (
|
||||
<ReactCountryFlag
|
||||
svg
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
}}
|
||||
countryCode={value ?? ""}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{props.trigger?.isDisplayFullname ? (selectedItem?.label || value) : (selectedItem?.text.toUpperCase() || value)} <ChevronDown />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{items.length > 0 &&
|
||||
items.map((item: { text: string; label?: string }, index: number) => (
|
||||
<Select.Item value={item.text || ""} key={item.text + index}>
|
||||
<div className="flex items-center w-full gap-3">
|
||||
{props.trigger?.isFlag && item.text && (
|
||||
<ReactCountryFlag
|
||||
svg
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
}}
|
||||
countryCode={item.text ?? ""}
|
||||
/>
|
||||
)}
|
||||
{item.text.toUpperCase()}
|
||||
</div>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { listRegions, getRegion } from "@lib/data/regions"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import VtCountryCodeSelectClient from "./index"
|
||||
|
||||
export default async function VtCountryCodeSelect({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const regions = (await listRegions()) as HttpTypes.StoreRegion[]
|
||||
|
||||
return (
|
||||
<VtCountryCodeSelectClient nodes={nodes} context={context} regions={regions} />
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
"use client"
|
||||
import { Button, clx } from "@medusajs/ui"
|
||||
import { ChevronRightMini } from "@medusajs/icons"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
|
||||
export function DefaultCtaBanner({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"relative w-[544px] bg-white rounded-[24px] border border-[#E6EFFC] shadow-[0_12px_40px_rgba(17,49,78,0.10)] p-6",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.tagText && (
|
||||
<div
|
||||
className={clx(
|
||||
"inline-flex items-center rounded-full bg-[#FCE9DA] text-[#E68445] px-3 py-1 text-sm font-medium",
|
||||
props.tagTextClassName
|
||||
)}
|
||||
>
|
||||
{props.tagText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.titleText && (
|
||||
<h1
|
||||
className={clx(
|
||||
"mt-4 text-[#11314E] font-semibold leading-normal text-[56px]",
|
||||
props.titleTextClassName
|
||||
)}
|
||||
>
|
||||
{props.titleText}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{props.descriptionText && (
|
||||
<p
|
||||
className={clx(
|
||||
"mt-5 text-[#285A86] text-[16px] sm:text-xl opacity-80",
|
||||
props.descriptionTextClassName
|
||||
)}
|
||||
>
|
||||
{props.descriptionText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className={clx(
|
||||
"mt-8 inline-flex items-center gap-2 bg-[#0F2740] hover:bg-[#173551] text-white px-6 py-3 rounded-[12px] shadow-md",
|
||||
props.buttonTextClassName
|
||||
)}
|
||||
>
|
||||
{props.buttonText}
|
||||
<ChevronRightMini />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
"use client"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import { DefaultCtaBanner } from "./default-cta"
|
||||
|
||||
export function VtCtaBanner({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
const variant = props.variant ?? "default"
|
||||
|
||||
const variants: Record<string, any> = {
|
||||
default: DefaultCtaBanner,
|
||||
}
|
||||
|
||||
const Component = variants[variant] || DefaultCtaBanner
|
||||
|
||||
return <Component nodes={nodes} context={context} />
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
"use client"
|
||||
import { Select } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
|
||||
export default function VtCurrencySelect({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
if (!props.trigger.text || props.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Select>
|
||||
<Select.Trigger
|
||||
className={props.trigger.className + " flex items-center gap-1"}
|
||||
>
|
||||
{props.trigger.icon && (
|
||||
<img
|
||||
src={props.trigger.icon}
|
||||
alt={props.trigger.text}
|
||||
className="w-5 h-5 rounded-[50%] mr-3"
|
||||
/>
|
||||
)}
|
||||
{props.trigger.text} {props.trigger.isShowArrow && <ChevronDown />}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{props.items.length > 0 &&
|
||||
props.items.map(
|
||||
(
|
||||
item: {
|
||||
text: string
|
||||
className?: string
|
||||
href?: string
|
||||
icon?: string
|
||||
},
|
||||
index: number
|
||||
) => (
|
||||
<Select.Item
|
||||
value={item.text || ""}
|
||||
key={item.text + index}
|
||||
className={item.className || ""}
|
||||
>
|
||||
{item.icon && (
|
||||
<img
|
||||
src={item.icon}
|
||||
alt={item.text}
|
||||
className="w-5 h-5 rounded-[50%] mr-3"
|
||||
/>
|
||||
)}
|
||||
{item.href ? (
|
||||
<LocalizedClientLink href={item.href}>
|
||||
{item.text}
|
||||
</LocalizedClientLink>
|
||||
) : (
|
||||
item.text
|
||||
)}
|
||||
</Select.Item>
|
||||
)
|
||||
)}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
"use client"
|
||||
import { DropdownMenu } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
|
||||
export default function VtDropdown({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
if (!props.trigger.text || props.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
className={props.trigger.className + " flex items-center gap-1"}
|
||||
>
|
||||
{props.trigger.icon && (
|
||||
<img
|
||||
src={props.trigger.icon}
|
||||
alt={props.trigger.text}
|
||||
className="w-5 h-5 rounded-[50%] mr-3"
|
||||
/>
|
||||
)}
|
||||
{props.trigger.text} {props.trigger.isShowArrow && <ChevronDown />}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{props.items.length > 0 &&
|
||||
props.items.map(
|
||||
(
|
||||
item: {
|
||||
text: string
|
||||
className?: string
|
||||
href?: string
|
||||
icon?: string
|
||||
},
|
||||
index: number
|
||||
) => (
|
||||
<DropdownMenu.Item
|
||||
key={item.text + index}
|
||||
className={item.className || ""}
|
||||
>
|
||||
{item.icon && (
|
||||
<img
|
||||
src={item.icon}
|
||||
alt={item.text}
|
||||
className="w-5 h-5 rounded-[50%] mr-3"
|
||||
/>
|
||||
)}
|
||||
{item.href ? (
|
||||
<LocalizedClientLink href={item.href}>
|
||||
{item.text}
|
||||
</LocalizedClientLink>
|
||||
) : (
|
||||
item.text
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,44 +1,158 @@
|
|||
import { listCategories } from "@lib/data/categories"
|
||||
import { listCollections } from "@lib/data/collections"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import { FooterProps } from "vibentec/component-props"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import MedusaCTA from "@modules/layout/components/medusa-cta"
|
||||
import { DynamicLayoutRenderer } from "@vibentec/renderer"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "vibentec/component-map"
|
||||
|
||||
export default async function VtFooter({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes?: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
|
||||
export default async function VtFooter({copyrightText}:FooterProps) {
|
||||
const { collections } = await listCollections({
|
||||
fields: "*products",
|
||||
})
|
||||
const productCategories = await listCategories()
|
||||
const props = nodes?.config ?? {}
|
||||
|
||||
return (
|
||||
<footer className={props?.className ?? ""}>
|
||||
{props?.left && (
|
||||
<div className={clx("flex h-full", props?.leftClassName)}>
|
||||
<DynamicLayoutRenderer nodes={props?.left} context={context} />
|
||||
<footer className="border-t border-ui-border-base w-full">
|
||||
<div className="content-container flex flex-col w-full">
|
||||
<div className="flex flex-col gap-y-6 xsmall:flex-row items-start justify-between py-40">
|
||||
<div>
|
||||
<LocalizedClientLink
|
||||
href="/"
|
||||
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base uppercase"
|
||||
>
|
||||
Medusa Store
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
<div className="text-small-regular gap-10 md:gap-x-16 grid grid-cols-2 sm:grid-cols-3">
|
||||
{productCategories && productCategories?.length > 0 && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<span className="txt-small-plus txt-ui-fg-base">
|
||||
Categories
|
||||
</span>
|
||||
<ul
|
||||
className="grid grid-cols-1 gap-2"
|
||||
data-testid="footer-categories"
|
||||
>
|
||||
{productCategories?.slice(0, 6).map((c) => {
|
||||
if (c.parent_category) {
|
||||
return
|
||||
}
|
||||
|
||||
const children =
|
||||
c.category_children?.map((child) => ({
|
||||
name: child.name,
|
||||
handle: child.handle,
|
||||
id: child.id,
|
||||
})) || null
|
||||
|
||||
return (
|
||||
<li
|
||||
className="flex flex-col gap-2 text-ui-fg-subtle txt-small"
|
||||
key={c.id}
|
||||
>
|
||||
<LocalizedClientLink
|
||||
className={clx(
|
||||
"hover:text-ui-fg-base",
|
||||
children && "txt-small-plus"
|
||||
)}
|
||||
href={`/categories/${c.handle}`}
|
||||
data-testid="category-link"
|
||||
>
|
||||
{c.name}
|
||||
</LocalizedClientLink>
|
||||
{children && (
|
||||
<ul className="grid grid-cols-1 ml-3 gap-2">
|
||||
{children &&
|
||||
children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base"
|
||||
href={`/categories/${child.handle}`}
|
||||
data-testid="category-link"
|
||||
>
|
||||
{child.name}
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{props?.center && (
|
||||
<div className={clx("flex h-full", props?.centerClassName)}>
|
||||
<DynamicLayoutRenderer nodes={props?.center} context={context} />
|
||||
{collections && collections.length > 0 && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<span className="txt-small-plus txt-ui-fg-base">
|
||||
Collections
|
||||
</span>
|
||||
<ul
|
||||
className={clx(
|
||||
"grid grid-cols-1 gap-2 text-ui-fg-subtle txt-small",
|
||||
{
|
||||
"grid-cols-2": (collections?.length || 0) > 3,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{collections?.slice(0, 6).map((c) => (
|
||||
<li key={c.id}>
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base"
|
||||
href={`/collections/${c.handle}`}
|
||||
>
|
||||
{c.title}
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{props?.right && (
|
||||
<div className={clx("flex h-full", props?.rightClassName)}>
|
||||
<DynamicLayoutRenderer nodes={props?.right} context={context} />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<span className="txt-small-plus txt-ui-fg-base">Medusa</span>
|
||||
<ul className="grid grid-cols-1 gap-y-2 text-ui-fg-subtle txt-small">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/medusajs"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-ui-fg-base"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://docs.medusajs.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-ui-fg-base"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/medusajs/nextjs-starter-medusa"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-ui-fg-base"
|
||||
>
|
||||
Source code
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full mb-16 justify-between text-ui-fg-muted">
|
||||
<Text className="txt-compact-small">
|
||||
{copyrightText || `© ${new Date().getFullYear()} Medusa Store. All rights reserved.`}
|
||||
</Text>
|
||||
<MedusaCTA />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import * as MedusaIcons from "@medusajs/icons"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
|
||||
export default function VtFooterBottom({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
|
||||
const renderIcon = (name: string, idx: number) => {
|
||||
const IconComp = (MedusaIcons as Record<string, any>)[name]
|
||||
if (!IconComp) return null
|
||||
return <IconComp key={`${name}-${idx}`} className="shadow-none" />
|
||||
}
|
||||
|
||||
const renderLink = (item: any, idx: number, total: number) => {
|
||||
const LinkEl = (
|
||||
<LocalizedClientLink
|
||||
href={item?.href ?? "#"}
|
||||
className={clx(
|
||||
"hover:underline",
|
||||
props.linkClassName
|
||||
)}
|
||||
>
|
||||
{item?.label ?? ""}
|
||||
</LocalizedClientLink>
|
||||
)
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`link-${idx}`}
|
||||
className={clx("flex items-center", props.linkItemClassName)}
|
||||
>
|
||||
{LinkEl}
|
||||
{idx < total - 1 && (
|
||||
<span className={clx("mx-2 text-white", props.separatorClassName)}>|</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"flex w-full items-center justify-between",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<span className={clx("text-[14px] font-[400] pt-2", props.textClassName)}>
|
||||
{props.text}
|
||||
</span>
|
||||
<div className={clx("flex items-center gap-4")}>
|
||||
{props.links && props.links.length > 0 && (
|
||||
<div className={clx("flex items-center", props.linksClassName)}>
|
||||
{props.links.map((l: any, idx: number) =>
|
||||
renderLink(l, idx, props.links.length)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={clx("flex items-center gap-2", props.iconsClassName)}>
|
||||
{(props.icons ?? []).map(renderIcon)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import { clx } from "@medusajs/ui"
|
||||
import { ChevronRightMini } from "@medusajs/icons"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import * as MedusaIcons from "@medusajs/icons"
|
||||
import * as CustomIcons from "@modules/common/icons"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
|
||||
interface SocialIcon {
|
||||
href?: string
|
||||
icon?: string
|
||||
imgSrc?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function VtFooterHero({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
const IconComp = (name?: string) => {
|
||||
if (!name) return null
|
||||
return (
|
||||
(MedusaIcons as Record<string, any>)[name] ||
|
||||
(CustomIcons as Record<string, any>)[name] ||
|
||||
null
|
||||
)
|
||||
}
|
||||
const renderSocialContent = (item: SocialIcon) => {
|
||||
const Icon = IconComp(item.icon)
|
||||
if (Icon) return <Icon className={item.className} />
|
||||
if (item.imgSrc)
|
||||
return (
|
||||
<img
|
||||
src={item.imgSrc}
|
||||
alt={item.icon ?? "social"}
|
||||
className={item.className}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<span className="w-4 h-4 rounded-md bg-white/10 text-white flex items-center justify-center text-sm">
|
||||
{item.icon?.[0] ?? "?"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSocialIcon = (icon: SocialIcon, idx: number) => {
|
||||
const content = renderSocialContent(icon)
|
||||
return icon.href ? (
|
||||
<a
|
||||
key={idx}
|
||||
href={icon.href}
|
||||
className={clx("hover:opacity-80", icon.className)}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<span key={idx} className={clx("", icon.className)}>
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={clx("flex flex-col items-start", props.className)}>
|
||||
{props.logoSrc && (
|
||||
<img
|
||||
src={props.logoSrc}
|
||||
alt={props.logoAlt ?? "logo"}
|
||||
className={clx("object-contain", props.logoClassName)}
|
||||
/>
|
||||
)}
|
||||
{props.title && (
|
||||
<h2
|
||||
className={clx(
|
||||
"mt-4 w-[320px] text-[24px] font-semibold text-[#11314E]",
|
||||
props.titleClassName
|
||||
)}
|
||||
>
|
||||
{props.title}
|
||||
</h2>
|
||||
)}
|
||||
{props.description && (
|
||||
<p
|
||||
className={clx(
|
||||
"mt-2 text-ui-fg-subtle txt-small",
|
||||
props.descriptionClassName
|
||||
)}
|
||||
>
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
{props.cta && (
|
||||
<LocalizedClientLink
|
||||
href={props.cta.href}
|
||||
className={clx(
|
||||
"bg-black text-white items-center flex gap-2 px-4 mt-[24px] py-2 rounded-md w-fit text-[14px]",
|
||||
props.ctaClassName
|
||||
)}
|
||||
>
|
||||
<span>{props.cta.label}</span>
|
||||
<ChevronRightMini className="order-1" />
|
||||
</LocalizedClientLink>
|
||||
)}
|
||||
|
||||
{props.email && (
|
||||
<form
|
||||
className={clx(
|
||||
"mt-4 w-full max-w-[500px]",
|
||||
props.email?.emailInputClassName
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center justify-between border border-white/40 rounded-md px-4 py-3">
|
||||
<input
|
||||
id="vt-footer-hero-email"
|
||||
type="email"
|
||||
name="contact[email]"
|
||||
placeholder=" "
|
||||
autoComplete="email"
|
||||
required
|
||||
className="peer bg-transparent outline-none text-white/90 placeholder-transparent flex-1"
|
||||
/>
|
||||
<label
|
||||
htmlFor="vt-footer-hero-email"
|
||||
className="absolute left-4 text-white/60 pointer-events-none transition-all duration-300
|
||||
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:text-white/50
|
||||
peer-focus:top-3 peer-focus:-translate-y-2 peer-focus:left-3 peer-focus:text-white
|
||||
peer-[:not(:placeholder-shown)]:translate-y-[-0.85rem]"
|
||||
>
|
||||
E-Mail
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-4 w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center"
|
||||
>
|
||||
<span className="sr-only">Abonnieren</span>
|
||||
<ChevronRightMini />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{props.socials && props.socials.length > 0 ? (
|
||||
<div
|
||||
className={clx(
|
||||
"flex items-center gap-4 mt-2",
|
||||
props.socialsClassName
|
||||
)}
|
||||
>
|
||||
{props.socials.map(renderSocialIcon)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
"use client"
|
||||
import { Button, Input } from "@medusajs/ui"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import * as MedusaIcons from "@medusajs/icons"
|
||||
import * as CustomIcons from "@modules/common/icons"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "@vibentec/component-map"
|
||||
import React, { useState } from "react"
|
||||
|
||||
interface SocialIcon {
|
||||
href?: string
|
||||
icon?: string
|
||||
imgSrc?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface VtFooterSignupConfig {
|
||||
title?: string
|
||||
placeholder?: string
|
||||
buttonLabel?: string
|
||||
className?: string
|
||||
titleClassName?: string
|
||||
formClassName?: string
|
||||
inputClassName?: string
|
||||
buttonClassName?: string
|
||||
socialsClassName?: string
|
||||
socials?: SocialIcon[]
|
||||
}
|
||||
|
||||
export default function VtFooterSignUp({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = (nodes.config as VtFooterSignupConfig) ?? {}
|
||||
const [email, setEmail] = useState("")
|
||||
|
||||
const IconComp = (name?: string) => {
|
||||
if (!name) return null
|
||||
return (
|
||||
(MedusaIcons as Record<string, any>)[name] ||
|
||||
(CustomIcons as Record<string, any>)[name] ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
console.log("newsletter_signup", { email })
|
||||
}
|
||||
|
||||
const renderSocialContent = (item: SocialIcon) => {
|
||||
const Icon = IconComp(item.icon)
|
||||
if (Icon) return <Icon className={item.className} />
|
||||
if (item.imgSrc) return <img src={item.imgSrc} alt={item.icon ?? "social"} className={item.className} />
|
||||
return (
|
||||
<span className="w-4 h-4 rounded-md bg-white/10 text-white flex items-center justify-center text-sm">
|
||||
{item.icon?.[0] ?? "?"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSocialIcon = (icon: SocialIcon, idx: number) => {
|
||||
const content = renderSocialContent(icon)
|
||||
return icon.href ? (
|
||||
<a key={idx} href={icon.href} className={clx("hover:opacity-80", icon.className)}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<span key={idx} className={clx("", icon.className)}>
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clx("flex flex-col gap-4", props.className)}>
|
||||
{props.title && (
|
||||
<p className={clx("text-white text-[18px]", props.titleClassName)}>
|
||||
{props.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className={clx("flex items-center gap-4", props.formClassName)}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={props.placeholder ?? "Email"}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={clx(
|
||||
"h-[48px] rounded-lg bg-white text-black px-4",
|
||||
props.inputClassName
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className={clx(
|
||||
"h-[48px] rounded-lg bg-[#C4622C] text-white",
|
||||
props.buttonClassName
|
||||
)}
|
||||
>
|
||||
{props.buttonLabel ?? "Sign Up"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{props.socials && props.socials.length > 0 ? (
|
||||
<div className={clx("flex items-center gap-4 mt-2", props.socialsClassName)}>
|
||||
{props.socials.map(renderSocialIcon)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||
import { clx } from "@medusajs/ui";
|
||||
|
||||
export default function VtHeader({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
|
||||
const { sticky = true } = nodes.config ?? {};
|
||||
const cName = clx(sticky && "sticky top-0","inset-x-0 z-50 group");
|
||||
|
||||
return (
|
||||
<header className={cName}>
|
||||
{ nodes.children && <DynamicLayoutRenderer nodes={nodes.children} context={context} /> }
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { clx } from "@medusajs/ui"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
export interface VtImageConfig {
|
||||
src: string
|
||||
alt: string
|
||||
className?: string,
|
||||
objectFit?: string,
|
||||
}
|
||||
export default function VtLogo({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = (nodes.config as VtImageConfig) ?? {}
|
||||
return (
|
||||
<div className={clx("relative", props.className)}>
|
||||
<img src={props.src} alt={props.alt} className={clx("w-full h-full", props.objectFit)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"use client"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import * as MedusaIcons from "@medusajs/icons"
|
||||
import * as CustomIcons from "@modules/common/icons"
|
||||
export default function VtMenuItem({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
const title = props.title ?? ""
|
||||
const items = props.items ?? []
|
||||
const icon = props.icon ?? ""
|
||||
|
||||
const getIconComponent = (icon: string | undefined) => {
|
||||
return icon
|
||||
? (MedusaIcons as Record<string, any>)[icon] ??
|
||||
(CustomIcons as Record<string, any>)[icon]
|
||||
: undefined
|
||||
}
|
||||
return (
|
||||
<div className={props.className ?? "flex flex-col gap-y-2"}>
|
||||
<span>{title}</span>
|
||||
<ul className="grid grid-cols-1 gap-2" data-testid="footer-categories">
|
||||
{items.map((item: { text: string; href?: string; icon?: string }, index: number) => {
|
||||
const Icon = getIconComponent(item.icon)
|
||||
return (
|
||||
<li
|
||||
key={`${item.text}-${index}`}
|
||||
className={props.itemClassName ?? "text-ui-fg-subtle txt-small"}
|
||||
>
|
||||
{Icon && <Icon className={props.iconClassName ?? "inline-block mr-2"} />}
|
||||
{item.href ? (
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base"
|
||||
href={item.href}
|
||||
data-testid="category-link"
|
||||
>
|
||||
{item.text}
|
||||
</LocalizedClientLink>
|
||||
) : (
|
||||
<span className="hover:text-ui-fg-base">{item.text}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +1,28 @@
|
|||
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||
import { clx } from "@medusajs/ui";
|
||||
import { listRegions } from "@lib/data/regions"
|
||||
import { StoreRegion } from "@medusajs/types"
|
||||
import SideMenu from "@modules/layout/components/side-menu"
|
||||
import { DynamicLayoutRenderer, DynamicLayoutRendererProps } from "vibentec/renderer"
|
||||
import { clx } from "@medusajs/ui"
|
||||
|
||||
interface BannerNavProps {
|
||||
className?: string;
|
||||
left?: LayoutComponentDefinition[];
|
||||
center?: LayoutComponentDefinition[];
|
||||
right?: LayoutComponentDefinition[];
|
||||
}
|
||||
export default function VtNav({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
|
||||
const props = nodes.config as BannerNavProps ?? {}
|
||||
export default async function VtNav({ nodes, context, className }: DynamicLayoutRendererProps & { className?: string }) {
|
||||
console.log({nodes, context})
|
||||
const regions = await listRegions().then((regions: StoreRegion[]) => regions)
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto border-b duration-200 bg-white border-ui-border-base">
|
||||
<nav className={clx("content-container txt-xsmall-plus flex items-center justify-between w-full h-full text-small-regular", props.className)}>
|
||||
<div className="flex items-center gap-x-4 h-full">
|
||||
{props.left && <DynamicLayoutRenderer nodes={props.left} context={context} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 h-full">
|
||||
{props.center && <DynamicLayoutRenderer nodes={props.center} context={context} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 h-full justify-end">
|
||||
{props.right && <DynamicLayoutRenderer nodes={props.right} context={context} />}
|
||||
<div className="sticky top-[40px] inset-x-0 z-50">
|
||||
<header className={clx("relative mx-auto border-b duration-200 border-ui-border-base", className ?? "bg-white") }>
|
||||
<nav className="flex justify-between w-full items-center h-full">
|
||||
{/* <div className="flex-1 basis-0 h-full flex items-center">
|
||||
<div className="h-full">
|
||||
<SideMenu regions={regions} />
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{nodes && (
|
||||
<DynamicLayoutRenderer nodes={nodes} context={context} />
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import React from "react"
|
||||
import { MagnifyingGlass } from "@medusajs/icons"
|
||||
import {
|
||||
LayoutComponentDefinition,
|
||||
LayoutContext,
|
||||
} from "@vibentec/component-map"
|
||||
|
||||
export default function VtSearchInput({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config || {}
|
||||
const placeholder = props.placeholder ?? "Search"
|
||||
const shortcut = props.shortcut ?? "⌘K"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
`flex items-center gap-3 w-full max-w-xl bg-white border border-grey-20 rounded-rounded shadow-sm px-4 py-2 ${
|
||||
props?.className ?? ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
<MagnifyingGlass className="text-[#11314E]" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
className="flex-1 bg-transparent outline-none text-[#11314E] placeholder:text-[#11314E]"
|
||||
/>
|
||||
{shortcut && (
|
||||
<span className="text-[#11314E] border border-grey-30 rounded-rounded px-2 py-0.5 text-sm">
|
||||
{shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { LayoutComponentDefinition, LayoutContext } from "@vibentec/component-map"
|
||||
import { DynamicLayoutRenderer } from "@vibentec/renderer"
|
||||
|
||||
export default function VtSocialLinks({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config ?? {}
|
||||
return (
|
||||
<div className={props.className ?? ""}>
|
||||
{nodes.children && <DynamicLayoutRenderer nodes={nodes.children} context={context} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { LayoutComponentDefinition } from "@vibentec/component-map"
|
||||
|
||||
import { LayoutContext } from "@vibentec/component-map"
|
||||
|
||||
export default function VtText({
|
||||
nodes,
|
||||
context,
|
||||
}: {
|
||||
nodes: LayoutComponentDefinition
|
||||
context: LayoutContext
|
||||
}) {
|
||||
const props = nodes.config || {}
|
||||
return (
|
||||
<span className={props?.className ?? ""}>
|
||||
{props?.label ?? ""}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ export default async function PreviewPrice({ price }: { price: VariantPrice }) {
|
|||
)}
|
||||
<Text
|
||||
className={clx("text-ui-fg-muted", {
|
||||
"text-green-700": price.price_type === "sale",
|
||||
"text-ui-fg-interactive": price.price_type === "sale",
|
||||
})}
|
||||
data-testid="price"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,225 +0,0 @@
|
|||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button, Heading, Text, clx } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import Divider from "@modules/common/components/divider"
|
||||
import PreviewPrice from "@modules/products/components/product-preview/price"
|
||||
import { getProductPrice } from "@lib/util/get-product-price"
|
||||
import { addToCart } from "@lib/data/cart"
|
||||
import VtThumbnail from "../vt-thumbnail"
|
||||
import { Plus, ChevronRight } from "@medusajs/icons"
|
||||
type ProductCardProps = {
|
||||
product: HttpTypes.StoreProduct
|
||||
badgeText?: string
|
||||
deliveryTime?: string
|
||||
className?: string
|
||||
countryCode: string
|
||||
styles?: any
|
||||
}
|
||||
|
||||
export default function ProductCard({
|
||||
product,
|
||||
badgeText = "Saved up to 20%",
|
||||
deliveryTime = "2-4 Wochen",
|
||||
className,
|
||||
countryCode,
|
||||
styles,
|
||||
}: ProductCardProps) {
|
||||
const firstVariant = product.variants?.[0]
|
||||
|
||||
const inStock = (() => {
|
||||
if (!firstVariant) return false
|
||||
if (!firstVariant.manage_inventory) return true
|
||||
if (firstVariant.allow_backorder) return true
|
||||
return (firstVariant.inventory_quantity || 0) > 0
|
||||
})()
|
||||
console.dir(product, { depth: null })
|
||||
const { cheapestPrice } = getProductPrice({ product })
|
||||
|
||||
async function handleAddToCart() {
|
||||
"use server"
|
||||
if (!firstVariant?.id) return
|
||||
await addToCart({
|
||||
variantId: firstVariant.id,
|
||||
quantity: 1,
|
||||
countryCode,
|
||||
})
|
||||
}
|
||||
|
||||
const description = (() => {
|
||||
const prodDescription = product.description || ""
|
||||
const textSlice =
|
||||
prodDescription.length > 120
|
||||
? prodDescription.slice(0, 117) + "…"
|
||||
: prodDescription
|
||||
return textSlice
|
||||
})()
|
||||
|
||||
const classes = {
|
||||
card: styles?.card ?? className ?? "",
|
||||
badge: {
|
||||
container: styles?.badge?.container ?? " pb-4",
|
||||
text:
|
||||
styles?.badge?.text ??
|
||||
"z-20 px-3 py-1 border-[0.5px] rounded bg-[#c9e0f5] txt-compact-small-plus shadow-borders-base text-[#285A86] ",
|
||||
},
|
||||
thumbnail: {
|
||||
className: styles?.thumbnail?.className ?? "rounded-none h-[240px]",
|
||||
size: styles?.thumbnail?.size ?? "full",
|
||||
},
|
||||
subtitle: styles?.subtitle ?? "",
|
||||
content: styles?.content ?? "p-6 flex flex-col flex-1",
|
||||
title: styles?.title ?? "",
|
||||
price: styles?.price ?? "",
|
||||
description: styles?.description ?? "",
|
||||
reviews: {
|
||||
container: styles?.reviews?.container ?? undefined,
|
||||
stars: styles?.reviews?.stars ?? "flex gap-1",
|
||||
star: styles?.reviews?.star ?? "text-[#C4622C] text-xl leading-none",
|
||||
emptyStar:
|
||||
styles?.reviews?.emptyStar ??
|
||||
"text-[#C4622C] text-xl opacity-30 leading-none",
|
||||
text: styles?.reviews?.text ?? "txt-small text-ui-fg-subtle",
|
||||
rating: styles?.reviews?.rating,
|
||||
count: styles?.reviews?.count,
|
||||
},
|
||||
button: {
|
||||
addToCart: styles?.button?.addToCart ?? "",
|
||||
moreInfo: styles?.button?.moreInfo ?? "",
|
||||
isShowIcon: styles?.button?.isShowIcon ?? false,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clx(classes.card)}>
|
||||
<LocalizedClientLink
|
||||
href={`/products/${product.handle}`}
|
||||
className="block"
|
||||
>
|
||||
<div className="relative">
|
||||
{badgeText && (
|
||||
<div className={classes.badge.container}>
|
||||
<span className={classes.badge.text}>{badgeText}</span>
|
||||
</div>
|
||||
)}
|
||||
<VtThumbnail
|
||||
thumbnail={product.thumbnail}
|
||||
className={classes.thumbnail.className}
|
||||
images={product.images}
|
||||
size={classes.thumbnail.size}
|
||||
isFeatured
|
||||
/>
|
||||
</div>
|
||||
</LocalizedClientLink>
|
||||
|
||||
<div className={classes.content}>
|
||||
{classes.subtitle && product.collection && (
|
||||
<LocalizedClientLink
|
||||
href={`/collections/${product.collection.handle}`}
|
||||
className="txt-small text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
>
|
||||
{product.subtitle}
|
||||
</LocalizedClientLink>
|
||||
)}
|
||||
|
||||
<LocalizedClientLink
|
||||
href={`/products/${product.handle}`}
|
||||
className="block"
|
||||
>
|
||||
<Heading
|
||||
level="h3"
|
||||
className={classes.title}
|
||||
data-testid="product-card-title"
|
||||
>
|
||||
{product.title}
|
||||
</Heading>
|
||||
</LocalizedClientLink>
|
||||
|
||||
{classes.price && (
|
||||
<div className={classes.price}>
|
||||
{cheapestPrice && <PreviewPrice price={cheapestPrice} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(classes.reviews.rating !== undefined ||
|
||||
classes.reviews.count !== undefined) && (
|
||||
<div
|
||||
className={
|
||||
classes.reviews.container || "mt-2 flex items-center gap-3"
|
||||
}
|
||||
>
|
||||
<div className="relative inline-block">
|
||||
<div className={classes.reviews.stars}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span
|
||||
key={`star-empty-${i}`}
|
||||
className={classes.reviews.emptyStar}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
width: `${Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
(((classes.reviews.rating as number) ?? 0) / 5) * 100
|
||||
)
|
||||
)}%`,
|
||||
}}
|
||||
>
|
||||
<div className={classes.reviews.stars}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span
|
||||
key={`star-fills-${i}`}
|
||||
className={classes.reviews.star}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{typeof classes.reviews.count === "number" && (
|
||||
<span className={classes.reviews.text}>
|
||||
{classes.reviews.count} Reviews
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{classes.description && (
|
||||
<Text className={clx(classes.description, "txt-small my-4")}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-auto">
|
||||
{classes.button?.addToCart && (
|
||||
<Button
|
||||
formAction={handleAddToCart}
|
||||
disabled={!inStock}
|
||||
variant="primary"
|
||||
className={classes.button.addToCart}
|
||||
>
|
||||
Add to cart {classes.button.isShowIcon && <Plus />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{classes.button?.moreInfo && (
|
||||
<LocalizedClientLink
|
||||
href={`/products/${product.handle}`}
|
||||
className="flex-1"
|
||||
>
|
||||
<Button variant="secondary" className={classes.button.moreInfo}>
|
||||
More Info {classes.button.isShowIcon && <ChevronRight />}
|
||||
</Button>
|
||||
</LocalizedClientLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { Container, clx } from "@medusajs/ui"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
|
||||
import PlaceholderImage from "@modules/common/icons/placeholder-image"
|
||||
|
||||
type ThumbnailProps = {
|
||||
thumbnail?: string | null
|
||||
// TODO: Fix image typings
|
||||
images?: any[] | null
|
||||
size?: "small" | "medium" | "large" | "full" | "square"
|
||||
isFeatured?: boolean
|
||||
className?: string
|
||||
"data-testid"?: string
|
||||
}
|
||||
|
||||
const VtThumbnail: React.FC<ThumbnailProps> = ({
|
||||
thumbnail,
|
||||
images,
|
||||
size = "small",
|
||||
isFeatured,
|
||||
className,
|
||||
"data-testid": dataTestid,
|
||||
}) => {
|
||||
const imageUrls = images?.map((i: any) => i.url) || []
|
||||
const initialImage = thumbnail || imageUrls?.[0]
|
||||
|
||||
let hoverImage: string | undefined = initialImage
|
||||
if (imageUrls.length > 1) {
|
||||
hoverImage = imageUrls.find((u) => u !== initialImage)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
className={clx(
|
||||
"group relative w-full overflow-hidden p-4 bg-ui-bg-subtle shadow-elevation-card-rest group-hover:shadow-elevation-card-hover transition-shadow ease-in-out duration-150",
|
||||
className,
|
||||
{
|
||||
"aspect-[11/14]": isFeatured,
|
||||
"aspect-[9/16]": !isFeatured && size !== "square",
|
||||
"aspect-[1/1]": size === "square",
|
||||
"w-[180px]": size === "small",
|
||||
"w-[290px]": size === "medium",
|
||||
"w-[440px]": size === "large",
|
||||
"w-full": size === "full",
|
||||
}
|
||||
)}
|
||||
data-testid={dataTestid}
|
||||
>
|
||||
<ImageOrPlaceholder
|
||||
image={initialImage}
|
||||
size={size}
|
||||
className="opacity-100 group-hover:opacity-0 transition-opacity duration-300"
|
||||
/>
|
||||
{hoverImage && (
|
||||
<ImageOrPlaceholder
|
||||
image={hoverImage}
|
||||
size={size}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageOrPlaceholder = ({
|
||||
image,
|
||||
size,
|
||||
className,
|
||||
}: Pick<ThumbnailProps, "size"> & { image?: string; className?: string }) => {
|
||||
return image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt="Thumbnail"
|
||||
className={clx("absolute inset-0 object-cover object-center", className)}
|
||||
draggable={false}
|
||||
quality={50}
|
||||
sizes="(max-width: 576px) 280px, (max-width: 768px) 360px, (max-width: 992px) 480px, 800px"
|
||||
fill
|
||||
/>
|
||||
) : (
|
||||
<div className={clx("w-full h-full absolute inset-0 flex items-center justify-center", className)}>
|
||||
<PlaceholderImage size={size === "small" ? 16 : 24} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VtThumbnail
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
|
||||
export default function SkeletonMegaMenu() {
|
||||
return (
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base hover:bg-neutral-100 rounded-full px-3 py-2"
|
||||
href="/store"
|
||||
>
|
||||
Products
|
||||
</LocalizedClientLink>
|
||||
)
|
||||
}
|
||||
|
|
@ -51,8 +51,7 @@
|
|||
|
||||
@layer components {
|
||||
.content-container {
|
||||
/* @apply max-w-[1440px] w-full mx-auto px-6; */
|
||||
@apply w-full mx-auto px-6;
|
||||
@apply max-w-[1440px] w-full mx-auto px-6;
|
||||
}
|
||||
|
||||
.contrast-btn {
|
||||
|
|
@ -111,6 +110,7 @@
|
|||
@apply text-[32px] leading-[44px] font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
[data-radix-popper-content-wrapper]{
|
||||
z-index: 51 !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,35 @@
|
|||
import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
|
||||
import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import CartButton from "@modules/layout/components/cart-button"
|
||||
|
||||
import { DynamicLayoutRenderer } from "./renderer"
|
||||
import React from "react"
|
||||
import VtNav from "@modules/layout/templates/vt-nav"
|
||||
import VtFooter from "@modules/layout/templates/vt-footer"
|
||||
import HomeButton from "@modules/layout/components/vt-homebutton"
|
||||
import AccountButton from "@modules/layout/components/vt-accountbutton"
|
||||
import VtCartButton from "@modules/layout/components/vt-cartbutton"
|
||||
import VtHeader from "@modules/layout/templates/vt-header"
|
||||
import Banner from "@modules/layout/templates/vt-banner"
|
||||
import VtMegaMenu from "@modules/layout/components/vt-mega-menu"
|
||||
import VtLink from "@modules/layout/components/vt-linkbutton"
|
||||
import VtSideMenu from "@modules/layout/components/vt-sidemenu"
|
||||
import VtDropdown from "@modules/layout/templates/vt-dropdown"
|
||||
import VtSearchInput from "@modules/layout/templates/vt-search-input"
|
||||
import VtCurrencySelect from "@modules/layout/templates/vt-currency-select"
|
||||
import VtMenuItem from "@modules/layout/templates/vt-menu-item"
|
||||
import VtCountryCodeSelect from "@modules/layout/templates/vt-country-select/server"
|
||||
import VtSocialLinks from "@modules/layout/templates/vt-social-link"
|
||||
import VtFooterHero from "@modules/layout/templates/vt-footer/vt-footer-hero"
|
||||
import VtFooterBottom from "@modules/layout/templates/vt-footer/vt-footer-bottom"
|
||||
import VtLogo from "@modules/layout/templates/vt-logo"
|
||||
import VtFooterSignUp from "@modules/layout/templates/vt-footer/vt-footer-signup"
|
||||
import Hero from "@modules/layout/templates/hero"
|
||||
import { VtCarousel } from "@modules/layout/templates/vt-carousel"
|
||||
import { VtCtaBanner } from "@modules/layout/templates/vt-cta-banner"
|
||||
import VtFeaturedProducts from "@modules/home/components/vt-featured-products"
|
||||
import VtCategoryHighlight from "@modules/home/components/vt-category-highlight"
|
||||
import VtBrand from "@modules/home/components/vt-brand"
|
||||
import VtFeedback from "@modules/home/components/vt-feedback"
|
||||
import VtFeedbackCard from "@modules/home/components/vt-feedback-card"
|
||||
import VtSubcription from "@modules/home/components/vt-subcription"
|
||||
|
||||
type ComponentConfig = Record<string, any>
|
||||
import AnnouncementBannerDefault from "@modules/layout/templates/3bear-template/announcement-bar"
|
||||
import AnnouncementBannerDrsquatch from "@modules/layout/templates/drsquatch-template/announcement-bar"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { User, MagnifyingGlassMini, Heart } from "@medusajs/icons"
|
||||
import DropdownMenuComponent from "@modules/layout/templates/dropdown-menu/dropdown-menu"
|
||||
import AnnouncementBannerVibenTec from "@modules/layout/templates/vibentec-template/announcement-bar"
|
||||
import SearchButton from "@modules/layout/components/search-button"
|
||||
import NavigationMenu from "@modules/layout/components/navigation-menu"
|
||||
|
||||
export interface LayoutComponentDefinition {
|
||||
config?: ComponentConfig
|
||||
props?: Record<string, any>
|
||||
children?: LayoutComponentNode[]
|
||||
}
|
||||
|
||||
//maps key = componentName to value = props + children
|
||||
export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
|
||||
|
||||
export interface LayoutContext {
|
||||
customer: any
|
||||
cart: any
|
||||
shippingOptions: any[]
|
||||
contentChildren: React.ReactNode
|
||||
countryCode?: string
|
||||
region?: any
|
||||
designId?: string
|
||||
}
|
||||
|
||||
export type ComponentRenderer = {
|
||||
|
|
@ -55,68 +39,129 @@ export type ComponentRenderer = {
|
|||
) => React.ReactNode
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
const configOnly = (
|
||||
Component: React.ComponentType<any>
|
||||
): ComponentRenderer => ({
|
||||
render: (entry) => <Component {...entry.config} />,
|
||||
})
|
||||
|
||||
const nodesContextRenderer = (
|
||||
Component: React.ComponentType<any>
|
||||
): ComponentRenderer => ({
|
||||
render: (entry: any, ctx: LayoutContext) => (
|
||||
<Component nodes={entry} context={ctx} />
|
||||
),
|
||||
// Utility, wenn eine Komponente nur props hat und keine children
|
||||
const simple = (Component: React.ComponentType<any>): ComponentRenderer => ({
|
||||
render: (entry) => <Component {...entry.props} />,
|
||||
})
|
||||
|
||||
// Helper für Kinder-Rendering
|
||||
const renderChildren = (entry: LayoutComponentDefinition, ctx: LayoutContext) =>
|
||||
entry.children ? (
|
||||
<DynamicLayoutRenderer nodes={entry.children} context={ctx} />
|
||||
) : null
|
||||
|
||||
// Map design identifiers to banner component variants (no conditionals)
|
||||
const AnnouncementBannerVariants: Record<string, React.ComponentType<any>> = {
|
||||
drsquatch: AnnouncementBannerDrsquatch,
|
||||
vibentec: AnnouncementBannerVibenTec,
|
||||
"3bear": AnnouncementBannerDefault,
|
||||
"medusa-starter": AnnouncementBannerDefault,
|
||||
default: AnnouncementBannerDefault,
|
||||
}
|
||||
|
||||
// Component Map
|
||||
export const componentMap: Record<string, ComponentRenderer> = {
|
||||
Header: nodesContextRenderer(VtHeader),
|
||||
Nav: nodesContextRenderer(VtNav),
|
||||
Hero: nodesContextRenderer(Hero),
|
||||
VtMegaMenu: nodesContextRenderer(VtMegaMenu),
|
||||
VtSideMenu: nodesContextRenderer(VtSideMenu),
|
||||
Banner: nodesContextRenderer(Banner),
|
||||
HomeButton: nodesContextRenderer(HomeButton),
|
||||
Logo: nodesContextRenderer(VtLogo),
|
||||
AccountButton: nodesContextRenderer(AccountButton),
|
||||
SearchInput: nodesContextRenderer(VtSearchInput),
|
||||
VtCartButton: nodesContextRenderer(VtCartButton),
|
||||
VtCurrencySelect: nodesContextRenderer(VtCurrencySelect),
|
||||
VtCountryCodeSelect: nodesContextRenderer(VtCountryCodeSelect),
|
||||
VtSocialLinks: nodesContextRenderer(VtSocialLinks),
|
||||
Link: nodesContextRenderer(VtLink),
|
||||
Dropdown: nodesContextRenderer(VtDropdown),
|
||||
VtMenuItem: nodesContextRenderer(VtMenuItem),
|
||||
CartMismatchBanner: configOnly(CartMismatchBanner),
|
||||
FreeShippingPriceNudge: configOnly(FreeShippingPriceNudge),
|
||||
AnnouncementBanner: {
|
||||
render: (entry: any, ctx: LayoutContext) => {
|
||||
const key = ctx.designId ?? "default"
|
||||
const Comp =
|
||||
AnnouncementBannerVariants[key] ?? AnnouncementBannerVariants.default
|
||||
return <Comp {...entry.props} nodes={entry.children} context={ctx} />
|
||||
},
|
||||
},
|
||||
Nav: {
|
||||
render: (entry: any, ctx: LayoutContext) => (
|
||||
<VtNav {...entry.props} nodes={entry.children} context={ctx} />
|
||||
),
|
||||
},
|
||||
Div: {
|
||||
render: (entry: any, ctx: LayoutContext) => (
|
||||
<div {...entry.props}>
|
||||
{entry.children ? (
|
||||
<DynamicLayoutRenderer nodes={entry.children} context={ctx} />
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
Text: {
|
||||
render: (entry: any, ctx: LayoutContext) => (
|
||||
<div {...entry.props}>{entry.props.label}</div>
|
||||
),
|
||||
},
|
||||
DropdownMenus: {
|
||||
render: (entry: any, ctx: LayoutContext) => (
|
||||
<DropdownMenuComponent {...entry} />
|
||||
),
|
||||
},
|
||||
NavMenu: {
|
||||
render: (entry: any, ctx: LayoutContext) => (
|
||||
<NavigationMenu props={entry.props} menuItems={entry.menuItems} />
|
||||
),
|
||||
},
|
||||
LocalizedClientLink: {
|
||||
render: (entry: any) => (
|
||||
<LocalizedClientLink {...entry.props}>
|
||||
{entry.props.label}
|
||||
</LocalizedClientLink>
|
||||
),
|
||||
},
|
||||
Image: {
|
||||
render: (entry: any) => {
|
||||
console.log(entry.props)
|
||||
return <img {...entry.props} alt={entry.props?.alt ?? ""} />
|
||||
},
|
||||
},
|
||||
Button: {
|
||||
render: (entry: any, ctx: LayoutContext) => (
|
||||
<Button {...entry.props}>{entry.props.label}</Button>
|
||||
),
|
||||
},
|
||||
SearchButton: {
|
||||
render: (entry: any) => (
|
||||
<Button variant="transparent">
|
||||
<MagnifyingGlassMini {...entry.props}>
|
||||
{entry.props.label}
|
||||
</MagnifyingGlassMini>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
InputSearchButton: {
|
||||
render: (entry: any) => <SearchButton {...entry.props} />,
|
||||
},
|
||||
UserButton: {
|
||||
render: (entry: any) => (
|
||||
<Button variant="transparent">
|
||||
<User {...entry.props}>{entry.props.label}</User>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
FavoriteButton: {
|
||||
render: (entry: any) => (
|
||||
<Button variant="transparent">
|
||||
<Heart {...entry.props}>{entry.props.label}</Heart>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
CartButton: simple(CartButton),
|
||||
Suspense: {
|
||||
render: (entry: any, ctx: LayoutContext) => (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
entry.props?.fallback ? (
|
||||
<DynamicLayoutRenderer nodes={entry.props.fallback} context={ctx} />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{entry.children ? (
|
||||
<DynamicLayoutRenderer nodes={entry.children} context={ctx} />
|
||||
) : null}
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
CartMismatchBanner: simple(CartMismatchBanner),
|
||||
FreeShippingPriceNudge: simple(FreeShippingPriceNudge),
|
||||
PropsChildren: {
|
||||
render: (_props, ctx) => ctx.contentChildren, // PageLayout's props.children
|
||||
},
|
||||
VtCtaBanner: nodesContextRenderer(VtCtaBanner),
|
||||
VtFooterHero: nodesContextRenderer(VtFooterHero),
|
||||
VtFooterBottom: nodesContextRenderer(VtFooterBottom),
|
||||
VtFooterSignUp: nodesContextRenderer(VtFooterSignUp),
|
||||
Footer: nodesContextRenderer(VtFooter),
|
||||
ImageDisplayer: nodesContextRenderer(VtCarousel),
|
||||
VtFeaturedProducts: nodesContextRenderer(VtFeaturedProducts),
|
||||
VtCategoryHighlight: nodesContextRenderer(VtCategoryHighlight),
|
||||
VtBrand: nodesContextRenderer(VtBrand),
|
||||
VtFeedback: nodesContextRenderer(VtFeedback),
|
||||
VtFeedbackCard: nodesContextRenderer(VtFeedbackCard),
|
||||
VtSubcription: nodesContextRenderer(VtSubcription),
|
||||
Footer: simple(VtFooter),
|
||||
}
|
||||
|
||||
export type ComponentName = keyof typeof componentMap
|
||||
|
||||
// //maps key = componentName to value = props + children
|
||||
// export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
|
||||
export type LayoutComponentNode = {
|
||||
[K in ComponentName]: LayoutComponentDefinition
|
||||
}[ComponentName]
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export interface FooterProps { copyrightText?: string }
|
||||
|
|
@ -1,29 +1,8 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { jsonFileNames } from "./devJsonFileNames"
|
||||
|
||||
const fileName = jsonFileNames.namVibentec
|
||||
|
||||
async function readDesignFile() {
|
||||
const filePath = path.join(process.cwd(), "config", fileName)
|
||||
export async function loadDesignConfig(designFile: string) {
|
||||
const filePath = path.join(process.cwd(), "config", designFile)
|
||||
const fileData = await fs.promises.readFile(filePath, "utf-8")
|
||||
return JSON.parse(fileData)
|
||||
}
|
||||
|
||||
|
||||
export async function loadLayoutConfig() {
|
||||
const config = await readDesignFile()
|
||||
if (Array.isArray(config)) return config
|
||||
return config.layout ?? []
|
||||
}
|
||||
|
||||
export async function loadPageConfig(pageKey: string) {
|
||||
const config = await readDesignFile()
|
||||
if (Array.isArray(config)) return []
|
||||
const pages = config.pages ?? {}
|
||||
return pages[pageKey] ?? []
|
||||
}
|
||||
|
||||
export async function loadDesignConfig() {
|
||||
return loadLayoutConfig()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
export const jsonFileNames = {
|
||||
steMedusaStarter: "ste.medusa-starter.design.json",
|
||||
stePlayGround: "ste.playground.design.json",
|
||||
nam3Bear: "nam.3bear.design.json",
|
||||
namDrsquatch: "nam.drsquatch.design.json",
|
||||
namVibentec: "nam.vibentec.design.json",
|
||||
namStarter: "nam.mds-starter-design.json",
|
||||
};
|
||||
|
|
@ -1,28 +1,27 @@
|
|||
import React from "react"
|
||||
import { ComponentName, LayoutComponentDefinition, LayoutComponentNode, LayoutContext, componentMap } from "./component-map"
|
||||
import {
|
||||
LayoutComponentNode,
|
||||
LayoutContext,
|
||||
componentMap,
|
||||
} from "./component-map"
|
||||
|
||||
export interface DynamicLayoutRendererProps {
|
||||
nodes: LayoutComponentNode[]
|
||||
context: LayoutContext
|
||||
}
|
||||
|
||||
export function DynamicLayoutRenderer({ nodes, context } : DynamicLayoutRendererProps) {
|
||||
|
||||
const nodeArray = Array.isArray(nodes) ? nodes : [nodes];
|
||||
|
||||
return nodeArray.map((entry, index) => {
|
||||
const [key, value] = Object.entries(entry)[0] as [ComponentName, LayoutComponentDefinition]
|
||||
|
||||
if (!value) {
|
||||
console.warn(`[UI-Builder] Component definition is undefined: ${key}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const component = componentMap[key];
|
||||
if (!component) {
|
||||
console.warn(`[UI-Builder] Unknown component: ${key}`);
|
||||
return null;
|
||||
}
|
||||
return <React.Fragment key={`${key}-${index}`}>{component.render(value, context)}</React.Fragment>
|
||||
export function DynamicLayoutRenderer({
|
||||
nodes,
|
||||
context,
|
||||
}: DynamicLayoutRendererProps) {
|
||||
return nodes.map((entry, index) => {
|
||||
const [key, value] = Object.entries(entry)[0] as [string, any]
|
||||
const component = componentMap[key]
|
||||
if (!component) return null
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{component.render(value, context)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React from "react"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
export interface LayoutContext {
|
||||
customer: HttpTypes.StoreCustomer | null
|
||||
cart: HttpTypes.StoreCart | null
|
||||
shippingOptions: HttpTypes.StoreShippingOption[]
|
||||
contentChildren: React.ReactNode
|
||||
}
|
||||
|
||||
export interface LayoutComponentDefinition<P = Record<string, unknown>> {
|
||||
props?: P
|
||||
children?: LayoutComponentNode[]
|
||||
}
|
||||
|
||||
// Maps key = componentName to value = props + children
|
||||
export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
|
||||
|
||||
export type ComponentRenderer<P = unknown> = {
|
||||
render: (entry: LayoutComponentDefinition<P>, ctx: LayoutContext) => React.ReactNode
|
||||
}
|
||||
|
||||
export type ComponentMap = Record<string, ComponentRenderer>
|
||||
|
|
@ -9,7 +9,7 @@ module.exports = {
|
|||
"./src/components/**/*.{js,ts,jsx,tsx}",
|
||||
"./src/modules/**/*.{js,ts,jsx,tsx}",
|
||||
"./node_modules/@medusajs/ui/dist/**/*.{js,jsx,ts,tsx}",
|
||||
"./config/*json",
|
||||
"./config/**/*.json",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
|
@ -141,6 +141,38 @@ module.exports = {
|
|||
"0%": { transform: "translateY(-100%)" },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
},
|
||||
enterFromRight: {
|
||||
from: { opacity: "0", transform: "translateX(200px)" },
|
||||
to: { opacity: "1", transform: "translateX(0)" },
|
||||
},
|
||||
enterFromLeft: {
|
||||
from: { opacity: "0", transform: "translateX(-200px)" },
|
||||
to: { opacity: "1", transform: "translateX(0)" },
|
||||
},
|
||||
exitToRight: {
|
||||
from: { opacity: "1", transform: "translateX(0)" },
|
||||
to: { opacity: "0", transform: "translateX(200px)" },
|
||||
},
|
||||
exitToLeft: {
|
||||
from: { opacity: "1", transform: "translateX(0)" },
|
||||
to: { opacity: "0", transform: "translateX(-200px)" },
|
||||
},
|
||||
scaleIn: {
|
||||
from: { opacity: "0", transform: "rotateX(-10deg) scale(0.9)" },
|
||||
to: { opacity: "1", transform: "rotateX(0deg) scale(1)" },
|
||||
},
|
||||
scaleOut: {
|
||||
from: { opacity: "1", transform: "rotateX(0deg) scale(1)" },
|
||||
to: { opacity: "0", transform: "rotateX(-10deg) scale(0.95)" },
|
||||
},
|
||||
fadeIn: {
|
||||
from: { opacity: "0" },
|
||||
to: { opacity: "1" },
|
||||
},
|
||||
fadeOut: {
|
||||
from: { opacity: "1" },
|
||||
to: { opacity: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
ring: "ring 2.2s cubic-bezier(0.5, 0, 0.5, 1) infinite",
|
||||
|
|
@ -156,6 +188,14 @@ module.exports = {
|
|||
enter: "enter 200ms ease-out",
|
||||
"slide-in": "slide-in 1.2s cubic-bezier(.41,.73,.51,1.02)",
|
||||
leave: "leave 150ms ease-in forwards",
|
||||
scaleIn: "scaleIn 200ms ease",
|
||||
scaleOut: "scaleOut 200ms ease",
|
||||
fadeIn: "fadeIn 200ms ease",
|
||||
fadeOut: "fadeOut 200ms ease",
|
||||
enterFromLeft: "enterFromLeft 250ms ease",
|
||||
enterFromRight: "enterFromRight 250ms ease",
|
||||
exitToLeft: "exitToLeft 250ms ease",
|
||||
exitToRight: "exitToRight 250ms ease",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@
|
|||
"paths": {
|
||||
"@lib/*": ["lib/*"],
|
||||
"@modules/*": ["modules/*"],
|
||||
"@pages/*": ["pages/*"],
|
||||
"@vibentec/*": ["vibentec/*"],
|
||||
"@pages/*": ["pages/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue