@unbox-plus/cli 0.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +46 -0
- package/README.md +175 -0
- package/bin/cli.js +439 -0
- package/package.json +43 -0
- package/src/colors.js +100 -0
- package/src/presets.js +241 -0
- package/src/theme.js +253 -0
- package/template/.claude/agents/assets-marca.md +40 -0
- package/template/.claude/agents/avaliador-visual.md +48 -0
- package/template/.claude/agents/branding-briefing.md +401 -0
- package/template/.claude/agents/conteudo-secao.md +46 -0
- package/template/.claude/agents/paginas-legais.md +44 -0
- package/template/.claude/settings.json +3 -0
- package/template/.claude/skills/frontend-design/ATTRIBUTION.md +3 -0
- package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/template/.claude/skills/frontend-design/SKILL.md +55 -0
- package/template/.claude/skills/web-design-guidelines/ATTRIBUTION.md +3 -0
- package/template/.claude/skills/web-design-guidelines/SKILL.md +50 -0
- package/template/.claude/skills/web-design-guidelines/guidelines-snapshot.md +180 -0
- package/template/.env.example +158 -0
- package/template/.mcp.json.example +14 -0
- package/template/.nvmrc +1 -0
- package/template/CLAUDE.md +333 -0
- package/template/COVERAGE.md +80 -0
- package/template/DEPLOY.md +179 -0
- package/template/QA.md +108 -0
- package/template/README.md +152 -0
- package/template/agents/CONSTRUCAO.md +207 -0
- package/template/agents/MANAGER.md +192 -0
- package/template/agents/PADROES.md +255 -0
- package/template/agents/definitions/00-scaffold.md +56 -0
- package/template/agents/definitions/01-layout.md +47 -0
- package/template/agents/definitions/02-homepage.md +70 -0
- package/template/agents/definitions/03-catalog.md +86 -0
- package/template/agents/definitions/04-pdp.md +97 -0
- package/template/agents/definitions/05-cart.md +116 -0
- package/template/agents/definitions/06-checkout.md +146 -0
- package/template/agents/definitions/07-auth.md +130 -0
- package/template/agents/definitions/08-customer.md +55 -0
- package/template/agents/definitions/09-promotions.md +62 -0
- package/template/agents/definitions/10-feedback.md +128 -0
- package/template/agents/definitions/11-seo-infra.md +159 -0
- package/template/agents/definitions/12-deploy.md +173 -0
- package/template/agents/definitions/13-cro.md +275 -0
- package/template/agents/definitions/14-seo.md +388 -0
- package/template/agents/definitions/15-branding.md +26 -0
- package/template/agents/definitions/16-qa-visual.md +138 -0
- package/template/agents/definitions/17-aeo.md +257 -0
- package/template/app/(loja)/busca/page.tsx +72 -0
- package/template/app/(loja)/carrinho/oferta/page.tsx +57 -0
- package/template/app/(loja)/carrinho/page.tsx +160 -0
- package/template/app/(loja)/categoria/[tagSlug]/layout.tsx +31 -0
- package/template/app/(loja)/categoria/[tagSlug]/loading.tsx +17 -0
- package/template/app/(loja)/categoria/[tagSlug]/page.tsx +49 -0
- package/template/app/(loja)/checkout/page.tsx +80 -0
- package/template/app/(loja)/checkout/pix/[ref]/page.tsx +133 -0
- package/template/app/(loja)/conta/assinaturas/[referenceId]/page.tsx +112 -0
- package/template/app/(loja)/conta/assinaturas/page.tsx +51 -0
- package/template/app/(loja)/conta/enderecos/page.tsx +20 -0
- package/template/app/(loja)/conta/entrar/page.tsx +119 -0
- package/template/app/(loja)/conta/page.tsx +45 -0
- package/template/app/(loja)/conta/pedidos/[referenceId]/page.tsx +49 -0
- package/template/app/(loja)/conta/pedidos/page.tsx +104 -0
- package/template/app/(loja)/conta/preferencias/page.tsx +24 -0
- package/template/app/(loja)/devolucoes/page.tsx +36 -0
- package/template/app/(loja)/layout.tsx +22 -0
- package/template/app/(loja)/oferta/page.tsx +57 -0
- package/template/app/(loja)/page.tsx +86 -0
- package/template/app/(loja)/pedido/[referenceId]/page.tsx +78 -0
- package/template/app/(loja)/privacidade/page.tsx +113 -0
- package/template/app/(loja)/produto/[productSlug]/layout.tsx +23 -0
- package/template/app/(loja)/produto/[productSlug]/loading.tsx +20 -0
- package/template/app/(loja)/produto/[productSlug]/page.tsx +372 -0
- package/template/app/(loja)/produtos/loading.tsx +16 -0
- package/template/app/(loja)/produtos/page.tsx +33 -0
- package/template/app/(loja)/termos/page.tsx +104 -0
- package/template/app/acesso/page.tsx +129 -0
- package/template/app/api/account/addresses/route.ts +39 -0
- package/template/app/api/account/exists/route.ts +21 -0
- package/template/app/api/account/me/route.ts +25 -0
- package/template/app/api/account/otp/route.ts +26 -0
- package/template/app/api/account/preferences/route.ts +19 -0
- package/template/app/api/account/signin/route.ts +27 -0
- package/template/app/api/account/signout/route.ts +9 -0
- package/template/app/api/acesso/route.ts +171 -0
- package/template/app/api/capi/route.ts +34 -0
- package/template/app/api/cart/coupon/route.ts +49 -0
- package/template/app/api/cart/items/route.ts +45 -0
- package/template/app/api/cart/link/route.ts +69 -0
- package/template/app/api/cart/route.ts +113 -0
- package/template/app/api/cart/share/route.ts +21 -0
- package/template/app/api/cep/[code]/route.ts +18 -0
- package/template/app/api/checkout/address/route.ts +24 -0
- package/template/app/api/checkout/email/route.ts +41 -0
- package/template/app/api/checkout/installments/route.ts +16 -0
- package/template/app/api/checkout/route.ts +110 -0
- package/template/app/api/checkout/shipping/route.ts +65 -0
- package/template/app/api/checkout-destination/route.ts +31 -0
- package/template/app/api/order/[ref]/route.ts +17 -0
- package/template/app/api/payment-link/route.ts +28 -0
- package/template/app/api/revalidate/route.ts +52 -0
- package/template/app/api/shipping/quote/route.ts +29 -0
- package/template/app/api/subscriptions/[id]/route.ts +52 -0
- package/template/app/api/track/route.ts +28 -0
- package/template/app/api/unbox/catalogo/route.ts +136 -0
- package/template/app/api/unbox/paginas/route.ts +167 -0
- package/template/app/api/unbox/vitrine/route.ts +84 -0
- package/template/app/api/webhooks/unbox/route.ts +97 -0
- package/template/app/apple-icon.svg +5 -0
- package/template/app/error.tsx +15 -0
- package/template/app/globals.css +457 -0
- package/template/app/icon.svg +4 -0
- package/template/app/layout.tsx +90 -0
- package/template/app/llms.txt/route.ts +43 -0
- package/template/app/manifest.ts +21 -0
- package/template/app/not-found.tsx +18 -0
- package/template/app/opengraph-image.tsx +35 -0
- package/template/app/robots.ts +31 -0
- package/template/app/sitemap.ts +42 -0
- package/template/bootstrap.sh +87 -0
- package/template/components/account/account-shell.tsx +47 -0
- package/template/components/account/address-book.tsx +147 -0
- package/template/components/account/preferences-form.tsx +77 -0
- package/template/components/account/reorder-button.tsx +47 -0
- package/template/components/account/signout-button.tsx +20 -0
- package/template/components/account/subscription-actions.tsx +204 -0
- package/template/components/account-nav.tsx +41 -0
- package/template/components/address-fields.tsx +102 -0
- package/template/components/analytics/data-layer-ready.tsx +16 -0
- package/template/components/analytics/purchase-tracker.tsx +32 -0
- package/template/components/brand-search.tsx +34 -0
- package/template/components/cart/cart-provider.tsx +340 -0
- package/template/components/cart/mini-cart.tsx +228 -0
- package/template/components/cart-button.tsx +35 -0
- package/template/components/catalog/catalog-client.tsx +523 -0
- package/template/components/catalog/grid-skeleton.tsx +19 -0
- package/template/components/catalog/pager.tsx +66 -0
- package/template/components/catalog/product-grid-card.tsx +52 -0
- package/template/components/catalog/product-grid.tsx +27 -0
- package/template/components/checkout/checkout-client.tsx +1333 -0
- package/template/components/chrome/announce-bar.tsx +57 -0
- package/template/components/chrome/chrome-recipe.ts +13 -0
- package/template/components/chrome/footers/captura-botao.tsx +28 -0
- package/template/components/chrome/footers/colunas.tsx +110 -0
- package/template/components/chrome/footers/conversao.tsx +90 -0
- package/template/components/chrome/footers/editorial.tsx +98 -0
- package/template/components/chrome/footers/minimal.tsx +55 -0
- package/template/components/chrome/header-bar-mobile.tsx +64 -0
- package/template/components/chrome/headers/centralizado.tsx +71 -0
- package/template/components/chrome/headers/classico.tsx +64 -0
- package/template/components/chrome/headers/compacto.tsx +66 -0
- package/template/components/chrome/headers/equilibrado.tsx +73 -0
- package/template/components/chrome/headers/imersivo.tsx +62 -0
- package/template/components/chrome/registry.ts +70 -0
- package/template/components/chrome/solid-on-scroll.tsx +39 -0
- package/template/components/empty-state.tsx +22 -0
- package/template/components/home/combo-card-compact.tsx +67 -0
- package/template/components/home/combos-home.tsx +64 -0
- package/template/components/home/combos-section.tsx +177 -0
- package/template/components/home/home-recipe.ts +41 -0
- package/template/components/home/sections/attributes-marquee.tsx +178 -0
- package/template/components/home/sections/benefits.tsx +80 -0
- package/template/components/home/sections/bloco-html.tsx +47 -0
- package/template/components/home/sections/catalogo.ts +165 -0
- package/template/components/home/sections/category-pills.tsx +32 -0
- package/template/components/home/sections/combos-carousel.tsx +141 -0
- package/template/components/home/sections/comparison.tsx +73 -0
- package/template/components/home/sections/founder-story.tsx +40 -0
- package/template/components/home/sections/hero.tsx +187 -0
- package/template/components/home/sections/kits.tsx +11 -0
- package/template/components/home/sections/media-cards.tsx +70 -0
- package/template/components/home/sections/newsletter.tsx +62 -0
- package/template/components/home/sections/product-showcase.tsx +175 -0
- package/template/components/home/sections/purchase-hero.tsx +187 -0
- package/template/components/home/sections/quote-banner.tsx +50 -0
- package/template/components/home/sections/registry.ts +186 -0
- package/template/components/home/sections/reviews-carousel.tsx +66 -0
- package/template/components/home/sections/reviews.tsx +68 -0
- package/template/components/home/sections/ritual.tsx +96 -0
- package/template/components/home/sections/savings.tsx +47 -0
- package/template/components/home/sections/social-row.tsx +65 -0
- package/template/components/home/sections/spec-table.tsx +117 -0
- package/template/components/home/sections/stars.tsx +12 -0
- package/template/components/home/sections/stats-grid.tsx +54 -0
- package/template/components/home/sections/trust-bar.tsx +56 -0
- package/template/components/home/sections/trust-strip.tsx +40 -0
- package/template/components/home/sections/video-wall.tsx +59 -0
- package/template/components/landing/landing-recipe.ts +20 -0
- package/template/components/landing/oferta-sections.tsx +40 -0
- package/template/components/landing/product-picker.tsx +257 -0
- package/template/components/mobile-nav.tsx +71 -0
- package/template/components/order-status.tsx +163 -0
- package/template/components/powered-by-unbox.tsx +27 -0
- package/template/components/product/pdp/buy-box.tsx +604 -0
- package/template/components/product/pdp/catalog-grid.tsx +113 -0
- package/template/components/product/pdp/faq-modelo.ts +86 -0
- package/template/components/product/pdp/gallery.tsx +87 -0
- package/template/components/product/pdp/interactive.tsx +200 -0
- package/template/components/product/pdp/newsletter.tsx +64 -0
- package/template/components/product/pdp/payment-chips.tsx +22 -0
- package/template/components/product/pdp/pdp-view.tsx +227 -0
- package/template/components/product/pdp/recommendations.tsx +144 -0
- package/template/components/product/pdp/sections.tsx +173 -0
- package/template/components/quantity-stepper.tsx +36 -0
- package/template/components/search-box.tsx +37 -0
- package/template/components/site-footer.tsx +59 -0
- package/template/components/site-header.tsx +64 -0
- package/template/components/ui/accordion.tsx +72 -0
- package/template/components/ui/alert-dialog.tsx +187 -0
- package/template/components/ui/alert.tsx +76 -0
- package/template/components/ui/aspect-ratio.tsx +22 -0
- package/template/components/ui/avatar.tsx +109 -0
- package/template/components/ui/badge.tsx +52 -0
- package/template/components/ui/breadcrumb.tsx +125 -0
- package/template/components/ui/button.tsx +58 -0
- package/template/components/ui/card.tsx +103 -0
- package/template/components/ui/carousel.tsx +242 -0
- package/template/components/ui/command.tsx +196 -0
- package/template/components/ui/dialog.tsx +160 -0
- package/template/components/ui/dropdown-menu.tsx +268 -0
- package/template/components/ui/foto.tsx +54 -0
- package/template/components/ui/input-group.tsx +158 -0
- package/template/components/ui/input-otp.tsx +87 -0
- package/template/components/ui/input.tsx +20 -0
- package/template/components/ui/label.tsx +20 -0
- package/template/components/ui/navigation-menu.tsx +168 -0
- package/template/components/ui/pagination.tsx +132 -0
- package/template/components/ui/popover.tsx +90 -0
- package/template/components/ui/progress.tsx +83 -0
- package/template/components/ui/radio-group.tsx +38 -0
- package/template/components/ui/scroll-area.tsx +55 -0
- package/template/components/ui/select.tsx +201 -0
- package/template/components/ui/separator.tsx +25 -0
- package/template/components/ui/sheet.tsx +138 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/sonner.tsx +44 -0
- package/template/components/ui/switch.tsx +32 -0
- package/template/components/ui/table.tsx +116 -0
- package/template/components/ui/tabs.tsx +82 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +89 -0
- package/template/components/ui/toggle.tsx +45 -0
- package/template/components/ui/tooltip.tsx +66 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +36 -0
- package/template/gitignore +21 -0
- package/template/lib/analytics.ts +369 -0
- package/template/lib/api.ts +60 -0
- package/template/lib/capi.ts +87 -0
- package/template/lib/cart-link.ts +51 -0
- package/template/lib/cart-normalize.ts +142 -0
- package/template/lib/cart-recovery.ts +23 -0
- package/template/lib/cart-response.ts +48 -0
- package/template/lib/catalog-map.ts +54 -0
- package/template/lib/catalog.ts +3 -0
- package/template/lib/checkout-lock.ts +21 -0
- package/template/lib/checkout-nav.ts +22 -0
- package/template/lib/config.ts +51 -0
- package/template/lib/crm.ts +40 -0
- package/template/lib/customer-session.ts +21 -0
- package/template/lib/dataloader.ts +23 -0
- package/template/lib/editable/config.ts +11 -0
- package/template/lib/editable/document.ts +1762 -0
- package/template/lib/editable/index.ts +3 -0
- package/template/lib/editable/primitives.tsx +821 -0
- package/template/lib/editable/provider.tsx +861 -0
- package/template/lib/editable/rastreio-navegacao.tsx +65 -0
- package/template/lib/editable/rastreio.tsx +168 -0
- package/template/lib/editable/server.ts +189 -0
- package/template/lib/editable/tokens.ts +22 -0
- package/template/lib/editable/verify.ts +24 -0
- package/template/lib/enrichment/combos.ts +184 -0
- package/template/lib/enrichment/index.ts +188 -0
- package/template/lib/enrichment/products.json +1 -0
- package/template/lib/env-check.ts +47 -0
- package/template/lib/format.ts +130 -0
- package/template/lib/icons.ts +67 -0
- package/template/lib/json-ld.ts +9 -0
- package/template/lib/llms-txt.ts +75 -0
- package/template/lib/mockup.ts +24 -0
- package/template/lib/newsletter.ts +12 -0
- package/template/lib/orders.ts +160 -0
- package/template/lib/queries.ts +66 -0
- package/template/lib/ratelimit.ts +47 -0
- package/template/lib/rotas-editaveis.ts +192 -0
- package/template/lib/sanitize.ts +36 -0
- package/template/lib/schemas.ts +78 -0
- package/template/lib/session.ts +98 -0
- package/template/lib/store-config.ts +67 -0
- package/template/lib/unbox/client.ts +829 -0
- package/template/lib/unbox/customer.ts +224 -0
- package/template/lib/unbox/errors.ts +97 -0
- package/template/lib/unbox/index.ts +10 -0
- package/template/lib/unbox/store.ts +113 -0
- package/template/lib/unbox/types.ts +195 -0
- package/template/lib/unbox/webhooks.ts +66 -0
- package/template/lib/utils.ts +6 -0
- package/template/lib/vitrine.ts +242 -0
- package/template/lib/webhook-store.ts +22 -0
- package/template/middleware.ts +178 -0
- package/template/next.config.ts +63 -0
- package/template/package.json +62 -0
- package/template/postcss.config.mjs +5 -0
- package/template/public/brand/coll/default.png +0 -0
- package/template/public/brand/hero-desktop.svg +12 -0
- package/template/public/brand/hero-mobile.svg +12 -0
- package/template/public/brand/heros/boutique-desktop.svg +11 -0
- package/template/public/brand/heros/boutique-mobile.svg +11 -0
- package/template/public/brand/heros/editorial-desktop.svg +11 -0
- package/template/public/brand/heros/editorial-mobile.svg +11 -0
- package/template/public/brand/heros/essencial-desktop.svg +12 -0
- package/template/public/brand/heros/essencial-mobile.svg +12 -0
- package/template/public/brand/heros/promocional-desktop.svg +12 -0
- package/template/public/brand/heros/promocional-mobile.svg +12 -0
- package/template/public/brand/logo-chrome.svg +6 -0
- package/template/public/brand/logo-white.svg +6 -0
- package/template/public/brand/logo.svg +7 -0
- package/template/public/brand/pay-amex.webp +0 -0
- package/template/public/brand/pay-elo.webp +0 -0
- package/template/public/brand/pay-mastercard.webp +0 -0
- package/template/public/brand/pay-pix.webp +0 -0
- package/template/public/brand/pay-visa.webp +0 -0
- package/template/public/brand/ph/avatar-a.svg +5 -0
- package/template/public/brand/ph/avatar-b.svg +5 -0
- package/template/public/brand/ph/avatar-c.svg +5 -0
- package/template/public/brand/ph/avatar-d.svg +5 -0
- package/template/public/brand/ph/photo-a.svg +7 -0
- package/template/public/brand/ph/photo-b.svg +6 -0
- package/template/public/brand/ph/photo-c.svg +6 -0
- package/template/public/brand/ph/poster-a.svg +6 -0
- package/template/public/brand/ph/poster-b.svg +6 -0
- package/template/public/brand/ph/poster-c.svg +6 -0
- package/template/public/unbox/powered-by-fundo-preto.png +0 -0
- package/template/public/unbox/powered-by-transparente.png +0 -0
- package/template/public/unbox/powered-by.png +0 -0
- package/template/scripts/abandoned-cart.ts +82 -0
- package/template/scripts/check-editable.mjs +467 -0
- package/template/scripts/check-honestidade.mjs +111 -0
- package/template/scripts/check-placeholder.mjs +200 -0
- package/template/scripts/check-recipe.mjs +76 -0
- package/template/scripts/check-unbox-brand.mjs +298 -0
- package/template/scripts/contraste.js +177 -0
- package/template/scripts/dump-catalog.ts +95 -0
- package/template/scripts/load-env.ts +13 -0
- package/template/scripts/medir-sistema.mjs +56 -0
- package/template/scripts/place-order-pix.ts +61 -0
- package/template/scripts/qa-screenshots.mjs +150 -0
- package/template/scripts/sistema.py +231 -0
- package/template/scripts/subscribe-webhook.ts +36 -0
- package/template/scripts/test-live.ts +186 -0
- package/template/scripts/vocabulario.py +264 -0
- package/template/tsconfig.json +27 -0
- package/tools/LEIA-ME.md +39 -0
- package/tools/check-template-neutro.mjs +111 -0
- package/tools/notion-doc.mjs +191 -0
- package/tools/tokenize-neutrals.mjs +0 -0
- package/tools/tokenize-radius.mjs +52 -0
- package/tools/workflow-storefront.legado.js +1207 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import Image from "next/image";
|
|
6
|
+
import { Plus, Heart, Spinner, ArrowRight } from "@phosphor-icons/react/dist/ssr";
|
|
7
|
+
import { useCart } from "@/components/cart/cart-provider";
|
|
8
|
+
// EDITOR: módulo de cliente ("use client"): aqui o namespace `Editable.*` é a forma de uso (README §4).
|
|
9
|
+
import { Editable } from "@/lib/editable";
|
|
10
|
+
|
|
11
|
+
export interface CatalogItem {
|
|
12
|
+
slug: string;
|
|
13
|
+
title: string;
|
|
14
|
+
displayPrice: string;
|
|
15
|
+
imageUrl?: string | null;
|
|
16
|
+
weight?: string | null;
|
|
17
|
+
productId?: string;
|
|
18
|
+
variantId?: string | null;
|
|
19
|
+
price?: number | null;
|
|
20
|
+
badge?: { label: string; bg: string; fg: string } | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Link "Ver todos os produtos" do bloco de catálogo da PDP. É `Slot` de tipo link (e não `Link`
|
|
25
|
+
* editável) porque o rótulo divide o elemento com o ícone: no `Editable.Link`, editar o rótulo
|
|
26
|
+
* apagaria a seta. Vive aqui, num módulo de cliente, porque o render-prop não atravessa a fronteira
|
|
27
|
+
* de servidor da view (pdp-view.tsx).
|
|
28
|
+
*/
|
|
29
|
+
export function CatalogCta() {
|
|
30
|
+
return (
|
|
31
|
+
<Editable.Slot path="cta" type="link" fallback={{ href: "/produtos", label: "Ver todos os produtos" }} label="Link para o catálogo completo">
|
|
32
|
+
{(v, attrs, ref, estilo) => (
|
|
33
|
+
<Link
|
|
34
|
+
ref={ref}
|
|
35
|
+
href={v.href}
|
|
36
|
+
{...attrs}
|
|
37
|
+
style={estilo}
|
|
38
|
+
className="flex items-center gap-1.5 text-sm font-bold text-[var(--store-primary,#18181B)] no-underline max-sm:hidden"
|
|
39
|
+
>
|
|
40
|
+
{v.label ?? "Ver todos os produtos"}{" "}
|
|
41
|
+
<Editable.Icon path="cta-icone" label="Ícone do link" size={14}>
|
|
42
|
+
<ArrowRight weight="bold" />
|
|
43
|
+
</Editable.Icon>
|
|
44
|
+
</Link>
|
|
45
|
+
)}
|
|
46
|
+
</Editable.Slot>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// EDITOR: os produtos da grade são dado do catálogo, não copy do molde: fora do editor (README §8).
|
|
51
|
+
export function CatalogGrid({ items }: { items: CatalogItem[] }) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5" data-editor-ignore>
|
|
54
|
+
{items.map((p) => (
|
|
55
|
+
<Card key={p.slug} item={p} />
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Card({ item }: { item: CatalogItem }) {
|
|
62
|
+
const { add } = useCart();
|
|
63
|
+
const [busy, setBusy] = React.useState(false);
|
|
64
|
+
const canQuickAdd = !!item.productId && !!item.variantId && item.price != null;
|
|
65
|
+
|
|
66
|
+
async function quickAdd() {
|
|
67
|
+
if (!canQuickAdd) return;
|
|
68
|
+
setBusy(true);
|
|
69
|
+
await add({
|
|
70
|
+
productId: item.productId!,
|
|
71
|
+
variantId: item.variantId!,
|
|
72
|
+
price: item.price ?? 0,
|
|
73
|
+
quantity: 1,
|
|
74
|
+
thumbnail: item.imageUrl ?? undefined,
|
|
75
|
+
title: item.title,
|
|
76
|
+
});
|
|
77
|
+
setBusy(false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="relative flex flex-col rounded-2xl border border-[var(--store-line)] bg-white p-3.5">
|
|
82
|
+
{item.badge && (
|
|
83
|
+
<span className="absolute left-3.5 top-3.5 z-[1] rounded-md px-2.5 py-1 text-[10px] font-extrabold tracking-[0.3px]" style={{ background: item.badge.bg, color: item.badge.fg }}>
|
|
84
|
+
{item.badge.label}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
<button type="button" aria-label="Favoritar" className="absolute right-3.5 top-3.5 z-[1] flex h-8 w-8 items-center justify-center rounded-full border border-[var(--store-line)] bg-white">
|
|
88
|
+
<Heart className="text-[15px] text-[var(--store-faint)]" />
|
|
89
|
+
</button>
|
|
90
|
+
|
|
91
|
+
<Link href={`/produto/${encodeURIComponent(item.slug)}`} className="flex h-[150px] items-center justify-center">
|
|
92
|
+
{item.imageUrl ? (
|
|
93
|
+
<span className="relative block h-full w-full">
|
|
94
|
+
<Image src={item.imageUrl} alt={item.title} fill sizes="180px" className="object-contain p-0.5" />
|
|
95
|
+
</span>
|
|
96
|
+
) : (
|
|
97
|
+
<span className="text-[var(--store-faint)]">sem imagem</span>
|
|
98
|
+
)}
|
|
99
|
+
</Link>
|
|
100
|
+
|
|
101
|
+
<Link href={`/produto/${encodeURIComponent(item.slug)}`} className="text-sm font-bold leading-tight text-[var(--store-ink)] no-underline line-clamp-2">{item.title}</Link>
|
|
102
|
+
{item.weight && <div className="mt-0.5 text-xs text-[var(--store-muted)]">{item.weight}</div>}
|
|
103
|
+
<div className="mt-2.5 flex items-center justify-between">
|
|
104
|
+
<div className="font-display text-[17px] font-extrabold text-[var(--store-ink)]">{item.displayPrice}</div>
|
|
105
|
+
{canQuickAdd && (
|
|
106
|
+
<button type="button" onClick={quickAdd} disabled={busy} aria-label="Adicionar ao carrinho" className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--store-primary,#18181B)] text-white shadow-[var(--store-shadow-cta-sm)] transition-colors hover:bg-[var(--store-primary-dark,#09090B)] disabled:opacity-60">
|
|
107
|
+
{busy ? <Spinner className="animate-spin text-lg" /> : <Plus weight="bold" className="text-lg" />}
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// AS PERGUNTAS MODELO DO FAQ DA PÁGINA DE PRODUTO, e o eco delas para o dado estruturado.
|
|
2
|
+
//
|
|
3
|
+
// Módulo SEM "use client" de propósito: a lista (components/product/pdp/interactive.tsx, cliente) e a
|
|
4
|
+
// página (app/(loja)/produto/[productSlug]/page.tsx, servidor) leem a MESMA constante. Um server
|
|
5
|
+
// component que importasse a constante de um módulo de cliente receberia uma referência, não a lista.
|
|
6
|
+
//
|
|
7
|
+
// Só perguntas com resposta que o template CONSEGUE afirmar: o prazo de arrependimento do CDC (lei, vale
|
|
8
|
+
// para toda loja online no Brasil) e a regra de frete grátis SE configurada em lib/store-config.ts.
|
|
9
|
+
// Prazo de entrega e validade não entram: eram inventados. As perguntas do PRODUTO vêm do enriquecimento
|
|
10
|
+
// (lib/enrichment/products.json) e são dado do catálogo, não copy do molde.
|
|
11
|
+
//
|
|
12
|
+
// EDITOR: estas são as perguntas MODELO do molde, uma LISTA editável (reordenar, ocultar, duplicar), com
|
|
13
|
+
// id por PAPEL: a de frete só existe com regra real de frete grátis, e um id posicional deslizaria (a
|
|
14
|
+
// resposta que o lojista escreveu na pergunta 2 apareceria na 1) quando a regra mudasse. Ids e caminhos
|
|
15
|
+
// são chave de arquivo (README do editor, §6): não existe renomear.
|
|
16
|
+
import { FREE_SHIPPING_THRESHOLD } from "@/lib/store-config";
|
|
17
|
+
import { orderSections, resolveValue, type ContentDocument } from "@/lib/editable/document";
|
|
18
|
+
|
|
19
|
+
export interface PerguntaResposta {
|
|
20
|
+
question: string;
|
|
21
|
+
answer: string;
|
|
22
|
+
}
|
|
23
|
+
export interface PerguntaModelo extends PerguntaResposta {
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const FAQ_MODELO: readonly PerguntaModelo[] = [
|
|
29
|
+
...(FREE_SHIPPING_THRESHOLD != null
|
|
30
|
+
? [{ id: "frete", label: "Pergunta sobre frete", question: "Tem frete grátis?", answer: `Sim, para compras a partir de R$ ${FREE_SHIPPING_THRESHOLD}. Abaixo disso o frete é calculado pelo CEP no carrinho.` }]
|
|
31
|
+
: []),
|
|
32
|
+
{
|
|
33
|
+
id: "devolucao",
|
|
34
|
+
label: "Pergunta sobre devolução",
|
|
35
|
+
question: "Posso desistir da compra?",
|
|
36
|
+
answer: "Sim. Você tem 7 dias corridos a partir do recebimento para desistir, com reembolso integral, conforme o art. 49 do Código de Defesa do Consumidor. Os detalhes estão na página de trocas e devoluções.",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** caminhos de cada pergunta, RELATIVOS ao item (`produto.qualidade-faq.faq.devolucao.pergunta`) */
|
|
41
|
+
export const CAMINHO_PERGUNTA = "pergunta";
|
|
42
|
+
export const CAMINHO_RESPOSTA = "resposta";
|
|
43
|
+
|
|
44
|
+
// Onde a lista mora no documento: container da PDP, seção, cartão do FAQ, pergunta. São os mesmos ids que
|
|
45
|
+
// pdp-view.tsx e a FaqList escrevem; mudar um lado sem o outro descola o dado estruturado da tela.
|
|
46
|
+
export const CONTAINER_DA_PDP = "produto";
|
|
47
|
+
export const SECAO_QUALIDADE_FAQ = "qualidade-faq";
|
|
48
|
+
export const CARTAO_FAQ = "faq";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* O FAQ COMO ELE ESTÁ NA TELA, lido do documento publicado, para o FAQPage (JSON-LD) da página.
|
|
52
|
+
*
|
|
53
|
+
* A regra da página é uma só: o dado estruturado nasce da MESMA lista que o accordion renderiza. Se o
|
|
54
|
+
* bloco some da tela, some do JSON-LD; pergunta que a loja não responde não vira schema. Com o editor, a
|
|
55
|
+
* lista que o accordion renderiza depende do publicado: a seção pode estar oculta, o cartão do FAQ pode
|
|
56
|
+
* estar oculto ou duplicado, e cada pergunta modelo pode ter sido reordenada, ocultada, duplicada ou
|
|
57
|
+
* reescrita. Este eco lê tudo isso do documento do mesmo jeito que os primitivos leem (ordem e ocultas
|
|
58
|
+
* por `orderSections`, valor por `resolveValue`), sem registrar ponto editável nenhum: é a mesma técnica
|
|
59
|
+
* da faixa corrida da home. Sem documento (loja nunca publicou, editor fora), o que vale é o código.
|
|
60
|
+
*
|
|
61
|
+
* As perguntas do enriquecimento entram antes das modelo, na ordem em que a FaqList as mostra.
|
|
62
|
+
*/
|
|
63
|
+
export function faqNaTela(doc: ContentDocument | null | undefined, doEnriquecimento: readonly PerguntaResposta[]): PerguntaResposta[] {
|
|
64
|
+
const secoes = doc?.sections ?? {};
|
|
65
|
+
const lista: PerguntaResposta[] = [];
|
|
66
|
+
// a seção e as cópias dela que estão visíveis, na ordem do container `produto`
|
|
67
|
+
for (const secao of orderSections([SECAO_QUALIDADE_FAQ], secoes[CONTAINER_DA_PDP]).visible) {
|
|
68
|
+
const escopoDaSecao = `${CONTAINER_DA_PDP}.${secao}`;
|
|
69
|
+
// o cartão do FAQ e as cópias dele, dentro da seção
|
|
70
|
+
for (const cartao of orderSections([CARTAO_FAQ], secoes[escopoDaSecao]).visible) {
|
|
71
|
+
const escopoDoCartao = `${escopoDaSecao}.${cartao}`;
|
|
72
|
+
const estado = secoes[escopoDoCartao];
|
|
73
|
+
lista.push(...doEnriquecimento);
|
|
74
|
+
for (const id of orderSections(FAQ_MODELO.map((f) => f.id), estado).visible) {
|
|
75
|
+
// uma cópia lê o texto no próprio escopo e cai no modelo da origem quando o lojista não o mudou
|
|
76
|
+
const base = FAQ_MODELO.find((f) => f.id === (estado?.clones?.[id] ?? id));
|
|
77
|
+
if (!base) continue;
|
|
78
|
+
lista.push({
|
|
79
|
+
question: resolveValue(doc, `${escopoDoCartao}.${id}.${CAMINHO_PERGUNTA}`, base.question),
|
|
80
|
+
answer: resolveValue(doc, `${escopoDoCartao}.${id}.${CAMINHO_RESPOSTA}`, base.answer),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lista;
|
|
86
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import { Heart, PlayCircle } from "@phosphor-icons/react/dist/ssr";
|
|
6
|
+
|
|
7
|
+
export function PdpGallery({
|
|
8
|
+
images,
|
|
9
|
+
videos,
|
|
10
|
+
title,
|
|
11
|
+
discountPct,
|
|
12
|
+
}: {
|
|
13
|
+
images: string[];
|
|
14
|
+
videos?: string[];
|
|
15
|
+
title: string;
|
|
16
|
+
discountPct?: number | null;
|
|
17
|
+
}) {
|
|
18
|
+
const imgs = images.length ? images : [];
|
|
19
|
+
const [active, setActive] = React.useState(0);
|
|
20
|
+
const [wish, setWish] = React.useState(false);
|
|
21
|
+
const hasVideo = (videos?.length ?? 0) > 0;
|
|
22
|
+
const current = imgs[active];
|
|
23
|
+
|
|
24
|
+
// EDITOR: fotos e vídeo são do produto (catálogo da Unbox), não copy do molde: a galeria inteira
|
|
25
|
+
// fica fora do editor (`data-editor-ignore`, README §8).
|
|
26
|
+
return (
|
|
27
|
+
<div className="grid grid-cols-[74px_1fr] gap-4 max-md:flex max-md:flex-col-reverse lg:sticky lg:top-[150px]" data-editor-ignore>
|
|
28
|
+
{/* thumbnails */}
|
|
29
|
+
<div className="flex flex-col gap-3 max-md:flex-row max-md:overflow-x-auto">
|
|
30
|
+
{imgs.slice(0, 4).map((src, i) => (
|
|
31
|
+
<button
|
|
32
|
+
key={src + i}
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => setActive(i)}
|
|
35
|
+
aria-label={`Imagem ${i + 1}`}
|
|
36
|
+
className="relative h-[74px] w-[74px] shrink-0 overflow-hidden rounded-xl bg-[var(--store-line)]"
|
|
37
|
+
style={{ border: i === active ? "2px solid var(--store-primary,#18181B)" : "1px solid var(--store-line)" }}
|
|
38
|
+
>
|
|
39
|
+
<Image src={src} alt="" fill sizes="74px" className="object-cover" />
|
|
40
|
+
</button>
|
|
41
|
+
))}
|
|
42
|
+
{hasVideo && (
|
|
43
|
+
<a
|
|
44
|
+
href={videos![0]}
|
|
45
|
+
target="_blank"
|
|
46
|
+
rel="noreferrer"
|
|
47
|
+
className="flex h-[74px] w-[74px] shrink-0 flex-col items-center justify-center gap-0.5 rounded-xl border border-[var(--store-line)] bg-[var(--store-chrome-bg)] text-white"
|
|
48
|
+
>
|
|
49
|
+
<PlayCircle className="text-[22px] text-[var(--store-cta,#D97706)]" />
|
|
50
|
+
<span className="text-[9px] font-bold">Vídeo</span>
|
|
51
|
+
</a>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* main image */}
|
|
56
|
+
<div className="relative h-[360px] overflow-hidden rounded-xl border border-[var(--store-line)] bg-[var(--store-line)] sm:h-[560px]">
|
|
57
|
+
<div className="absolute left-4 top-4 z-[2] flex flex-col items-start gap-2">
|
|
58
|
+
{/* Sem selo "MAIS VENDIDO" fixo: aparecia em todo produto de toda loja, sem dado de venda.
|
|
59
|
+
O selo de desconto abaixo fica porque vem do compareAtPrice real. */}
|
|
60
|
+
{discountPct ? (
|
|
61
|
+
<span className="rounded-md bg-[var(--store-sale)] px-[11px] py-[5px] text-[11px] font-extrabold tracking-[0.4px] text-white">-{discountPct}% OFF</span>
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={() => setWish((w) => !w)}
|
|
67
|
+
aria-label="Favoritar"
|
|
68
|
+
className="absolute right-4 top-4 z-[2] flex h-[42px] w-[42px] items-center justify-center rounded-full border border-[var(--store-line)] bg-white"
|
|
69
|
+
>
|
|
70
|
+
<Heart weight={wish ? "fill" : "regular"} className="text-[20px]" style={{ color: wish ? "var(--store-sale)" : "var(--store-muted)" }} />
|
|
71
|
+
</button>
|
|
72
|
+
{current ? (
|
|
73
|
+
<Image src={current} alt={title} fill priority sizes="(max-width:1024px) 100vw, 50vw" className="object-cover" />
|
|
74
|
+
) : (
|
|
75
|
+
<div className="flex h-full items-center justify-center text-[var(--store-muted)]">sem imagem</div>
|
|
76
|
+
)}
|
|
77
|
+
{imgs.length > 1 && (
|
|
78
|
+
<div className="absolute bottom-3.5 left-1/2 flex -translate-x-1/2 gap-1.5">
|
|
79
|
+
{imgs.slice(0, 4).map((_, i) => (
|
|
80
|
+
<span key={i} className="h-2 w-2 rounded-full" style={{ background: i === active ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }} />
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Abas da PDP, "modo de uso" e FAQ. REGRA DESTE ARQUIVO: nada aqui tem conteúdo default de PRODUTO.
|
|
4
|
+
// Cada bloco só renderiza com dado real vindo do enriquecimento (lib/enrichment/products.json)
|
|
5
|
+
// ou da Unbox; a única copy do molde é a das perguntas MODELO do FAQ (faq-modelo.ts), que o
|
|
6
|
+
// template consegue afirmar e o lojista edita. A versão anterior trazia passos de uso de um produto de alimentação cravados no
|
|
7
|
+
// código, uma aba nutricional que aparecia com traços em loja de qualquer ramo, e um FAQ inventado
|
|
8
|
+
// (validade de 24 meses, devolução em 30 dias). Tudo isso vazou para lojas entregues.
|
|
9
|
+
// Um campo vazio declarado é melhor que um campo plausível inventado.
|
|
10
|
+
import * as React from "react";
|
|
11
|
+
import { CaretDown } from "@phosphor-icons/react/dist/ssr";
|
|
12
|
+
// EDITOR: módulo de cliente ("use client"): aqui o namespace `Editable.*` é a forma de uso (README §4).
|
|
13
|
+
import { Editable } from "@/lib/editable";
|
|
14
|
+
import { FAQ_MODELO, CAMINHO_PERGUNTA, CAMINHO_RESPOSTA, type PerguntaResposta } from "@/components/product/pdp/faq-modelo";
|
|
15
|
+
|
|
16
|
+
export interface KV { k: string; v: string }
|
|
17
|
+
|
|
18
|
+
export function ProductTabs({
|
|
19
|
+
descHtml,
|
|
20
|
+
infoHtml,
|
|
21
|
+
specItems,
|
|
22
|
+
nutriItems,
|
|
23
|
+
nutriBase,
|
|
24
|
+
usageSteps,
|
|
25
|
+
}: {
|
|
26
|
+
descHtml?: string | null;
|
|
27
|
+
infoHtml?: string | null;
|
|
28
|
+
/** Ficha técnica (composição, medidas, origem...). Vazio = a aba não existe. */
|
|
29
|
+
specItems: KV[];
|
|
30
|
+
/** Tabela nutricional — só para alimento/suplemento com dado real. Vazio = a aba não existe. */
|
|
31
|
+
nutriItems: KV[];
|
|
32
|
+
/** Base da tabela nutricional, ex.: "100 g" ou "porção de 30 g". */
|
|
33
|
+
nutriBase?: string | null;
|
|
34
|
+
/** Passos de uso do produto, do enriquecimento. Vazio = o bloco não existe. */
|
|
35
|
+
usageSteps?: string[] | null;
|
|
36
|
+
}) {
|
|
37
|
+
// As abas existem em função do dado, não o contrário.
|
|
38
|
+
const tabs: { label: string; render: () => React.ReactNode }[] = [
|
|
39
|
+
{
|
|
40
|
+
label: "Descrição",
|
|
41
|
+
render: () => (
|
|
42
|
+
<div className={`grid gap-10 ${usageSteps?.length ? "lg:grid-cols-[1.4fr_1fr]" : ""}`}>
|
|
43
|
+
<div className="text-[15px] leading-[1.75] text-[var(--store-ink-2)]">
|
|
44
|
+
{descHtml ? (
|
|
45
|
+
<div className="richtext" dangerouslySetInnerHTML={{ __html: descHtml }} />
|
|
46
|
+
) : (
|
|
47
|
+
<p className="m-0 text-[var(--store-muted)]">
|
|
48
|
+
Descrição completa ainda não cadastrada. Preencha <code>lib/enrichment/products.json</code> ou a descrição do produto no painel Unbox.
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
{usageSteps && usageSteps.length > 0 && (
|
|
53
|
+
<div className="rounded-lg border border-[var(--store-line)] bg-[var(--store-surface)] p-5">
|
|
54
|
+
<div className="font-display mb-3.5 text-sm font-bold">Modo de uso</div>
|
|
55
|
+
<div className="flex flex-col gap-3.5">
|
|
56
|
+
{usageSteps.map((s, i) => (
|
|
57
|
+
<div key={i} className="flex items-start gap-3">
|
|
58
|
+
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--store-primary-soft,#F1F1F3)] text-[13px] font-extrabold text-[var(--store-primary,#18181B)]">{i + 1}</div>
|
|
59
|
+
<div className="text-[13.5px] leading-[1.5] text-[var(--store-ink-2)]">{s}</div>
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
if (specItems.length > 0 || infoHtml) {
|
|
71
|
+
tabs.push({
|
|
72
|
+
label: "Características",
|
|
73
|
+
render: () => (
|
|
74
|
+
<div className="grid max-w-[720px] gap-x-10 gap-y-3 sm:grid-cols-2">
|
|
75
|
+
{specItems.map((sp) => (
|
|
76
|
+
<div key={sp.k} className="flex justify-between gap-6 border-b border-[var(--store-surface-2)] py-3 text-sm">
|
|
77
|
+
<span className="shrink-0 text-[var(--store-muted)]">{sp.k}</span>
|
|
78
|
+
<span className="text-right font-bold text-[var(--store-ink)]">{sp.v}</span>
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
{infoHtml && <div className="richtext sm:col-span-2" dangerouslySetInnerHTML={{ __html: infoHtml }} />}
|
|
82
|
+
</div>
|
|
83
|
+
),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (nutriItems.length > 0) {
|
|
88
|
+
tabs.push({
|
|
89
|
+
label: "Informação nutricional",
|
|
90
|
+
render: () => (
|
|
91
|
+
<div className="max-w-[520px] overflow-hidden rounded-lg border border-[var(--store-line)]">
|
|
92
|
+
<div className="font-display bg-[var(--store-chrome-bg,#18181B)] px-[18px] py-[13px] text-sm font-bold text-white">
|
|
93
|
+
Informação nutricional{nutriBase ? ` · ${nutriBase}` : ""}
|
|
94
|
+
</div>
|
|
95
|
+
{nutriItems.map((nu) => (
|
|
96
|
+
<div key={nu.k} className="flex justify-between gap-6 border-b border-[var(--store-surface-2)] px-[18px] py-3 text-sm">
|
|
97
|
+
<span className="shrink-0 text-[var(--store-ink-2)]">{nu.k}</span>
|
|
98
|
+
<span className="text-right font-bold">{nu.v}</span>
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const [tab, setTab] = React.useState(0);
|
|
107
|
+
const atual = tabs[Math.min(tab, tabs.length - 1)];
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div>
|
|
111
|
+
{tabs.length > 1 && (
|
|
112
|
+
<div className="flex gap-1 border-b-[1.5px] border-[var(--store-line)]">
|
|
113
|
+
{tabs.map((t, i) => (
|
|
114
|
+
<button
|
|
115
|
+
key={t.label}
|
|
116
|
+
type="button"
|
|
117
|
+
onClick={() => setTab(i)}
|
|
118
|
+
className="font-display relative cursor-pointer border-none bg-transparent px-3 py-3.5 text-[13px] font-bold sm:px-5 sm:text-[15px]"
|
|
119
|
+
style={{ color: tab === i ? "var(--store-primary,#18181B)" : "var(--store-muted)" }}
|
|
120
|
+
>
|
|
121
|
+
{t.label}
|
|
122
|
+
{tab === i && <span className="absolute inset-x-0 -bottom-[1.5px] h-[3px] rounded bg-[var(--store-primary,#18181B)]" />}
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
<div className="px-0.5 py-6.5 pt-6">{atual.render()}</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const CLASSE_RESPOSTA = "px-4 pb-[15px] text-[13.5px] leading-[1.6] text-[var(--store-ink-2)]";
|
|
133
|
+
|
|
134
|
+
function FaqItem({ open, onToggle, pergunta, resposta }: {
|
|
135
|
+
open: boolean;
|
|
136
|
+
onToggle: () => void;
|
|
137
|
+
pergunta: React.ReactNode;
|
|
138
|
+
/** já com `hidden` quando fechada: a resposta fica no HTML para o painel listar todas sem abrir cada uma */
|
|
139
|
+
resposta: React.ReactNode;
|
|
140
|
+
}) {
|
|
141
|
+
return (
|
|
142
|
+
<div className="overflow-hidden rounded-xl border border-[var(--store-line)] bg-white">
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={onToggle}
|
|
146
|
+
className="flex w-full cursor-pointer items-center justify-between gap-3 border-none bg-transparent px-4 py-[15px] text-left text-sm font-semibold text-[var(--store-ink)]"
|
|
147
|
+
>
|
|
148
|
+
{pergunta}
|
|
149
|
+
<CaretDown weight="bold" className="shrink-0 text-[15px] text-[var(--store-primary,#18181B)] transition-transform" style={{ transform: open ? "rotate(180deg)" : "rotate(0)" }} />
|
|
150
|
+
</button>
|
|
151
|
+
{resposta}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* FAQ do produto: as perguntas do ENRIQUECIMENTO (por produto) e as perguntas MODELO do molde
|
|
158
|
+
* (faq-modelo.ts). Sem nenhuma das duas, não renderiza nada.
|
|
159
|
+
*
|
|
160
|
+
* EDITOR: a PDP é um MOLDE, um valor para todos os produtos. As perguntas do enriquecimento são dado
|
|
161
|
+
* do catálogo e ficam fora do editor (`data-editor-ignore`, README §8). As perguntas modelo são copy
|
|
162
|
+
* e formam uma LISTA editável (reordenar, ocultar, duplicar, reescrever), com id por PAPEL. O título
|
|
163
|
+
* do cartão fica em quem renderiza esta lista (pdp-view.tsx). O FAQPage da página é lido do MESMO
|
|
164
|
+
* lugar (`faqNaTela`, faq-modelo.ts): o que muda aqui muda lá.
|
|
165
|
+
*/
|
|
166
|
+
export function FaqList({ items }: { items?: readonly PerguntaResposta[] | null }) {
|
|
167
|
+
const doEnriquecimento = items ?? [];
|
|
168
|
+
const [open, setOpen] = React.useState<string | null>(doEnriquecimento.length ? "0" : (FAQ_MODELO[0]?.id ?? null));
|
|
169
|
+
const toggle = (chave: string) => setOpen((atual) => (atual === chave ? null : chave));
|
|
170
|
+
if (doEnriquecimento.length === 0 && FAQ_MODELO.length === 0) return null;
|
|
171
|
+
return (
|
|
172
|
+
<div className="flex flex-col gap-3">
|
|
173
|
+
{doEnriquecimento.length > 0 && (
|
|
174
|
+
<div className="flex flex-col gap-3" data-editor-ignore>
|
|
175
|
+
{doEnriquecimento.map((x, i) => (
|
|
176
|
+
<FaqItem
|
|
177
|
+
key={i}
|
|
178
|
+
open={open === String(i)}
|
|
179
|
+
onToggle={() => toggle(String(i))}
|
|
180
|
+
pergunta={x.question}
|
|
181
|
+
resposta={<div className={CLASSE_RESPOSTA} hidden={open !== String(i)}>{x.answer}</div>}
|
|
182
|
+
/>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
<Editable.Sections nested>
|
|
187
|
+
{FAQ_MODELO.map((f) => (
|
|
188
|
+
<Editable.Section key={f.id} item id={f.id} label={f.label}>
|
|
189
|
+
<FaqItem
|
|
190
|
+
open={open === f.id}
|
|
191
|
+
onToggle={() => toggle(f.id)}
|
|
192
|
+
pergunta={<Editable.Text path={CAMINHO_PERGUNTA} fallback={f.question} label="Pergunta" />}
|
|
193
|
+
resposta={<Editable.Text as="div" path={CAMINHO_RESPOSTA} fallback={f.answer} label="Resposta" className={CLASSE_RESPOSTA} hidden={open !== f.id} />}
|
|
194
|
+
/>
|
|
195
|
+
</Editable.Section>
|
|
196
|
+
))}
|
|
197
|
+
</Editable.Sections>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { CheckCircle } from "@phosphor-icons/react/dist/ssr";
|
|
5
|
+
// EDITOR: módulo de cliente ("use client"): aqui o namespace `Editable.*` é a forma de uso (README §4).
|
|
6
|
+
import { Editable } from "@/lib/editable";
|
|
7
|
+
|
|
8
|
+
// EDITOR: o título vem em três caixas (uma por trecho da composição, como a home faz com o título por
|
|
9
|
+
// linha), porque o destaque sublinhado e a quebra são tipografia que o primitivo não deve engolir num
|
|
10
|
+
// `multiline` só. Campo, placeholder e a mensagem de confirmação são interface do formulário e ficam
|
|
11
|
+
// fora (README do editor, §8); o texto do botão é copy e vai por `Slot`, porque o <button> é filho
|
|
12
|
+
// direto de um flex e precisa continuar sendo o MESMO elemento, com type="submit".
|
|
13
|
+
export function Newsletter() {
|
|
14
|
+
const [email, setEmail] = React.useState("");
|
|
15
|
+
const [done, setDone] = React.useState(false);
|
|
16
|
+
|
|
17
|
+
function submit(e: React.FormEvent) {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
if (!/.+@.+\..+/.test(email)) return;
|
|
20
|
+
setDone(true);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="relative flex min-h-[120px] items-center gap-7 overflow-hidden rounded-xl bg-[var(--store-cta,#D97706)] py-6 pl-10 max-md:flex-col max-md:items-stretch max-md:gap-4 max-md:p-6">
|
|
25
|
+
<div className="shrink-0">
|
|
26
|
+
{/* TODO: personalize a oferta de boas-vindas da newsletter */}
|
|
27
|
+
<div className="font-display text-[23px] font-extrabold leading-tight text-[var(--store-cta-fg,#1C1207)]">
|
|
28
|
+
<Editable.Text path="titulo.1" fallback="Cadastre-se e receba" label="Título (parte 1)" />{" "}
|
|
29
|
+
<Editable.Text path="titulo.2" fallback="ofertas exclusivas" label="Título (destaque sublinhado)" className="underline decoration-2 underline-offset-2" />
|
|
30
|
+
<br />
|
|
31
|
+
<Editable.Text path="titulo.3" fallback="e novidades da loja!" label="Título (parte 3)" />
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
{done ? (
|
|
35
|
+
<div className="flex flex-1 items-center gap-2 rounded-full bg-white px-6 py-3.5 text-[15px] font-bold text-[var(--store-primary,#18181B)] max-md:justify-center md:max-w-[480px]">
|
|
36
|
+
<CheckCircle weight="fill" className="text-xl" /> Pronto! Seu cupom chega no e-mail.
|
|
37
|
+
</div>
|
|
38
|
+
) : (
|
|
39
|
+
<form onSubmit={submit} className="flex flex-1 items-center rounded-full bg-white p-1.5 md:max-w-[480px]">
|
|
40
|
+
<input
|
|
41
|
+
type="email"
|
|
42
|
+
value={email}
|
|
43
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
44
|
+
placeholder="Seu melhor e-mail"
|
|
45
|
+
className="h-11 min-w-0 flex-1 border-none bg-transparent px-5 text-[15px] text-[var(--store-ink)] outline-none"
|
|
46
|
+
/>
|
|
47
|
+
<Editable.Slot path="botao" type="text" fallback="EU QUERO!" label="Texto do botão">
|
|
48
|
+
{(v, attrs, ref, estilo) => (
|
|
49
|
+
<button
|
|
50
|
+
ref={ref}
|
|
51
|
+
{...attrs}
|
|
52
|
+
type="submit"
|
|
53
|
+
className="font-display h-11 shrink-0 cursor-pointer whitespace-nowrap border-none bg-transparent px-[22px] text-[15px] font-extrabold tracking-[0.5px] text-[var(--store-ink)]"
|
|
54
|
+
style={estilo}
|
|
55
|
+
>
|
|
56
|
+
{v}
|
|
57
|
+
</button>
|
|
58
|
+
)}
|
|
59
|
+
</Editable.Slot>
|
|
60
|
+
</form>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const METHODS = [
|
|
2
|
+
{ label: "Visa", src: "/brand/pay-visa.webp" },
|
|
3
|
+
{ label: "Mastercard", src: "/brand/pay-mastercard.webp" },
|
|
4
|
+
{ label: "Elo", src: "/brand/pay-elo.webp" },
|
|
5
|
+
{ label: "American Express", src: "/brand/pay-amex.webp" },
|
|
6
|
+
{ label: "Pix", src: "/brand/pay-pix.webp" },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export function PaymentChips({ size = "sm" }: { size?: "sm" | "md" }) {
|
|
10
|
+
const h = size === "md" ? "h-[34px]" : "h-[28px]";
|
|
11
|
+
// EDITOR: bandeiras de pagamento são selo de terceiro (quais meios a loja aceita é mecânica da
|
|
12
|
+
// Unbox), não copy da marca: fora do editor (`data-editor-ignore`, README §8). O atributo mora
|
|
13
|
+
// aqui, e não em cada rodapé, porque o componente também entra na página de produto e no checkout.
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex flex-wrap items-center gap-2" data-editor-ignore="">
|
|
16
|
+
{METHODS.map((m) => (
|
|
17
|
+
// eslint-disable-next-line @next/next/no-img-element -- bandeiras de ~1 KB: passar 1 KB pelo otimizador não economiza nada. O que importa aqui é o loading=lazy, porque o React 19 preloada <img> eager renderizado no servidor e estas ficam no rodapé.
|
|
18
|
+
<img key={m.label} src={m.src} alt={m.label} loading="lazy" decoding="async" className={`${h} w-auto rounded`} />
|
|
19
|
+
))}
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|