From f201b95c4ceb3f7856c0eed6e219216d9c8fc967 Mon Sep 17 00:00:00 2001 From: feispro <262678496+feiscs@users.noreply.github.com> Date: Tue, 12 May 2026 23:05:37 -0400 Subject: [PATCH] Add Turborepo starter workspace layout --- .github/ISSUE_TEMPLATE/ai-agent-task.md | 24 + .github/pull_request_template.md | 20 + .github/workflows/check.yml | 22 + .gitignore | 1 + CLAUDE.md | 9 + README.md | 11 + apps/docs/index.html | 23 + apps/docs/package.json | 15 + apps/storefront/index.html | 24 + apps/storefront/package.json | 16 + assets/config.js | 26 + assets/integrations.js | 189 ++++++ assets/store.css | 570 ++++++++++++++++++ assets/store.js | 507 ++++++++++++++++ docs/GITHUB_PLAIN_DIFFS.md | 34 ++ docs/GO_LIVE_CHECKLIST.md | 93 +++ docs/INTEGRATIONS.md | 106 ++++ docs/TURBOREPO.md | 82 +++ docs/UPSTREAM_SHOPIFY_RUBY.md | 76 +++ index.html | 264 +++++++- package-lock.json | 63 +- package.json | 31 + packages/config/index.js | 11 + packages/config/package.json | 11 + packages/storefront-data/index.js | 9 + packages/storefront-data/package.json | 11 + packages/ui/index.js | 7 + packages/ui/package.json | 11 + scripts/github-plain-diff.js | 30 + scripts/prepare-shopify-upstream.sh | 53 ++ scripts/smoke-app.js | 27 + scripts/smoke-static.js | 55 ++ scripts/turbo-doctor.js | 27 + scripts/verify-files.js | 106 ++++ shopify-ruby/.env.example | 11 + shopify-ruby/Gemfile | 6 + shopify-ruby/README.md | 40 ++ shopify-ruby/config/shopify_context.rb | 29 + .../lib/forma_shopify/admin_client.rb | 48 ++ .../lib/forma_shopify/storefront_client.rb | 40 ++ shopify-ruby/scripts/list_products.rb | 14 + shopify-theme/Gemfile | 9 + shopify-theme/README.md | 42 ++ shopify-theme/assets/ajax-cart.js.liquid | 33 + shopify-theme/assets/forma-theme.css.liquid | 152 +++++ shopify-theme/assets/forma-theme.js | 22 + shopify-theme/assets/gift-card.scss.liquid | 29 + shopify-theme/assets/timber.js.liquid | 33 + shopify-theme/config.yml | 5 + shopify-theme/config/settings_data.json | 7 + shopify-theme/config/settings_schema.json | 30 + shopify-theme/layout/theme.liquid | 27 + shopify-theme/locales/en.default.json | 96 +++ shopify-theme/locales/es.default.json | 96 +++ .../snippets/ajax-cart-template.liquid | 12 + shopify-theme/snippets/breadcrumb.liquid | 28 + .../snippets/collection-sorting.liquid | 14 + shopify-theme/snippets/comment.liquid | 6 + shopify-theme/snippets/oldIE-js.liquid | 7 + .../onboarding-empty-collection.liquid | 10 + .../onboarding-featured-collections.liquid | 10 + shopify-theme/snippets/product-card.liquid | 16 + shopify-theme/snippets/site-footer.liquid | 13 + shopify-theme/snippets/site-header.liquid | 14 + shopify-theme/spec/helpers/html_helper.rb | 32 + shopify-theme/spec/helpers/i18n_helper.rb | 38 ++ shopify-theme/spec/html_validity_spec.rb | 11 + shopify-theme/spec/i18n_validity_spec.rb | 27 + shopify-theme/spec/spec_helper.rb | 13 + shopify-theme/spec/theme_structure_spec.rb | 50 ++ shopify-theme/templates/404.liquid | 7 + shopify-theme/templates/article.liquid | 14 + shopify-theme/templates/blog.liquid | 9 + shopify-theme/templates/cart.liquid | 18 + shopify-theme/templates/collection.liquid | 15 + .../templates/collection.list.liquid | 9 + .../templates/customers/account.liquid | 5 + .../templates/customers/addresses.liquid | 8 + .../templates/customers/login.liquid | 9 + .../templates/customers/order.liquid | 5 + .../templates/customers/register.liquid | 11 + shopify-theme/templates/gift_card.liquid | 23 + shopify-theme/templates/index.liquid | 31 + .../templates/list-collections.liquid | 10 + shopify-theme/templates/page.contact.liquid | 13 + shopify-theme/templates/page.liquid | 4 + shopify-theme/templates/product.liquid | 19 + shopify-theme/templates/search.liquid | 18 + .../202605130001_storefront_core.sql | 32 + turbo.json | 42 ++ vercel.json | 24 + 91 files changed, 3941 insertions(+), 19 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/ai-agent-task.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/check.yml create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 apps/docs/index.html create mode 100644 apps/docs/package.json create mode 100644 apps/storefront/index.html create mode 100644 apps/storefront/package.json create mode 100644 assets/config.js create mode 100644 assets/integrations.js create mode 100644 assets/store.css create mode 100644 assets/store.js create mode 100644 docs/GITHUB_PLAIN_DIFFS.md create mode 100644 docs/GO_LIVE_CHECKLIST.md create mode 100644 docs/INTEGRATIONS.md create mode 100644 docs/TURBOREPO.md create mode 100644 docs/UPSTREAM_SHOPIFY_RUBY.md create mode 100644 package.json create mode 100644 packages/config/index.js create mode 100644 packages/config/package.json create mode 100644 packages/storefront-data/index.js create mode 100644 packages/storefront-data/package.json create mode 100644 packages/ui/index.js create mode 100644 packages/ui/package.json create mode 100755 scripts/github-plain-diff.js create mode 100755 scripts/prepare-shopify-upstream.sh create mode 100755 scripts/smoke-app.js create mode 100644 scripts/smoke-static.js create mode 100755 scripts/turbo-doctor.js create mode 100644 scripts/verify-files.js create mode 100644 shopify-ruby/.env.example create mode 100644 shopify-ruby/Gemfile create mode 100644 shopify-ruby/README.md create mode 100644 shopify-ruby/config/shopify_context.rb create mode 100644 shopify-ruby/lib/forma_shopify/admin_client.rb create mode 100644 shopify-ruby/lib/forma_shopify/storefront_client.rb create mode 100755 shopify-ruby/scripts/list_products.rb create mode 100644 shopify-theme/Gemfile create mode 100644 shopify-theme/README.md create mode 100644 shopify-theme/assets/ajax-cart.js.liquid create mode 100644 shopify-theme/assets/forma-theme.css.liquid create mode 100644 shopify-theme/assets/forma-theme.js create mode 100644 shopify-theme/assets/gift-card.scss.liquid create mode 100644 shopify-theme/assets/timber.js.liquid create mode 100644 shopify-theme/config.yml create mode 100644 shopify-theme/config/settings_data.json create mode 100644 shopify-theme/config/settings_schema.json create mode 100644 shopify-theme/layout/theme.liquid create mode 100644 shopify-theme/locales/en.default.json create mode 100644 shopify-theme/locales/es.default.json create mode 100644 shopify-theme/snippets/ajax-cart-template.liquid create mode 100644 shopify-theme/snippets/breadcrumb.liquid create mode 100644 shopify-theme/snippets/collection-sorting.liquid create mode 100644 shopify-theme/snippets/comment.liquid create mode 100644 shopify-theme/snippets/oldIE-js.liquid create mode 100644 shopify-theme/snippets/onboarding-empty-collection.liquid create mode 100644 shopify-theme/snippets/onboarding-featured-collections.liquid create mode 100644 shopify-theme/snippets/product-card.liquid create mode 100644 shopify-theme/snippets/site-footer.liquid create mode 100644 shopify-theme/snippets/site-header.liquid create mode 100644 shopify-theme/spec/helpers/html_helper.rb create mode 100644 shopify-theme/spec/helpers/i18n_helper.rb create mode 100644 shopify-theme/spec/html_validity_spec.rb create mode 100644 shopify-theme/spec/i18n_validity_spec.rb create mode 100644 shopify-theme/spec/spec_helper.rb create mode 100644 shopify-theme/spec/theme_structure_spec.rb create mode 100644 shopify-theme/templates/404.liquid create mode 100644 shopify-theme/templates/article.liquid create mode 100644 shopify-theme/templates/blog.liquid create mode 100644 shopify-theme/templates/cart.liquid create mode 100644 shopify-theme/templates/collection.liquid create mode 100644 shopify-theme/templates/collection.list.liquid create mode 100644 shopify-theme/templates/customers/account.liquid create mode 100644 shopify-theme/templates/customers/addresses.liquid create mode 100644 shopify-theme/templates/customers/login.liquid create mode 100644 shopify-theme/templates/customers/order.liquid create mode 100644 shopify-theme/templates/customers/register.liquid create mode 100644 shopify-theme/templates/gift_card.liquid create mode 100644 shopify-theme/templates/index.liquid create mode 100644 shopify-theme/templates/list-collections.liquid create mode 100644 shopify-theme/templates/page.contact.liquid create mode 100644 shopify-theme/templates/page.liquid create mode 100644 shopify-theme/templates/product.liquid create mode 100644 shopify-theme/templates/search.liquid create mode 100644 supabase/migrations/202605130001_storefront_core.sql create mode 100644 turbo.json create mode 100644 vercel.json diff --git a/.github/ISSUE_TEMPLATE/ai-agent-task.md b/.github/ISSUE_TEMPLATE/ai-agent-task.md new file mode 100644 index 0000000..4563853 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ai-agent-task.md @@ -0,0 +1,24 @@ +--- +name: AI agent task +about: Scope work for Claude/Copilot/GitHub coding agents +labels: ai-agent, needs-plan +--- + +## Goal + +Describe the customer/business outcome. + +## Context + +- Storefront area: +- Shopify/Supabase/Chatbase/Vercel impact: +- Design references: +- Plain `.patch`/`.diff` URL, if reviewing GitHub changes: + +## Acceptance criteria + +- [ ] Implementation matches the requested UX. +- [ ] `npm run check` passes. +- [ ] Vercel preview link is attached for UI changes. +- [ ] No secrets are committed. +- [ ] PR summary explains Shopify/Supabase/Chatbase impact if relevant. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..97b4b16 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Summary + +- + +## Integration impact + +- Shopify: +- Supabase: +- Chatbase: +- Vercel: + +## Review links + +- Plain diff/patch URL: + +## Testing + +- [ ] `npm run check` +- [ ] Vercel preview reviewed, if UI changed +- [ ] No secrets committed diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..987fe24 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,22 @@ +name: Storefront checks + +on: + pull_request: + push: + branches: [main, master, work] + +jobs: + static-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install dependencies + run: npm ci + - name: Run static checks + run: npm run check diff --git a/.gitignore b/.gitignore index 713d500..74072f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ .env +.turbo/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..97aad93 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# Claude/GitHub agent instructions for FORMA + +- Keep the storefront deployable as a static Vercel site unless a task explicitly adds a backend. +- Do not commit production secrets. Use `assets/config.js` only with blank/demo values in git. +- Prefer adding integration hooks in `assets/integrations.js` and UI behavior in `assets/store.js`. +- Run `npm run check` before every PR. +- For visual changes, attach a Vercel preview link or screenshot evidence in the PR. +- Keep Shopify as the commerce source of truth, Supabase as the CRM/event store, and Chatbase as the support assistant. +- When reviewing GitHub changes, prefer plain-text `.patch` or `.diff` URLs and see `docs/GITHUB_PLAIN_DIFFS.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a1cb59 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# FORMA storefront + +See `docs/GO_LIVE_CHECKLIST.md` to deploy and connect integrations. + +## Turborepo + +See `docs/TURBOREPO.md` for installing and running Turbo with this repo. + +## Monorepo apps and packages + +This repo now includes two deployable apps (`apps/storefront`, `apps/docs`) and three shared packages (`packages/ui`, `packages/config`, `packages/storefront-data`) to match the Turborepo starter shape. diff --git a/apps/docs/index.html b/apps/docs/index.html new file mode 100644 index 0000000..0cc19da --- /dev/null +++ b/apps/docs/index.html @@ -0,0 +1,23 @@ + + + + + + FORMA Docs App + + + +
+

Deployable app 2 · apps/docs

+

FORMA Docs

+

Documentación deployable para instalación, go-live, Shopify, Supabase, Chatbase, Vercel y Turborepo.

+ +
+ + diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..37ee4bd --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,15 @@ +{ + "name": "@forma/app-docs", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "node ../../scripts/smoke-app.js .", + "check": "node ../../scripts/smoke-app.js .", + "dev": "python3 -m http.server 4175 --directory apps/docs", + "start": "python3 -m http.server ${PORT:-4175} --directory apps/docs" + }, + "dependencies": { + "@forma/config": "*", + "@forma/ui": "*" + } +} diff --git a/apps/storefront/index.html b/apps/storefront/index.html new file mode 100644 index 0000000..999c10c --- /dev/null +++ b/apps/storefront/index.html @@ -0,0 +1,24 @@ + + + + + + FORMA Storefront App + + + +
+

Deployable app 1 · apps/storefront

+

FORMA Storefront

+

App estática deployable dentro del monorepo Turborepo. La tienda principal completa sigue disponible en la raíz del repo.

+
+
Trench Lino Arena
Prendas · $148
+
Bolso Arc Cognac
Bolsos · $96
+
Tote Weekend
Bolsos · $112
+
+

Abrir storefront completo

+
+ + diff --git a/apps/storefront/package.json b/apps/storefront/package.json new file mode 100644 index 0000000..5ae48cd --- /dev/null +++ b/apps/storefront/package.json @@ -0,0 +1,16 @@ +{ + "name": "@forma/app-storefront", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "node ../../scripts/smoke-app.js .", + "check": "node ../../scripts/smoke-app.js .", + "dev": "python3 -m http.server 4174 --directory apps/storefront", + "start": "python3 -m http.server ${PORT:-4174} --directory apps/storefront" + }, + "dependencies": { + "@forma/config": "*", + "@forma/storefront-data": "*", + "@forma/ui": "*" + } +} diff --git a/assets/config.js b/assets/config.js new file mode 100644 index 0000000..eef72a0 --- /dev/null +++ b/assets/config.js @@ -0,0 +1,26 @@ +// FORMA runtime configuration. +// Keep real credentials in Vercel Environment Variables or generate this file during deploy. +// Values left blank keep the storefront in safe local-demo mode. +window.FORMA_CONFIG = { + shopify: { + domain: '', + storefrontToken: '', + apiVersion: '2026-01', + enableRemoteProducts: false, + }, + supabase: { + url: '', + anonKey: '', + eventsTable: 'store_events', + newsletterTable: 'newsletter_signups', + }, + chatbase: { + botId: '', + enabled: false, + }, + githubAgent: { + provider: 'Claude', + repo: 'FEISHTML', + workflow: 'Plan → branch → PR → Vercel preview → review → merge', + }, +}; diff --git a/assets/integrations.js b/assets/integrations.js new file mode 100644 index 0000000..477d5ff --- /dev/null +++ b/assets/integrations.js @@ -0,0 +1,189 @@ +(function attachFormaIntegrations() { + const emptyConfig = { + shopify: {}, + supabase: {}, + chatbase: {}, + githubAgent: {}, + }; + + function getConfig() { + return { ...emptyConfig, ...(window.FORMA_CONFIG || {}) }; + } + + function hasSupabase() { + const { supabase } = getConfig(); + return Boolean(supabase?.url && supabase?.anonKey); + } + + async function supabaseInsert(tableName, payload) { + const { supabase } = getConfig(); + if (!hasSupabase() || !tableName) return { skipped: true }; + + const response = await fetch(`${supabase.url.replace(/\/$/, '')}/rest/v1/${tableName}`, { + method: 'POST', + headers: { + apikey: supabase.anonKey, + Authorization: `Bearer ${supabase.anonKey}`, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Supabase insert failed: ${response.status}`); + } + + return { ok: true }; + } + + async function trackEvent(eventName, payload = {}) { + const { supabase } = getConfig(); + try { + return await supabaseInsert(supabase.eventsTable, { + event_name: eventName, + payload, + page_path: window.location.pathname, + user_agent: window.navigator.userAgent, + created_at: new Date().toISOString(), + }); + } catch (error) { + console.warn('[FORMA] Event tracking skipped:', error.message); + return { skipped: true, error }; + } + } + + async function saveNewsletter(email) { + const { supabase } = getConfig(); + try { + return await supabaseInsert(supabase.newsletterTable, { + email, + source: 'forma-storefront', + created_at: new Date().toISOString(), + }); + } catch (error) { + console.warn('[FORMA] Newsletter sync skipped:', error.message); + return { skipped: true, error }; + } + } + + function loadChatbase() { + const { chatbase } = getConfig(); + if (!chatbase?.enabled || !chatbase?.botId || document.querySelector('[data-chatbase-loader]')) { + return false; + } + + window.embeddedChatbotConfig = { chatbotId: chatbase.botId, domain: 'www.chatbase.co' }; + const script = document.createElement('script'); + script.src = 'https://www.chatbase.co/embed.min.js'; + script.id = chatbase.botId; + script.setAttribute('chatbotId', chatbase.botId); + script.setAttribute('domain', 'www.chatbase.co'); + script.dataset.chatbaseLoader = 'true'; + script.defer = true; + document.body.appendChild(script); + return true; + } + + function hasShopify() { + const { shopify } = getConfig(); + return Boolean(shopify?.domain && shopify?.storefrontToken && shopify?.enableRemoteProducts); + } + + async function fetchShopifyProducts() { + const { shopify } = getConfig(); + if (!hasShopify()) return []; + + const endpoint = `https://${shopify.domain}/api/${shopify.apiVersion || '2026-01'}/graphql.json`; + const query = ` + query FormaProducts($first: Int!) { + products(first: $first, sortKey: CREATED_AT, reverse: true) { + edges { + node { + id + title + productType + createdAt + description + tags + featuredImage { url altText } + variants(first: 20) { + edges { + node { + id + title + price { amount currencyCode } + compareAtPrice { amount currencyCode } + selectedOptions { name value } + } + } + } + } + } + } + } + `; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Storefront-Access-Token': shopify.storefrontToken, + }, + body: JSON.stringify({ query, variables: { first: 12 } }), + }); + + if (!response.ok) { + throw new Error(`Shopify request failed: ${response.status}`); + } + + const data = await response.json(); + if (data.errors) { + throw new Error(data.errors.map((error) => error.message).join(', ')); + } + + return data.data.products.edges.map(({ node }, index) => { + const firstVariant = node.variants.edges[0]?.node; + const price = Number(firstVariant?.price?.amount || 0); + const oldPrice = Number(firstVariant?.compareAtPrice?.amount || 0) || null; + const colorValues = new Set(); + const sizeValues = new Set(); + + node.variants.edges.forEach(({ node: variant }) => { + variant.selectedOptions.forEach((option) => { + if (/color/i.test(option.name)) colorValues.add(option.value); + if (/size|talla/i.test(option.name)) sizeValues.add(option.value); + }); + }); + + return { + id: node.id, + shopifyVariantId: firstVariant?.id, + name: node.title, + category: node.productType || 'Shopify', + price, + oldPrice, + rating: 4.8, + badge: node.tags[0] || 'Shopify', + createdAt: 100 + index, + description: node.description || 'Producto sincronizado desde Shopify.', + colors: [...colorValues].length ? [...colorValues] : ['Default'], + sizes: [...sizeValues].length ? [...sizeValues] : ['Única'], + bg: '#eadfd0', + gradient: 'linear-gradient(145deg, #fff8ec, #c89b77)', + shape: '1.2rem', + image: node.featuredImage?.url, + }; + }); + } + + window.FormaIntegrations = { + fetchShopifyProducts, + getConfig, + hasShopify, + hasSupabase, + loadChatbase, + saveNewsletter, + trackEvent, + }; +})(); diff --git a/assets/store.css b/assets/store.css new file mode 100644 index 0000000..a9e0052 --- /dev/null +++ b/assets/store.css @@ -0,0 +1,570 @@ +:root { + --ink: #16130f; + --muted: #766f66; + --line: #e8dfd3; + --paper: #fbf7f0; + --sand: #efe4d4; + --clay: #b96f4f; + --olive: #6f7758; + --cream: #fffaf2; + --shadow: 0 28px 90px rgba(52, 39, 25, 0.16); +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + color: var(--ink); + background: linear-gradient(180deg, #fffaf2, #f3eadc 45%, #fffaf2); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +body.is-locked { overflow: hidden; } +a { color: inherit; text-decoration: none; } +button, input, select { font: inherit; } +button { cursor: pointer; } + +.navbar { + position: sticky; + top: 0; + z-index: 50; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 2rem; + padding: 1rem clamp(1rem, 4vw, 4rem); + background: rgba(255, 250, 242, 0.84); + border-bottom: 1px solid rgba(22, 19, 15, 0.08); + backdrop-filter: blur(18px); +} +.brand { + display: inline-flex; + align-items: center; + gap: 0.7rem; + font-weight: 900; + letter-spacing: 0.22em; +} +.brand-mark { + display: grid; + place-items: center; + width: 2.35rem; + height: 2.35rem; + color: var(--cream); + background: var(--ink); + border-radius: 50%; + letter-spacing: 0; +} +.nav-links { + display: flex; + justify-content: center; + gap: clamp(1rem, 3vw, 2.5rem); + color: var(--muted); + font-size: 0.9rem; + font-weight: 800; +} +.nav-links a:hover { color: var(--ink); } +.nav-actions { display: flex; align-items: center; gap: 0.65rem; } +.search-box { + display: flex; + align-items: center; + gap: 0.45rem; + width: min(18vw, 13rem); + padding: 0.65rem 0.85rem; + background: #fff; + border: 1px solid var(--line); + border-radius: 999px; +} +.search-box input { + min-width: 0; + width: 100%; + border: 0; + outline: 0; + background: transparent; +} +.nav-icon, .cart-trigger, .view-toggle, .tab, .close-btn { + border: 1px solid var(--line); + color: var(--ink); + background: #fff; +} +.nav-icon { + position: relative; + display: grid; + place-items: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 50%; + font-weight: 900; +} +.nav-icon strong, .cart-trigger strong { + display: grid; + place-items: center; + min-width: 1.35rem; + height: 1.35rem; + color: #fff; + background: var(--clay); + border-radius: 999px; + font-size: 0.72rem; +} +.nav-icon strong { + position: absolute; + top: -0.35rem; + right: -0.25rem; +} +.cart-trigger { + display: inline-flex; + align-items: center; + gap: 0.55rem; + min-height: 2.75rem; + padding: 0 1rem; + border-radius: 999px; + font-weight: 900; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(330px, 1.05fr); + gap: clamp(2rem, 6vw, 6rem); + align-items: center; + min-height: calc(100vh - 5rem); + padding: clamp(3rem, 6vw, 6rem) clamp(1rem, 5vw, 5rem); +} +.eyebrow { + margin: 0 0 0.85rem; + color: var(--clay); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.2em; + text-transform: uppercase; +} +h1, h2, p { margin-top: 0; } +h1, h2 { + font-family: "Playfair Display", Georgia, serif; + line-height: 0.94; + letter-spacing: -0.055em; +} +h1 { max-width: 11ch; margin-bottom: 1.35rem; font-size: clamp(4rem, 9vw, 9rem); } +h2 { margin-bottom: 1rem; font-size: clamp(2.5rem, 6vw, 5.4rem); } +.hero-copy > p:not(.eyebrow), .section-head p, .editorial p, .footer p { + max-width: 42rem; + color: var(--muted); + line-height: 1.75; + font-size: 1.05rem; +} +.hero-actions { display: flex; flex-wrap: wrap; gap: 1rem; margin: 2rem 0; } +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3.15rem; + padding: 0 1.25rem; + border: 1px solid transparent; + border-radius: 999px; + font-weight: 900; + transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease; +} +.btn:hover { transform: translateY(-2px); box-shadow: 0 16px 35px rgba(22, 19, 15, 0.13); } +.btn-dark { color: #fff; background: var(--ink); } +.btn-light { color: var(--ink); background: #fff; border-color: var(--line); } +.stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; + max-width: 35rem; + margin: 0; +} +.stats div, .feature-bar article, .product-card, .drawer-panel, .product-modal, .footer { + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(22, 19, 15, 0.08); + box-shadow: var(--shadow); +} +.stats div { padding: 1rem; border-radius: 1.25rem; } +.stats dt { font-size: 2rem; font-weight: 900; } +.stats dd { margin: 0; color: var(--muted); font-size: 0.86rem; } + +.campaign-card { position: relative; min-height: 42rem; } +.campaign-image { + position: absolute; + inset: 0; + overflow: hidden; + background: + radial-gradient(circle at 68% 38%, rgba(185, 111, 79, 0.3), transparent 18rem), + linear-gradient(140deg, #d6c1a7, #f8eee0 46%, #8a765f 46.5%, #c08a67 65%, #efe4d4); + border: 1px solid rgba(22, 19, 15, 0.1); + border-radius: 2.5rem; + box-shadow: var(--shadow); +} +.campaign-image::before { + content: ""; + position: absolute; + right: 12%; + bottom: 10%; + width: 42%; + height: 58%; + background: linear-gradient(160deg, #251f19, #8e6048 58%, #e7d1b7 59%); + border-radius: 48% 48% 1.4rem 1.4rem; + box-shadow: 0 30px 60px rgba(22, 19, 15, 0.25); +} +.campaign-image::after { + content: "FORMA"; + position: absolute; + left: -0.4rem; + bottom: 2rem; + color: rgba(255, 255, 255, 0.54); + font-size: clamp(5rem, 10vw, 9rem); + font-weight: 900; + letter-spacing: 0.08em; +} +.campaign-note { + position: absolute; + left: 1.5rem; + top: 1.5rem; + display: grid; + gap: 0.25rem; + padding: 1rem 1.1rem; + background: rgba(255, 250, 242, 0.78); + border: 1px solid rgba(255,255,255,0.4); + border-radius: 1rem; + backdrop-filter: blur(12px); +} +.campaign-note span { color: var(--clay); font-size: 0.75rem; font-weight: 900; text-transform: uppercase; } + +.feature-bar, .catalog, .editorial, .footer { + width: min(1180px, calc(100% - 2rem)); + margin: 0 auto; +} +.feature-bar { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + padding: 1rem 0 5rem; +} +.feature-bar article { padding: 1.2rem; border-radius: 1.25rem; } +.feature-bar span { font-size: 1.7rem; } +.feature-bar strong { display: block; margin: 0.7rem 0 0.25rem; } +.feature-bar p { margin-bottom: 0; color: var(--muted); } + +.catalog, .editorial { padding: 5rem 0; } +.section-head { max-width: 46rem; } +.catalog-toolbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin: 2rem 0 1.2rem; +} +.tabs, .toolbar-actions { display: flex; flex-wrap: wrap; gap: 0.65rem; } +.tab, .view-toggle { + min-height: 2.75rem; + padding: 0 1rem; + border-radius: 999px; + font-weight: 900; +} +.tab.is-active, .tab:hover, .view-toggle[aria-pressed="true"] { color: #fff; background: var(--ink); } +.toolbar-actions label { + display: grid; + gap: 0.35rem; + color: var(--muted); + font-size: 0.75rem; + font-weight: 900; + text-transform: uppercase; +} +select { + min-height: 2.75rem; + padding: 0 0.9rem; + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; +} +.product-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} +.product-grid.is-compact { grid-template-columns: repeat(6, minmax(0, 1fr)); } +.product-card { + position: relative; + overflow: hidden; + border-radius: 1.4rem; + transition: transform 180ms ease, box-shadow 180ms ease; +} +.product-card:hover { transform: translateY(-4px); } +.product-media { + position: relative; + display: grid; + place-items: center; + min-height: 17rem; + overflow: hidden; + background: var(--product-bg, var(--sand)); +} +.product-visual { + width: 62%; + aspect-ratio: 0.82; + border-radius: var(--shape, 42% 42% 1.2rem 1.2rem); + background: var(--product-gradient); + box-shadow: 0 20px 45px rgba(22, 19, 15, 0.18); + transition: transform 250ms ease; +} +.product-card:hover .product-visual { transform: scale(1.06) rotate(-2deg); } +.badge { + position: absolute; + top: 0.8rem; + left: 0.8rem; + padding: 0.45rem 0.65rem; + color: #fff; + background: var(--clay); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; +} +.favorite-btn { + position: absolute; + top: 0.65rem; + right: 0.65rem; + display: grid; + place-items: center; + width: 2.35rem; + height: 2.35rem; + border: 0; + background: rgba(255,255,255,0.8); + border-radius: 50%; + font-size: 1.2rem; +} +.favorite-btn.is-active { color: #fff; background: var(--clay); } +.quick-add { + position: absolute; + right: 0.8rem; + bottom: 0.8rem; + left: 0.8rem; + opacity: 0; + transform: translateY(0.5rem); +} +.product-card:hover .quick-add, .product-card:focus-within .quick-add { opacity: 1; transform: translateY(0); } +.product-info { padding: 1rem; } +.product-top { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: 0.55rem; } +.product-top h3 { margin: 0; font-size: 1rem; } +.rating { color: var(--clay); font-weight: 900; } +.product-info p { min-height: 3.2rem; color: var(--muted); line-height: 1.55; } +.price-line { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.price-line strong { font-size: 1.18rem; } +.price-line s { color: var(--muted); font-size: 0.86rem; } +.detail-link { border: 0; background: transparent; color: var(--clay); font-weight: 900; } +.product-grid.is-compact .product-media { min-height: 11rem; } +.product-grid.is-compact .product-info p, .product-grid.is-compact .quick-add { display: none; } +.empty-state { padding: 3rem; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 1.2rem; } + +.editorial { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(320px, 1.15fr); + gap: clamp(2rem, 5vw, 4rem); + align-items: center; +} +.editorial-grid { + display: grid; + grid-template-columns: 1fr 0.8fr; + grid-template-rows: repeat(2, 14rem); + gap: 1rem; +} +.art { border-radius: 1.6rem; box-shadow: var(--shadow); } +.art-one { grid-row: 1 / 3; background: linear-gradient(145deg, #806b56, #e9d4ba); } +.art-two { background: linear-gradient(145deg, #2b2923, #9a7d5f); } +.art-three { background: linear-gradient(145deg, #dcbf9e, #fff8eb); } + +.drawer { + position: fixed; + inset: 0; + z-index: 70; + pointer-events: none; + background: rgba(22, 19, 15, 0); + transition: background 180ms ease; +} +.drawer.is-open { pointer-events: auto; background: rgba(22, 19, 15, 0.34); } +.drawer-panel { + position: absolute; + top: 0; + right: 0; + display: grid; + grid-template-rows: auto auto 1fr auto; + width: min(100%, 30rem); + height: 100%; + padding: 1.2rem; + background: var(--cream); + transform: translateX(105%); + transition: transform 220ms ease; +} +.drawer.is-open .drawer-panel { transform: translateX(0); } +.small-panel { grid-template-rows: auto 1fr; } +.drawer-head { display: flex; justify-content: space-between; align-items: start; border-bottom: 1px solid var(--line); } +.drawer-head h2 { margin-bottom: 1rem; font-size: 2.9rem; } +.close-btn { + display: grid; + place-items: center; + width: 2.6rem; + height: 2.6rem; + border-radius: 50%; + font-size: 1.5rem; +} +.shipping-meter { display: grid; gap: 0.7rem; padding: 1rem 0; color: var(--muted); font-size: 0.92rem; } +.shipping-meter span { overflow: hidden; height: 0.5rem; background: var(--sand); border-radius: 999px; } +.shipping-meter i { display: block; width: 0; height: 100%; background: var(--olive); border-radius: inherit; transition: width 180ms ease; } +.cart-items, .wishlist-items { overflow: auto; } +.cart-empty { color: var(--muted); text-align: center; padding: 4rem 1rem; } +.line-item { + display: grid; + grid-template-columns: 4.5rem 1fr auto; + gap: 0.9rem; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid var(--line); +} +.line-thumb { height: 4.5rem; background: var(--thumb-bg); border-radius: 1rem; } +.line-item h3 { margin: 0 0 0.2rem; font-size: 0.95rem; } +.line-item p { margin: 0; color: var(--muted); font-size: 0.85rem; } +.qty { display: inline-flex; align-items: center; gap: 0.55rem; margin-top: 0.55rem; } +.qty button { width: 1.7rem; height: 1.7rem; border: 1px solid var(--line); background: #fff; border-radius: 50%; } +.cart-footer { display: grid; gap: 1rem; padding-top: 1rem; border-top: 1px solid var(--line); } +.cart-footer dl { display: grid; gap: 0.65rem; margin: 0; } +.cart-footer div { display: flex; justify-content: space-between; } +.cart-footer dt { color: var(--muted); } +.cart-footer dd { margin: 0; font-weight: 900; } + +.product-modal { + width: min(960px, calc(100% - 2rem)); + padding: 0; + border: 0; + border-radius: 1.6rem; + background: var(--cream); +} +.product-modal::backdrop { background: rgba(22, 19, 15, 0.45); backdrop-filter: blur(3px); } +.modal-close { position: absolute; top: 1rem; right: 1rem; z-index: 2; } +.modal-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 0.92fr); gap: 1.4rem; padding: 1.2rem; } +.modal-gallery { display: grid; gap: 0.8rem; } +.modal-hero { min-height: 28rem; display: grid; place-items: center; background: var(--modal-bg); border-radius: 1.2rem; } +.modal-hero .product-visual { width: 42%; } +.thumbs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.65rem; } +.thumbs button { min-height: 5rem; border: 1px solid var(--line); border-radius: 0.9rem; background: var(--modal-bg); } +.modal-copy { padding: 1rem 1rem 1rem 0; } +.modal-copy h2 { font-size: clamp(2.4rem, 5vw, 4.2rem); } +.option-group { margin: 1.2rem 0; } +.option-group strong { display: block; margin-bottom: 0.6rem; } +.option-list { display: flex; flex-wrap: wrap; gap: 0.55rem; } +.option-list button { min-height: 2.35rem; padding: 0 0.8rem; border: 1px solid var(--line); background: #fff; border-radius: 999px; font-weight: 800; } +.option-list button.is-selected { color: #fff; background: var(--ink); } +.guarantees { display: grid; gap: 0.55rem; margin: 1rem 0; padding: 0; list-style: none; color: var(--muted); } +.guarantees li::before { content: "✓"; margin-right: 0.55rem; color: var(--olive); font-weight: 900; } + +.footer { + display: grid; + grid-template-columns: 1fr 1.25fr 0.55fr; + gap: 2rem; + margin-top: 4rem; + margin-bottom: 1rem; + padding: 2rem; + border-radius: 1.6rem; +} +.newsletter label { display: block; margin-bottom: 0.75rem; font-weight: 900; } +.newsletter div { display: flex; gap: 0.65rem; } +.newsletter input { + width: 100%; + min-height: 3.15rem; + padding: 0 1rem; + border: 1px solid var(--line); + border-radius: 999px; +} +.newsletter p { min-height: 1.4rem; margin: 0.6rem 0 0; color: var(--olive); font-weight: 800; } +.footer nav { display: grid; gap: 0.7rem; align-content: start; color: var(--muted); font-weight: 800; } +.footer nav a:hover { color: var(--ink); } + +@media (max-width: 1020px) { + .navbar { grid-template-columns: 1fr auto; } + .nav-links { display: none; } + .search-box { width: 10rem; } + .hero, .editorial, .modal-grid { grid-template-columns: 1fr; } + .campaign-card { min-height: 32rem; } + .feature-bar, .product-grid, .product-grid.is-compact { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .catalog-toolbar { align-items: stretch; flex-direction: column; } + .footer { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .navbar { gap: 0.8rem; padding-inline: 0.8rem; } + .brand { letter-spacing: 0.12em; } + .search-box, .nav-icon[aria-label="Cuenta de usuario"] { display: none; } + .hero { padding-top: 2rem; } + h1 { font-size: clamp(3.4rem, 18vw, 5rem); } + .stats, .feature-bar, .product-grid, .product-grid.is-compact, .editorial-grid { grid-template-columns: 1fr; } + .editorial-grid { grid-template-rows: repeat(3, 12rem); } + .art-one { grid-row: auto; } + .newsletter div { flex-direction: column; } +} + +.ops-stack { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr); + gap: clamp(2rem, 5vw, 4rem); + align-items: start; + width: min(1180px, calc(100% - 2rem)); + margin: 0 auto; + padding: 5rem 0; +} +.ops-stack p { color: var(--muted); line-height: 1.75; } +.ops-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} +.ops-grid article { + min-height: 11rem; + padding: 1.2rem; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(22, 19, 15, 0.08); + border-radius: 1.25rem; + box-shadow: var(--shadow); +} +.ops-grid article:last-child { grid-column: 1 / -1; } +.ops-grid strong { display: block; margin-bottom: 0.65rem; font-size: 1.05rem; } +.ops-grid span { + display: inline-flex; + margin-bottom: 0.7rem; + padding: 0.38rem 0.6rem; + color: #fff; + background: var(--ink); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; +} +.ops-grid span.is-live { background: var(--olive); } +.product-image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 250ms ease; +} +.product-card:hover .product-image { transform: scale(1.06); } +.modal-hero .product-image { border-radius: 1.2rem; } + +@media (max-width: 1020px) { + .ops-stack { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .ops-grid { grid-template-columns: 1fr; } + .ops-grid article:last-child { grid-column: auto; } +} + +.runtime-panel { + margin-top: 1rem; + padding: 1rem; + color: var(--muted); + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(22, 19, 15, 0.08); + border-radius: 1rem; + box-shadow: var(--shadow); +} +html[data-runtime-error] .runtime-panel::after { + content: " Error detectado: " attr(data-runtime-error); + display: block; + margin-top: 0.5rem; + color: #b00020; + font-weight: 900; +} diff --git a/assets/store.js b/assets/store.js new file mode 100644 index 0000000..4933536 --- /dev/null +++ b/assets/store.js @@ -0,0 +1,507 @@ +const defaultProducts = [ + { + id: 'linen-trench', + name: 'Trench Lino Arena', + category: 'Prendas', + price: 148, + oldPrice: 188, + rating: 4.9, + badge: '-20%', + createdAt: 8, + description: 'Trench liviano de lino premium con caída relajada, bolsillos amplios y acabado repelente al agua.', + colors: ['Arena', 'Oliva', 'Negro'], + sizes: ['XS', 'S', 'M', 'L'], + bg: '#ead9c3', + gradient: 'linear-gradient(145deg, #d8b894, #f8ecdc 48%, #77624b 49%, #b96f4f)', + shape: '44% 44% 1.2rem 1.2rem', + }, + { + id: 'arc-bag', + name: 'Bolso Arc Cognac', + category: 'Bolsos', + price: 96, + oldPrice: null, + rating: 4.8, + badge: 'Nuevo', + createdAt: 10, + description: 'Bolso estructurado con asa curva, cierre magnético y compartimento interior para básicos diarios.', + colors: ['Cognac', 'Crema', 'Chocolate'], + sizes: ['Única'], + bg: '#e8c9aa', + gradient: 'radial-gradient(circle at 50% 18%, #fff1df 0 18%, transparent 19%), linear-gradient(160deg, #8a5136, #c48763)', + shape: '48% 48% 18% 18%', + }, + { + id: 'ribbed-set', + name: 'Set Rib Studio', + category: 'Prendas', + price: 82, + oldPrice: 104, + rating: 4.7, + badge: '-21%', + createdAt: 6, + description: 'Conjunto ribbed de dos piezas con textura suave, cintura alta y fit cómodo para todo el día.', + colors: ['Marfil', 'Topo', 'Grafito'], + sizes: ['S', 'M', 'L'], + bg: '#eee4d6', + gradient: 'repeating-linear-gradient(90deg, #f7efe4 0 8px, #ddcbb7 8px 13px)', + shape: '1.1rem', + }, + { + id: 'ceramic-vase', + name: 'Jarrón Forma 02', + category: 'Hogar', + price: 58, + oldPrice: null, + rating: 4.9, + badge: 'Nuevo', + createdAt: 9, + description: 'Pieza cerámica hecha a mano con silueta orgánica y esmalte mate para espacios minimalistas.', + colors: ['Crudo', 'Terracota'], + sizes: ['S', 'M'], + bg: '#e7ddcd', + gradient: 'linear-gradient(145deg, #fff8ec, #c89b77)', + shape: '50% 50% 34% 34%', + }, + { + id: 'silk-scarf', + name: 'Pañuelo Seda Grid', + category: 'Accesorios', + price: 44, + oldPrice: null, + rating: 4.6, + badge: 'Limited', + createdAt: 7, + description: 'Pañuelo de seda con patrón geométrico, bordes enrollados y acabado satinado.', + colors: ['Marfil', 'Azul', 'Caramelo'], + sizes: ['Única'], + bg: '#dde1d2', + gradient: 'linear-gradient(45deg, #fff 0 25%, #6f7758 25% 50%, #d7a075 50% 75%, #fff 75%)', + shape: '0.8rem', + }, + { + id: 'leather-belt', + name: 'Cinturón Nudo', + category: 'Accesorios', + price: 52, + oldPrice: 68, + rating: 4.7, + badge: '-24%', + createdAt: 5, + description: 'Cinturón en cuero vegetal con hebilla escultural y costuras tono sobre tono.', + colors: ['Negro', 'Cognac'], + sizes: ['S', 'M', 'L'], + bg: '#dcc7ae', + gradient: 'linear-gradient(90deg, #2a211b 0 22%, #b96f4f 22% 78%, #2a211b 78%)', + shape: '999px', + }, + { + id: 'weekend-tote', + name: 'Tote Weekend', + category: 'Bolsos', + price: 112, + oldPrice: null, + rating: 4.8, + badge: 'Nuevo', + createdAt: 11, + description: 'Tote amplio de canvas encerado con correas de cuero y bolsillo para laptop de 14 pulgadas.', + colors: ['Canvas', 'Oliva', 'Negro'], + sizes: ['Única'], + bg: '#e2d3bf', + gradient: 'linear-gradient(160deg, #efe4d4 0 45%, #6f7758 46% 75%, #2b2923 76%)', + shape: '1rem 1rem 2rem 2rem', + }, + { + id: 'scent-candle', + name: 'Vela Santal 300g', + category: 'Hogar', + price: 38, + oldPrice: 48, + rating: 4.5, + badge: '-20%', + createdAt: 4, + description: 'Vela aromática de santal, ámbar y cedro en vaso reusable de vidrio ahumado.', + colors: ['Ámbar', 'Humo'], + sizes: ['300g'], + bg: '#eadfd0', + gradient: 'linear-gradient(180deg, #fff1c7 0 12%, #37312c 13% 100%)', + shape: '0.5rem 0.5rem 1.2rem 1.2rem', + }, +]; + +let products = [...defaultProducts]; + +const store = { + state: { + cart: [], + wishlist: [], + filter: 'all', + sort: 'newest', + compact: false, + search: '', + selectedProduct: null, + selectedSize: null, + selectedColor: null, + }, + set(partial) { + this.state = { ...this.state, ...partial }; + render(); + }, +}; + +const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); +const escapeHTML = (value = '') => String(value).replace(/[&<>'"]/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"', +}[char])); +const productGrid = document.querySelector('[data-product-grid]'); +const emptyState = document.querySelector('[data-empty-state]'); +const cartDrawer = document.querySelector('[data-cart-drawer]'); +const wishlistDrawer = document.querySelector('[data-wishlist-drawer]'); +const modal = document.querySelector('[data-product-modal]'); +const modalContent = document.querySelector('[data-modal-content]'); +const runtimeStatus = document.querySelector('[data-runtime-status]'); + +function setRuntimeStatus(message) { + if (runtimeStatus) runtimeStatus.textContent = message; +} + +function visibleProducts() { + const query = store.state.search.trim().toLowerCase(); + const filtered = products.filter((product) => { + const categoryMatch = store.state.filter === 'all' || product.category === store.state.filter; + const queryMatch = !query || `${product.name} ${product.category} ${product.description}`.toLowerCase().includes(query); + return categoryMatch && queryMatch; + }); + + return filtered.sort((a, b) => { + if (store.state.sort === 'price-asc') return a.price - b.price; + if (store.state.sort === 'price-desc') return b.price - a.price; + if (store.state.sort === 'rating') return b.rating - a.rating; + return b.createdAt - a.createdAt; + }); +} + +function productStyle(product) { + return `--product-bg:${product.bg};--thumb-bg:${product.bg};--modal-bg:${product.bg};--product-gradient:${product.gradient};--shape:${product.shape};`; +} + +function productMedia(product) { + return product.image + ? `${escapeHTML(product.name)}` + : ''; +} + +function renderProducts() { + const list = visibleProducts(); + productGrid.dataset.enhanced = 'true'; + productGrid.classList.toggle('is-compact', store.state.compact); + emptyState.hidden = list.length > 0; + + productGrid.innerHTML = list.map((product) => { + const isFavorite = store.state.wishlist.includes(product.id); + return ` +
+
+ ${escapeHTML(product.badge)} + + ${productMedia(product)} + +
+
+
+

${escapeHTML(product.name)}

+ ★ ${product.rating} +
+

${escapeHTML(product.description)}

+
+ ${money.format(product.price)} ${product.oldPrice ? `${money.format(product.oldPrice)}` : ''} + +
+
+
+ `; + }).join(''); +} + +function addToCart(productId, options = {}) { + const product = products.find((item) => item.id === productId); + if (!product) return; + const size = options.size || product.sizes[0]; + const color = options.color || product.colors[0]; + const key = `${productId}-${size}-${color}`; + const existing = store.state.cart.find((item) => item.key === key); + + if (existing) { + existing.quantity += 1; + } else { + store.state.cart.push({ ...product, key, size, color, quantity: 1 }); + } + + render(); + window.FormaIntegrations?.trackEvent('cart_add', { productId, size, color, price: product.price }); + openDrawer(cartDrawer); +} + +function changeQuantity(key, amount) { + const item = store.state.cart.find((cartItem) => cartItem.key === key); + if (!item) return; + item.quantity += amount; + if (item.quantity <= 0) { + store.state.cart = store.state.cart.filter((cartItem) => cartItem.key !== key); + } + render(); +} + +function toggleFavorite(productId) { + const exists = store.state.wishlist.includes(productId); + store.state.wishlist = exists + ? store.state.wishlist.filter((id) => id !== productId) + : [...store.state.wishlist, productId]; + render(); +} + +function renderCart() { + const cartItems = document.querySelector('[data-cart-items]'); + const count = store.state.cart.reduce((sum, item) => sum + item.quantity, 0); + const subtotal = store.state.cart.reduce((sum, item) => sum + item.price * item.quantity, 0); + const freeShippingTarget = 100; + const remaining = Math.max(freeShippingTarget - subtotal, 0); + const shipping = subtotal === 0 ? 8 : remaining > 0 ? 8 : 0; + const progress = Math.min((subtotal / freeShippingTarget) * 100, 100); + + document.querySelector('[data-cart-count]').textContent = count; + document.querySelector('[data-subtotal]').textContent = money.format(subtotal); + document.querySelector('[data-shipping-cost]').textContent = shipping === 0 ? 'Gratis' : money.format(shipping); + document.querySelector('[data-total]').textContent = money.format(subtotal + shipping); + document.querySelector('[data-shipping-progress]').style.width = `${progress}%`; + document.querySelector('[data-shipping-copy]').textContent = subtotal === 0 + ? 'Agrega productos para desbloquear envío gratis.' + : remaining > 0 + ? `Te faltan ${money.format(remaining)} para envío gratis.` + : '¡Desbloqueaste envío gratis!'; + + if (!store.state.cart.length) { + cartItems.innerHTML = '

Tu bolsa está vacía. Agrega tus favoritos para empezar.

'; + return; + } + + cartItems.innerHTML = store.state.cart.map((item) => ` +
+ +
+

${escapeHTML(item.name)}

+

${escapeHTML(item.color)} · ${escapeHTML(item.size)} · ${money.format(item.price)}

+
+ + ${item.quantity} + +
+
+ ${money.format(item.price * item.quantity)} +
+ `).join(''); +} + +function renderWishlist() { + document.querySelector('[data-wishlist-count]').textContent = store.state.wishlist.length; + const wishlistItems = document.querySelector('[data-wishlist-items]'); + const list = store.state.wishlist.map((id) => products.find((product) => product.id === id)).filter(Boolean); + + wishlistItems.innerHTML = list.length + ? list.map((product) => ` +
+ +

${escapeHTML(product.name)}

${escapeHTML(product.category)} · ${money.format(product.price)}

+ +
+ `).join('') + : '

Aún no tienes favoritos.

'; +} + +function openProduct(productId) { + const product = products.find((item) => item.id === productId); + if (!product) return; + store.state.selectedProduct = product.id; + store.state.selectedSize = product.sizes[0]; + store.state.selectedColor = product.colors[0]; + renderModal(); + if (typeof modal.showModal === 'function') { + modal.showModal(); + } else { + modal.setAttribute('open', ''); + } + document.body.classList.add('is-locked'); +} + +function renderModal() { + const product = products.find((item) => item.id === store.state.selectedProduct); + if (!product) return; + + modalContent.innerHTML = ` + + + `; +} + +function openDrawer(drawer) { + drawer.classList.add('is-open'); + drawer.setAttribute('aria-hidden', 'false'); + document.body.classList.add('is-locked'); +} + +function closeDrawer(drawer) { + drawer.classList.remove('is-open'); + drawer.setAttribute('aria-hidden', 'true'); + if (!cartDrawer.classList.contains('is-open') && !wishlistDrawer.classList.contains('is-open') && !modal.open) { + document.body.classList.remove('is-locked'); + } +} + +function closeModal() { + if (typeof modal.close === 'function') { + modal.close(); + } else { + modal.removeAttribute('open'); + } + document.body.classList.remove('is-locked'); +} + +function renderIntegrationStatus() { + const config = window.FormaIntegrations?.getConfig?.() || {}; + const statuses = { + shopify: config.shopify?.enableRemoteProducts && config.shopify?.domain ? 'Conectado' : 'Modo demo', + supabase: config.supabase?.url && config.supabase?.anonKey ? 'Conectado' : 'Modo demo', + chatbase: config.chatbase?.enabled && config.chatbase?.botId ? 'Activo' : 'Modo demo', + }; + + Object.entries(statuses).forEach(([key, label]) => { + const node = document.querySelector(`[data-integration-status="${key}"]`); + if (!node) return; + node.textContent = label; + node.classList.toggle('is-live', label !== 'Modo demo'); + }); +} + +function render() { + renderProducts(); + renderCart(); + renderWishlist(); + renderIntegrationStatus(); + setRuntimeStatus(`Tienda activa: ${products.length} registros de producto cargados. Filtros, carrito, wishlist y modal listos.`); +} + +async function hydrateCatalog() { + try { + const remoteProducts = await window.FormaIntegrations?.fetchShopifyProducts?.(); + if (remoteProducts?.length) { + products = remoteProducts; + render(); + window.FormaIntegrations?.trackEvent('shopify_catalog_loaded', { count: remoteProducts.length }); + } else { + setRuntimeStatus(`Tienda activa en modo demo: ${products.length} registros locales cargados. Configura Shopify para productos remotos.`); + } + } catch (error) { + console.warn('[FORMA] Shopify catalog fallback enabled:', error.message); + } +} + +document.addEventListener('click', (event) => { + const filterButton = event.target.closest('[data-filter]'); + const quickAddButton = event.target.closest('[data-quick-add]'); + const productButton = event.target.closest('[data-open-product]'); + const favoriteButton = event.target.closest('[data-favorite]'); + const quantityButton = event.target.closest('[data-qty]'); + const sizeButton = event.target.closest('[data-size]'); + const colorButton = event.target.closest('[data-color]'); + const modalAddButton = event.target.closest('[data-modal-add]'); + + if (filterButton) { + store.set({ filter: filterButton.dataset.filter }); + document.querySelectorAll('[data-filter]').forEach((button) => button.classList.toggle('is-active', button === filterButton)); + } + if (quickAddButton) addToCart(quickAddButton.dataset.quickAdd); + if (productButton) openProduct(productButton.dataset.openProduct); + if (favoriteButton) toggleFavorite(favoriteButton.dataset.favorite); + if (quantityButton) changeQuantity(quantityButton.dataset.qty, Number(quantityButton.dataset.amount)); + if (sizeButton) { store.state.selectedSize = sizeButton.dataset.size; renderModal(); } + if (colorButton) { store.state.selectedColor = colorButton.dataset.color; renderModal(); } + if (modalAddButton) { + closeModal(); + addToCart(modalAddButton.dataset.modalAdd, { size: store.state.selectedSize, color: store.state.selectedColor }); + } + + if (event.target.closest('[data-open-cart]')) openDrawer(cartDrawer); + if (event.target.closest('[data-close-cart]')) closeDrawer(cartDrawer); + if (event.target.closest('[data-open-wishlist]')) openDrawer(wishlistDrawer); + if (event.target.closest('[data-close-wishlist]')) closeDrawer(wishlistDrawer); + if (event.target.closest('[data-close-modal]')) closeModal(); + if (event.target === cartDrawer) closeDrawer(cartDrawer); + if (event.target === wishlistDrawer) closeDrawer(wishlistDrawer); + if (event.target.closest('[data-open-featured]')) openProduct('linen-trench'); +}); + +document.querySelector('[data-sort]').addEventListener('change', (event) => store.set({ sort: event.target.value })); +document.querySelector('[data-search]').addEventListener('input', (event) => store.set({ search: event.target.value })); +document.querySelector('[data-view-toggle]').addEventListener('click', (event) => { + store.state.compact = !store.state.compact; + event.currentTarget.setAttribute('aria-pressed', String(store.state.compact)); + renderProducts(); +}); + +document.querySelector('[data-newsletter-form]').addEventListener('submit', async (event) => { + event.preventDefault(); + const email = event.currentTarget.querySelector('input[type="email"]').value; + await window.FormaIntegrations?.saveNewsletter?.(email); + window.FormaIntegrations?.trackEvent('newsletter_signup', { email }); + document.querySelector('[data-newsletter-message]').textContent = 'Listo. Te avisaremos del próximo drop privado.'; +}); + +modal.addEventListener('click', (event) => { + const dialogBounds = modal.getBoundingClientRect(); + const isBackdropClick = event.clientX < dialogBounds.left || event.clientX > dialogBounds.right || event.clientY < dialogBounds.top || event.clientY > dialogBounds.bottom; + if (isBackdropClick) closeModal(); +}); + +try { + render(); + window.FormaIntegrations?.loadChatbase?.(); + hydrateCatalog(); +} catch (error) { + console.error('[FORMA] Runtime error:', error); + document.documentElement.dataset.runtimeError = error.message; + setRuntimeStatus('La tienda cargó HTML, pero el runtime JS necesita revisión. Revisa la consola.'); +} diff --git a/docs/GITHUB_PLAIN_DIFFS.md b/docs/GITHUB_PLAIN_DIFFS.md new file mode 100644 index 0000000..d4aa953 --- /dev/null +++ b/docs/GITHUB_PLAIN_DIFFS.md @@ -0,0 +1,34 @@ +# GitHub plain-text patch and diff URLs + +Professional review tip: add `.patch` or `.diff` to the end of many GitHub PR, commit and compare URLs to get plain-text output that is easier to inspect, archive, quote and feed into AI agents. + +## Examples + +```text +https://github.com/Shopify/Timber/pull/1.patch +https://github.com/Shopify/Timber/pull/1.diff +https://github.com/Shopify/Timber/commit/.patch +https://github.com/Shopify/Timber/compare/main...branch.diff +``` + +## Helper script + +Use the local helper to normalize a GitHub URL into a plain patch or diff URL: + +```bash +scripts/github-plain-diff.js https://github.com/Shopify/Timber/pull/1 patch +scripts/github-plain-diff.js https://github.com/Shopify/Timber/pull/1 diff +``` + +## Why this helps agents + +- Plain text avoids heavy GitHub UI markup. +- `.patch` includes commit metadata and mail-style patches. +- `.diff` is compact when you only need file changes. +- Reviewers can paste exact plain-text URLs into issues, PR comments or AI prompts. + +## Suggested workflow + +1. Use `.diff` for quick code review. +2. Use `.patch` when commit metadata matters. +3. Include one of these URLs in AI-agent tasks when asking for review or implementation follow-up. diff --git a/docs/GO_LIVE_CHECKLIST.md b/docs/GO_LIVE_CHECKLIST.md new file mode 100644 index 0000000..87d3fca --- /dev/null +++ b/docs/GO_LIVE_CHECKLIST.md @@ -0,0 +1,93 @@ +# FORMA go-live checklist + +If the page exists on GitHub but you do not see the storefront working, the missing step is usually deployment/configuration rather than more files. + +## What works immediately + +The static demo storefront works with local demo data when served from the repo root: + +```bash +npm ci +npm run check +npm run dev +# open http://127.0.0.1:4173/ +``` + +You should see: + +- Product cards rendered by `assets/store.js`. +- Category filters and sort controls. +- Wishlist drawer. +- Product detail modal. +- Quick Add and cart drawer. +- Newsletter confirmation message. + +## What does not work just by pushing a branch + +Pushing the branch to GitHub does **not** automatically activate these systems: + +| Area | Why it still looks inactive | Required next step | +| --- | --- | --- | +| Public website | GitHub branch is code storage, not necessarily hosting. | Connect the repo/branch to Vercel or enable GitHub Pages. | +| Shopify product sync | `assets/config.js` ships blank credentials and `enableRemoteProducts: false`. | Generate runtime config from Vercel env vars or edit demo config locally. | +| Shopify checkout | The current static demo cart is client-side only. | Connect Storefront Cart API/checkout URL or use the Liquid theme in Shopify. | +| Supabase events/newsletter | Supabase URL and anon key are blank. | Create project, run migration, add URL/key to runtime config. | +| Chatbase widget | Chatbase bot ID is blank and `enabled: false`. | Create bot, add bot ID and set enabled true. | +| Shopify Liquid theme | Files in `shopify-theme/` are not active until uploaded to Shopify. | Upload with Shopify CLI/Theme Kit or copy into a Shopify theme repo. | +| Ruby Shopify adapter | It needs gems and Shopify Admin env vars. | `cd shopify-ruby`, `bundle install`, fill `.env`, run smoke script. | + +## Vercel deployment path + +1. Push branch to GitHub. +2. Import the repo in Vercel. +3. Select the branch you pushed. +4. Framework preset: **Other** / static. +5. Build command: optional `npm run check`. +6. Output directory: repo root (`.`). +7. Deploy and open the Vercel preview URL. + +## Runtime config path + +For production, do not commit secrets to `assets/config.js`. Instead generate the file during deploy or replace placeholders using Vercel environment variables. + +Minimum values to turn integrations from demo to connected: + +```js +window.FORMA_CONFIG = { + shopify: { + domain: 'your-store.myshopify.com', + storefrontToken: 'public-storefront-token', + apiVersion: '2026-01', + enableRemoteProducts: true, + }, + supabase: { + url: 'https://your-project.supabase.co', + anonKey: 'public-anon-key', + eventsTable: 'store_events', + newsletterTable: 'newsletter_signups', + }, + chatbase: { + botId: 'your-chatbase-bot-id', + enabled: true, + }, +}; +``` + +## Shopify theme path + +The static Vercel store and the Shopify Liquid theme are separate deliverables: + +- Static storefront: repo root `index.html` + `assets/`. +- Shopify theme: `shopify-theme/`. + +To make the Shopify theme visible inside Shopify, upload or connect `shopify-theme/` with Shopify tooling, then preview it from Shopify Admin. + +## Quick diagnosis + +Run: + +```bash +npm run smoke:static +``` + +This checks that `index.html` references the required storefront scripts/styles and that all referenced local assets exist. diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md new file mode 100644 index 0000000..6fd7291 --- /dev/null +++ b/docs/INTEGRATIONS.md @@ -0,0 +1,106 @@ +# FORMA integration map + +This storefront is intentionally static-first so it can ship quickly to Vercel, while still exposing clean hooks for commerce, CRM, support and AI-assisted maintenance. + +## Runtime configuration + +Edit `assets/config.js` locally for demos, or generate it during Vercel builds from environment variables. Never commit real production tokens. + +| Area | Config keys | Purpose | +| --- | --- | --- | +| Shopify | `shopify.domain`, `shopify.storefrontToken`, `shopify.apiVersion`, `shopify.enableRemoteProducts` | Reads products and variants through the Shopify Storefront API. | +| Supabase | `supabase.url`, `supabase.anonKey`, `supabase.eventsTable`, `supabase.newsletterTable` | Stores storefront events and newsletter signups. | +| Chatbase | `chatbase.botId`, `chatbase.enabled` | Loads the Chatbase support widget only when enabled. | +| GitHub AI | `githubAgent.provider`, `githubAgent.workflow` | Documents the expected Claude-style GitHub agent workflow. | + +## Shopify path + +1. Create a Shopify custom app with Storefront API access. +2. Enable product read permissions and copy the Storefront access token. +3. Set `shopify.domain` to the shop domain, for example `forma.myshopify.com`. +4. Set `shopify.enableRemoteProducts` to `true`. +5. The storefront falls back to the local demo catalog if Shopify is not configured or fails. + + + +## Shopify Liquid theme scaffold + +The `shopify-theme/` directory mirrors the classic Timber-style theme structure requested for Shopify theme work: + +- `assets/` for JavaScript, CSS and theme images. +- `layout/theme.liquid` as the base HTML document. +- `snippets/` for reusable Liquid components. +- `templates/` for Shopify page types, including cart, collection, product, search, blog and customer templates. +- `spec/` for a structure smoke test plus Timber-style i18n/HTML helper specs using Nokogiri, HTMLEntities and RSpec. +- `config.yml` as a legacy Theme Kit style placeholder. + +Validate it with: + +```bash +ruby shopify-theme/spec/theme_structure_spec.rb +``` + +Optional full theme specs after installing Ruby gems: + +```bash +cd shopify-theme +bundle install +bundle exec rspec +``` + +This is a migration scaffold inspired by Timber, not a vendored copy of Timber. It includes Timber-style files such as `timber.js.liquid`, `ajax-cart.js.liquid`, `breadcrumb`, `collection-sorting`, onboarding snippets, `comment`, `oldIE-js`, and `gift_card.liquid`. + +## Shopify Ruby Admin adapter + +Reference repository: https://github.com/Shopify/shopify-api-ruby + +If you want to contribute changes back to the upstream Shopify Ruby API gem, use `docs/UPSTREAM_SHOPIFY_RUBY.md` and `scripts/prepare-shopify-upstream.sh` to prepare a clean fork-based workspace. + +The `shopify-ruby/` folder contains the FORMA-side Ruby adapter for secure Shopify Admin API operations. Use it when you need server-side tasks such as product syncs, order operations, inventory workflows or private Admin GraphQL calls that must not run in the browser. + +Quick start: + +```bash +cd shopify-ruby +cp .env.example .env +bundle install +SHOPIFY_PRODUCT_LIMIT=5 bundle exec ruby scripts/list_products.rb +``` + +The static Vercel storefront can keep using `assets/integrations.js` for safe Storefront/demo behavior, while this Ruby adapter handles private Admin API work. + +## Supabase path + +1. Create a Supabase project. +2. Run `supabase/migrations/202605130001_storefront_core.sql`. +3. Put the project URL and anon key into runtime config. +4. The storefront will insert: + - `cart_add` events into `store_events`. + - `newsletter_signup` rows into `newsletter_signups`. + +## Chatbase path + +Reference: https://www.chatbase.co/docs/developer-guides/javascript-embed + +1. Create a Chatbase bot trained with product, shipping, return and brand FAQs. +2. Copy the bot ID into `chatbase.botId`. +3. Set `chatbase.enabled` to `true`. +4. The widget script is injected at runtime by `assets/integrations.js`. + +## GitHub AI agent workflow + +Use Claude, Copilot Workspace or another GitHub agent with this loop: + +1. Create an issue using `.github/ISSUE_TEMPLATE/ai-agent-task.md`. +2. Ask the agent to create a branch from the issue. +3. Require a PR with screenshots or Vercel preview evidence for UI changes. +4. Review the PR using the checklist in `.github/pull_request_template.md`. +5. Merge only after `npm run check` and Vercel preview pass. + +## Vercel path + +1. Import the GitHub repo into Vercel. +2. Use no build command for the static storefront, or set `npm run check` as an optional preflight in CI. +3. Output directory should be the repo root. +4. Add production env vars if generating `assets/config.js` during deploy. +5. Vercel previews should be used for every GitHub PR. diff --git a/docs/TURBOREPO.md b/docs/TURBOREPO.md new file mode 100644 index 0000000..b6378ad --- /dev/null +++ b/docs/TURBOREPO.md @@ -0,0 +1,82 @@ +# Turborepo setup for FORMA + +This repo now includes a minimal `turbo.json` so the existing storefront checks can be run through Turborepo when `turbo` is installed. + +Official installation reference reviewed: + +## Quick start options + +Create a new Turborepo starter elsewhere: + +```bash +pnpm dlx create-turbo@latest +yarn dlx create-turbo@latest +npx create-turbo@latest +bunx create-turbo@latest +``` + +Install Turbo globally for this existing repo: + +```bash +pnpm add turbo --global +yarn global add turbo +npm install turbo --global +bun install turbo --global +``` + +The official docs recommend installing `turbo` globally for convenient terminal usage and locally in a repo for stable team workflows. In this environment, installing the npm package returned HTTP 403, so this repo keeps the Turborepo config committed while leaving package installation to your machine/CI network. + +## Commands in this repo + +Without Turbo: + +```bash +npm run check +npm run smoke:static +npm run dev +``` + +With Turbo installed: + +```bash +turbo run check +turbo run smoke:static +turbo run verify:files +``` + +Check whether Turbo is available: + +```bash +npm run turbo:doctor +``` + +## Why this is not a full create-turbo rewrite + +`create-turbo` creates a new monorepo with multiple apps and packages. FORMA already has a static storefront, Shopify Liquid theme scaffold, Shopify Ruby adapter and deployment docs, so this change adds Turborepo orchestration without moving files or breaking the current Vercel/static workflow. + + +## Starter-compatible monorepo shape + +The official starter describes two deployable applications and three shared libraries. FORMA now mirrors that shape while preserving the existing static root storefront: + +```text +apps/ + storefront/ # deployable static storefront app + docs/ # deployable static docs app +packages/ + ui/ # shared HTML/UI helpers + config/ # shared brand/deployment config + storefront-data/ # shared product/category records +``` + +Useful commands: + +```bash +npm run check:workspaces +npm run build +turbo run build +turbo build --filter=@forma/app-docs --dry +cd apps/docs && turbo build +``` + +`turbo` is an alias for `turbo run`, so `turbo build` and `turbo run build` are equivalent. diff --git a/docs/UPSTREAM_SHOPIFY_RUBY.md b/docs/UPSTREAM_SHOPIFY_RUBY.md new file mode 100644 index 0000000..7c6fd14 --- /dev/null +++ b/docs/UPSTREAM_SHOPIFY_RUBY.md @@ -0,0 +1,76 @@ +# Contributing upstream to Shopify/shopify-api-ruby + +Yes — this project is prepared to help create focused upstream contributions for `Shopify/shopify-api-ruby`. + +Upstream repository: + +## What I can do from this workspace + +- Inspect the FORMA Shopify adapter and identify issues that may belong upstream. +- Create a clean reproduction script or failing test against `shopify_api`. +- Prepare a patch branch in a local clone of `Shopify/shopify-api-ruby` or your fork. +- Draft the upstream PR title, body, test evidence and migration notes. + +## What I need from you before opening an upstream PR + +1. Your GitHub fork URL, for example: + - `git@github.com:/shopify-api-ruby.git` + - or `https://github.com//shopify-api-ruby.git` +2. The issue or improvement you want to contribute. +3. Confirmation that I can work in a sibling directory outside this storefront repo. +4. Any Shopify app/test-store context needed to reproduce the issue, without sharing secrets in git. + +## Recommended upstream workflow + +```bash +# From this repo root +scripts/prepare-shopify-upstream.sh git@github.com:/shopify-api-ruby.git + +cd ../shopify-api-ruby-upstream +git checkout -b forma/ +bundle install +bundle exec rake test +``` + +Then implement the smallest possible change, add/update tests, run the project checks and push to your fork: + +```bash +git push -u fork forma/ +``` + +Open the PR against `Shopify/shopify-api-ruby:main`. + + +## Plain-text GitHub review URLs + +When reviewing upstream Shopify changes, append `.patch` or `.diff` to GitHub PR, commit or compare URLs. See `docs/GITHUB_PLAIN_DIFFS.md` or run: + +```bash +scripts/github-plain-diff.js https://github.com/Shopify/shopify-api-ruby/pull/ patch +``` + +## PR quality checklist + +- [ ] The PR solves one clear upstream problem. +- [ ] The PR includes a failing test or regression coverage when possible. +- [ ] The PR does not include FORMA-specific product/business code. +- [ ] The PR does not include secrets, store domains, tokens or private payloads. +- [ ] The PR body includes reproduction steps and test output. +- [ ] The PR follows the upstream repository style instead of this storefront style. + +## Good contribution candidates + +- Documentation improvements discovered while wiring `ShopifyAPI::Context`. +- Clear examples for Admin GraphQL or Storefront GraphQL clients. +- Better error messages or guards around configuration. +- Test coverage for client behavior that is currently unclear. + +## Not good upstream candidates + +- FORMA storefront UI, catalog data, Supabase integration or Chatbase integration. +- Private Shopify app credentials or store-specific behavior. +- Large refactors without an issue or maintainer alignment. + +## Current environment note + +This container could not read the upstream Git remote directly because the network tunnel returned HTTP 403. If you provide a fork and the environment allows SSH/HTTPS Git access, the helper script can prepare the workspace. If not, I can still draft patches and PR text for you to apply locally. diff --git a/index.html b/index.html index ddfc503..e0149b1 100644 --- a/index.html +++ b/index.html @@ -1,18 +1,248 @@ - - - - - - - FEISS Gaming — Perifericos Premium - - - - - - - - -
- + + + + + + FORMA — Tienda premium de esenciales modernos + + + + + + + + + + + + + +
+
+
+

Nueva campaña · Primavera urbana

+

Esenciales con forma, intención y carácter.

+

+ Compra una selección curada de moda, accesorios y objetos lifestyle con estética editorial, materiales premium y experiencia de compra sin fricción. +

+
+ Comprar ahora + +
+
+
24h
despacho
+
+18k
clientes
+
4.9
rating
+
+
+
+ +
+ Drop 04 + Minimal Utility +
+
+
+ +
+
🚚Envío gratis

En pedidos desde $100.

+
Devoluciones fáciles

30 días para cambios.

+
Garantía premium

Materiales verificados.

+
Soporte humano

Asesoría de estilo real.

+
+ +
+
+

Catálogo

+

Compra por categoría

+

Filtros rápidos, ordenamiento inteligente, vista compacta y tarjetas listas para conversión.

+
+ +
+
+ + + + + +
+
+ + +
+
+ +
+
+
-20%
+

Trench Lino Arena

★ 4.9

Trench liviano de lino premium con caída relajada.

$148.00Prendas
+
+
+
Nuevo
+

Bolso Arc Cognac

★ 4.8

Bolso estructurado con asa curva y cierre magnético.

$96.00Bolsos
+
+
+
-21%
+

Set Rib Studio

★ 4.7

Conjunto ribbed de dos piezas con textura suave.

$82.00Prendas
+
+
+
Nuevo
+

Jarrón Forma 02

★ 4.9

Pieza cerámica hecha a mano con silueta orgánica.

$58.00Hogar
+
+
+
Limited
+

Pañuelo Seda Grid

★ 4.6

Pañuelo de seda con patrón geométrico satinado.

$44.00Accesorios
+
+
+
-24%
+

Cinturón Nudo

★ 4.7

Cinturón en cuero vegetal con hebilla escultural.

$52.00Accesorios
+
+
+
Nuevo
+

Tote Weekend

★ 4.8

Tote amplio de canvas encerado con correas de cuero.

$112.00Bolsos
+
+
+
-20%
+

Vela Santal 300g

★ 4.5

Vela aromática de santal, ámbar y cedro.

$38.00Hogar
+
+
+ +

Catálogo HTML cargado. El JavaScript activará filtros, carrito, wishlist y modal al terminar de cargar.

+
+ +
+
+

Colección cápsula

+

Diseñada para combinar sin pensar.

+

+ Piezas neutras, siluetas limpias y acentos de textura para elevar outfits diarios, viajes cortos y espacios personales. +

+
+
+ + + +
+
+ +
+
+

Operación conectada

+

Lista para Shopify, Supabase, Chatbase, GitHub AI y Vercel.

+

+ La tienda queda organizada para usar Shopify como catálogo y checkout, Supabase como CRM/eventos, Chatbase como asistente, GitHub con agente IA tipo Claude para PRs y Vercel para previews y producción. +

+
+
+
ShopifyModo demo

Storefront API para productos y variantes.

+
SupabaseModo demo

Eventos, newsletter y base operativa.

+
ChatbaseModo demo

Chatbot de soporte cargado por configuración.

+
GitHub AIClaude-ready

Flujo de issues, ramas, PRs y revisión.

+
VercelPreview-ready

Deploy estático con headers y rewrites.

+
+
+
+ + + + + + + + + + +
+
+ FFORMA +

Un e-commerce editorial construido para vender productos premium con claridad, confianza y estilo.

+
+ + +
+ + diff --git a/package-lock.json b/package-lock.json index af2503e..a6af0e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,65 @@ { - "name": "project", + "name": "forma-storefront", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "name": "forma-storefront", + "version": "1.0.0", + "workspaces": [ + "apps/*", + "packages/*" + ] + }, + "apps/docs": { + "name": "@forma/app-docs", + "version": "1.0.0", + "dependencies": { + "@forma/config": "*", + "@forma/ui": "*" + } + }, + "apps/storefront": { + "name": "@forma/app-storefront", + "version": "1.0.0", + "dependencies": { + "@forma/config": "*", + "@forma/storefront-data": "*", + "@forma/ui": "*" + } + }, + "node_modules/@forma/app-docs": { + "resolved": "apps/docs", + "link": true + }, + "node_modules/@forma/app-storefront": { + "resolved": "apps/storefront", + "link": true + }, + "node_modules/@forma/config": { + "resolved": "packages/config", + "link": true + }, + "node_modules/@forma/storefront-data": { + "resolved": "packages/storefront-data", + "link": true + }, + "node_modules/@forma/ui": { + "resolved": "packages/ui", + "link": true + }, + "packages/config": { + "name": "@forma/config", + "version": "1.0.0" + }, + "packages/storefront-data": { + "name": "@forma/storefront-data", + "version": "1.0.0" + }, + "packages/ui": { + "name": "@forma/ui", + "version": "1.0.0" + } + } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..ceb4ac1 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "forma-storefront", + "version": "1.0.0", + "private": true, + "description": "FORMA editorial ecommerce storefront with Shopify, Supabase, Chatbase and Vercel integration hooks.", + "scripts": { + "dev": "python3 -m http.server 4173", + "check": "npm run verify:files && npm run smoke:static && npm run check:workspaces && node --check assets/integrations.js && node --check assets/store.js && node --check scripts/verify-files.js && node --check scripts/smoke-static.js && node --check scripts/smoke-app.js && ruby shopify-theme/spec/theme_structure_spec.rb", + "start": "python3 -m http.server ${PORT:-4173}", + "verify:files": "node scripts/verify-files.js", + "theme:spec": "cd shopify-theme && bundle exec rspec", + "smoke:static": "node scripts/smoke-static.js", + "turbo:doctor": "node scripts/turbo-doctor.js", + "turbo:check": "turbo run check", + "turbo:smoke": "turbo run smoke:static", + "build": "npm run --workspaces --if-present build", + "check:workspaces": "npm run --workspaces --if-present check" + }, + "keywords": [ + "ecommerce", + "shopify", + "supabase", + "chatbase", + "vercel" + ], + "packageManager": "npm@11.4.2", + "workspaces": [ + "apps/*", + "packages/*" + ] +} diff --git a/packages/config/index.js b/packages/config/index.js new file mode 100644 index 0000000..3c45576 --- /dev/null +++ b/packages/config/index.js @@ -0,0 +1,11 @@ +export const brand = { + name: 'FORMA', + tagline: 'Esenciales con forma, intención y carácter.', + supportEmail: 'help@forma.store', +}; + +export const deployment = { + staticRoot: '.', + storefrontApp: 'apps/storefront', + docsApp: 'apps/docs', +}; diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..e47099c --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,11 @@ +{ + "name": "@forma/config", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.js", + "scripts": { + "build": "node --check index.js", + "check": "node --check index.js" + } +} diff --git a/packages/storefront-data/index.js b/packages/storefront-data/index.js new file mode 100644 index 0000000..d59c37c --- /dev/null +++ b/packages/storefront-data/index.js @@ -0,0 +1,9 @@ +export const products = [ + { id: 'linen-trench', name: 'Trench Lino Arena', category: 'Prendas', price: 148 }, + { id: 'arc-bag', name: 'Bolso Arc Cognac', category: 'Bolsos', price: 96 }, + { id: 'silk-scarf', name: 'Pañuelo Seda Grid', category: 'Accesorios', price: 44 }, + { id: 'weekend-tote', name: 'Tote Weekend', category: 'Bolsos', price: 112 }, + { id: 'scent-candle', name: 'Vela Santal 300g', category: 'Hogar', price: 38 }, +]; + +export const categories = [...new Set(products.map((product) => product.category))]; diff --git a/packages/storefront-data/package.json b/packages/storefront-data/package.json new file mode 100644 index 0000000..73e69f9 --- /dev/null +++ b/packages/storefront-data/package.json @@ -0,0 +1,11 @@ +{ + "name": "@forma/storefront-data", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.js", + "scripts": { + "build": "node --check index.js", + "check": "node --check index.js" + } +} diff --git a/packages/ui/index.js b/packages/ui/index.js new file mode 100644 index 0000000..e048d65 --- /dev/null +++ b/packages/ui/index.js @@ -0,0 +1,7 @@ +export function productCard(product) { + return `
${product.name}${product.category}$${product.price}
`; +} + +export function pageShell({ title, body }) { + return `${title}${body}`; +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..3aaa07c --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,11 @@ +{ + "name": "@forma/ui", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.js", + "scripts": { + "build": "node --check index.js", + "check": "node --check index.js" + } +} diff --git a/scripts/github-plain-diff.js b/scripts/github-plain-diff.js new file mode 100755 index 0000000..bd53aa8 --- /dev/null +++ b/scripts/github-plain-diff.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node + +const input = process.argv[2]; +const format = process.argv[3] || 'patch'; + +if (!input || !['patch', 'diff'].includes(format)) { + console.error('Usage: scripts/github-plain-diff.js [patch|diff]'); + console.error('Example: scripts/github-plain-diff.js https://github.com/Shopify/Timber/pull/1 patch'); + process.exit(64); +} + +let url; +try { + url = new URL(input); +} catch (error) { + console.error(`Invalid URL: ${input}`); + process.exit(65); +} + +if (url.hostname !== 'github.com') { + console.error('Only github.com URLs are supported.'); + process.exit(66); +} + +url.hash = ''; +url.search = ''; +url.pathname = url.pathname.replace(/\.(patch|diff)$/i, ''); +url.pathname = `${url.pathname}.${format}`; + +console.log(url.toString()); diff --git a/scripts/prepare-shopify-upstream.sh b/scripts/prepare-shopify-upstream.sh new file mode 100755 index 0000000..e5b6288 --- /dev/null +++ b/scripts/prepare-shopify-upstream.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +FORK_URL="${1:-}" +TARGET_DIR="${2:-../shopify-api-ruby-upstream}" +UPSTREAM_URL="git@github.com:Shopify/shopify-api-ruby.git" + +if [[ -z "${FORK_URL}" ]]; then + cat >&2 <<'USAGE' +Usage: + scripts/prepare-shopify-upstream.sh [target-dir] + +Example: + scripts/prepare-shopify-upstream.sh git@github.com:your-user/shopify-api-ruby.git +USAGE + exit 64 +fi + +if [[ -e "${TARGET_DIR}" && ! -d "${TARGET_DIR}/.git" ]]; then + echo "Target exists but is not a git repository: ${TARGET_DIR}" >&2 + exit 65 +fi + +if [[ ! -d "${TARGET_DIR}/.git" ]]; then + git clone "${FORK_URL}" "${TARGET_DIR}" +fi + +cd "${TARGET_DIR}" + +if ! git remote get-url upstream >/dev/null 2>&1; then + git remote add upstream "${UPSTREAM_URL}" +fi + +if ! git remote get-url fork >/dev/null 2>&1; then + git remote add fork "${FORK_URL}" +fi + +git fetch upstream main || git fetch origin main +git checkout main +git pull --ff-only upstream main || git pull --ff-only origin main + +cat </dev/null || true) + Fork: $(git remote get-url fork 2>/dev/null || true) + +Next steps: + cd ${TARGET_DIR} + git checkout -b forma/ + bundle install + bundle exec rake test +SUMMARY diff --git a/scripts/smoke-app.js b/scripts/smoke-app.js new file mode 100755 index 0000000..29d7e6d --- /dev/null +++ b/scripts/smoke-app.js @@ -0,0 +1,27 @@ +const fs = require('fs'); +const path = require('path'); + +const appDir = process.argv[2]; +if (!appDir) { + console.error('Usage: node scripts/smoke-app.js '); + process.exit(64); +} + +const indexPath = path.join(process.cwd(), appDir, 'index.html'); +const pkgPath = path.join(process.cwd(), appDir, 'package.json'); + +for (const file of [indexPath, pkgPath]) { + if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8').trim().length === 0) { + console.error(`Missing or empty app file: ${file}`); + process.exit(1); + } +} + +const html = fs.readFileSync(indexPath, 'utf8'); +const missing = ['', ' !html.includes(marker)); +if (missing.length) { + console.error(`App ${appDir} is missing markers: ${missing.join(', ')}`); + process.exit(1); +} + +console.log(`Verified deployable app: ${appDir}`); diff --git a/scripts/smoke-static.js b/scripts/smoke-static.js new file mode 100644 index 0000000..0a886b7 --- /dev/null +++ b/scripts/smoke-static.js @@ -0,0 +1,55 @@ +const fs = require('fs'); +const path = require('path'); + +const root = process.cwd(); +const htmlPath = path.join(root, 'index.html'); +const html = fs.readFileSync(htmlPath, 'utf8'); + +const requiredMarkers = [ + 'assets/config.js', + 'assets/integrations.js', + 'assets/store.js', + 'assets/store.css', + 'data-product-grid', + 'data-cart-drawer', + 'data-wishlist-drawer', + 'data-product-modal', + 'data-integration-status="shopify"', + 'data-integration-status="supabase"', + 'data-integration-status="chatbase"', + 'Bolso Arc Cognac', + 'Vela Santal 300g', + 'Tote Weekend', + 'Pañuelo Seda Grid', + 'Trench Lino Arena', + 'data-runtime-status', +]; + +const missingMarkers = requiredMarkers.filter((marker) => !html.includes(marker)); +if (missingMarkers.length) { + console.error(`Missing required storefront markers:\n${missingMarkers.map((marker) => `- ${marker}`).join('\n')}`); + process.exit(1); +} + +const assetRefs = [...html.matchAll(/(?:src|href)="(assets\/[^"]+)"/g)].map((match) => match[1]); +const missingAssets = assetRefs.filter((asset) => !fs.existsSync(path.join(root, asset))); +if (missingAssets.length) { + console.error(`Missing referenced assets:\n${missingAssets.map((asset) => `- ${asset}`).join('\n')}`); + process.exit(1); +} + +const config = fs.readFileSync(path.join(root, 'assets/config.js'), 'utf8'); +const demoMode = [ + "domain: ''", + "storefrontToken: ''", + 'enableRemoteProducts: false', + "url: ''", + "anonKey: ''", + "botId: ''", + 'enabled: false', +].every((marker) => config.includes(marker)); + +console.log(`Verified ${assetRefs.length} local asset references and ${requiredMarkers.length} storefront markers.`); +console.log(demoMode + ? 'Runtime integrations are intentionally in demo mode. Configure assets/config.js or deploy-time env generation for Shopify/Supabase/Chatbase.' + : 'Runtime integration config appears customized.'); diff --git a/scripts/turbo-doctor.js b/scripts/turbo-doctor.js new file mode 100755 index 0000000..ebe79cd --- /dev/null +++ b/scripts/turbo-doctor.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +const { spawnSync } = require('child_process'); + +const result = spawnSync('turbo', ['--version'], { encoding: 'utf8' }); + +if (result.error) { + console.log('Turbo is not installed in this environment.'); + console.log('Install globally with one of:'); + console.log(' pnpm add turbo --global'); + console.log(' yarn global add turbo'); + console.log(' npm install turbo --global'); + console.log(' bun install turbo --global'); + console.log('Or scaffold a new starter with:'); + console.log(' pnpm dlx create-turbo@latest'); + console.log(' yarn dlx create-turbo@latest'); + console.log(' npx create-turbo@latest'); + console.log(' bunx create-turbo@latest'); + process.exit(0); +} + +if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(result.status || 1); +} + +console.log(`Turbo installed: ${result.stdout.trim()}`); +console.log('Try: turbo run check'); diff --git a/scripts/verify-files.js b/scripts/verify-files.js new file mode 100644 index 0000000..85d8873 --- /dev/null +++ b/scripts/verify-files.js @@ -0,0 +1,106 @@ +const fs = require('fs'); +const path = require('path'); + +const requiredFiles = [ + 'README.md', + 'package.json', + 'scripts/turbo-doctor.js', + 'docs/TURBOREPO.md', + 'turbo.json', + 'index.html', + 'assets/config.js', + 'assets/integrations.js', + 'assets/store.css', + 'assets/store.js', + 'docs/INTEGRATIONS.md', + 'supabase/migrations/202605130001_storefront_core.sql', + 'vercel.json', + 'CLAUDE.md', + '.github/workflows/check.yml', + '.github/pull_request_template.md', + '.github/ISSUE_TEMPLATE/ai-agent-task.md', + 'shopify-ruby/Gemfile', + 'shopify-ruby/.env.example', + 'shopify-ruby/config/shopify_context.rb', + 'shopify-ruby/lib/forma_shopify/admin_client.rb', + 'shopify-ruby/lib/forma_shopify/storefront_client.rb', + 'shopify-ruby/scripts/list_products.rb', + 'shopify-ruby/README.md', + 'docs/UPSTREAM_SHOPIFY_RUBY.md', + 'scripts/prepare-shopify-upstream.sh', + 'scripts/github-plain-diff.js', + 'docs/GITHUB_PLAIN_DIFFS.md', + 'scripts/smoke-static.js', + 'scripts/smoke-app.js', + 'packages/storefront-data/package.json', + 'packages/storefront-data/index.js', + 'packages/config/package.json', + 'packages/config/index.js', + 'packages/ui/package.json', + 'packages/ui/index.js', + 'apps/docs/package.json', + 'apps/docs/index.html', + 'apps/storefront/package.json', + 'apps/storefront/index.html', + 'docs/GO_LIVE_CHECKLIST.md', + + 'shopify-theme/README.md', + 'shopify-theme/Gemfile', + 'shopify-theme/spec/spec_helper.rb', + 'shopify-theme/spec/helpers/html_helper.rb', + 'shopify-theme/spec/helpers/i18n_helper.rb', + 'shopify-theme/spec/i18n_validity_spec.rb', + 'shopify-theme/spec/html_validity_spec.rb', + 'shopify-theme/locales/en.default.json', + 'shopify-theme/locales/es.default.json', + 'shopify-theme/assets/forma-theme.css.liquid', + 'shopify-theme/assets/forma-theme.js', + 'shopify-theme/assets/timber.js.liquid', + 'shopify-theme/assets/ajax-cart.js.liquid', + 'shopify-theme/assets/gift-card.scss.liquid', + 'shopify-theme/snippets/breadcrumb.liquid', + 'shopify-theme/snippets/collection-sorting.liquid', + 'shopify-theme/snippets/onboarding-empty-collection.liquid', + 'shopify-theme/snippets/onboarding-featured-collections.liquid', + 'shopify-theme/snippets/comment.liquid', + 'shopify-theme/snippets/oldIE-js.liquid', + 'shopify-theme/snippets/ajax-cart-template.liquid', + 'shopify-theme/templates/gift_card.liquid', + 'shopify-theme/layout/theme.liquid', + 'shopify-theme/snippets/site-header.liquid', + 'shopify-theme/snippets/site-footer.liquid', + 'shopify-theme/snippets/product-card.liquid', + 'shopify-theme/spec/theme_structure_spec.rb', + 'shopify-theme/templates/404.liquid', + 'shopify-theme/templates/article.liquid', + 'shopify-theme/templates/blog.liquid', + 'shopify-theme/templates/cart.liquid', + 'shopify-theme/templates/collection.liquid', + 'shopify-theme/templates/collection.list.liquid', + 'shopify-theme/templates/index.liquid', + 'shopify-theme/templates/list-collections.liquid', + 'shopify-theme/templates/page.contact.liquid', + 'shopify-theme/templates/page.liquid', + 'shopify-theme/templates/product.liquid', + 'shopify-theme/templates/search.liquid', + 'shopify-theme/templates/customers/account.liquid', + 'shopify-theme/templates/customers/addresses.liquid', + 'shopify-theme/templates/customers/login.liquid', + 'shopify-theme/templates/customers/order.liquid', + 'shopify-theme/templates/customers/register.liquid', + 'shopify-theme/config.yml', + 'shopify-theme/config/settings_schema.json', + 'shopify-theme/config/settings_data.json', +]; + +const emptyFiles = requiredFiles.filter((file) => { + const fullPath = path.join(process.cwd(), file); + return !fs.existsSync(fullPath) || fs.readFileSync(fullPath, 'utf8').trim().length === 0; +}); + +if (emptyFiles.length) { + console.error(`Empty or missing required files:\n${emptyFiles.map((file) => `- ${file}`).join('\n')}`); + process.exit(1); +} + +console.log(`Verified ${requiredFiles.length} required files contain content.`); diff --git a/shopify-ruby/.env.example b/shopify-ruby/.env.example new file mode 100644 index 0000000..a53f87f --- /dev/null +++ b/shopify-ruby/.env.example @@ -0,0 +1,11 @@ +# Shopify Admin API custom app credentials. +SHOPIFY_API_KEY= +SHOPIFY_API_SECRET= +SHOPIFY_ADMIN_ACCESS_TOKEN= +SHOPIFY_SHOP_DOMAIN=your-store.myshopify.com +SHOPIFY_API_VERSION=2026-04 +SHOPIFY_SCOPES=read_products,write_products,read_orders + +# Optional Storefront API token for public catalog queries. +SHOPIFY_STOREFRONT_PUBLIC_TOKEN= +SHOPIFY_STOREFRONT_PRIVATE_TOKEN= diff --git a/shopify-ruby/Gemfile b/shopify-ruby/Gemfile new file mode 100644 index 0000000..8e2ca87 --- /dev/null +++ b/shopify-ruby/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "shopify_api" +gem "dotenv" diff --git a/shopify-ruby/README.md b/shopify-ruby/README.md new file mode 100644 index 0000000..335011f --- /dev/null +++ b/shopify-ruby/README.md @@ -0,0 +1,40 @@ +# FORMA Shopify Ruby scaffold + +This folder is a small adapter for the official Shopify Ruby API gem from `git@github.com:Shopify/shopify-api-ruby.git` / `https://github.com/Shopify/shopify-api-ruby`. + +It is not a vendored copy of Shopify's repository. It is the app-side code FORMA needs to organize Shopify Admin and Storefront API access while the storefront remains deployable on Vercel. + +## What it includes + +- `Gemfile` with `shopify_api` and `dotenv`. +- `config/shopify_context.rb` to initialize `ShopifyAPI::Context` with environment variables. +- `lib/forma_shopify/admin_client.rb` for GraphQL Admin API product reads. +- `lib/forma_shopify/storefront_client.rb` for Storefront API product reads. +- `scripts/list_products.rb` as a smoke test for Admin API credentials. + +## Setup + +```bash +cd shopify-ruby +cp .env.example .env +bundle install +SHOPIFY_PRODUCT_LIMIT=5 bundle exec ruby scripts/list_products.rb +``` + +## Required environment variables + +| Variable | Purpose | +| --- | --- | +| `SHOPIFY_API_KEY` | Custom app API key. | +| `SHOPIFY_API_SECRET` | Custom app API secret. | +| `SHOPIFY_ADMIN_ACCESS_TOKEN` | Admin API access token for GraphQL Admin calls. | +| `SHOPIFY_SHOP_DOMAIN` | Shop domain, for example `your-store.myshopify.com`. | +| `SHOPIFY_API_VERSION` | Admin API version, default `2026-04`. | +| `SHOPIFY_SCOPES` | Comma-separated app scopes. | +| `SHOPIFY_STOREFRONT_PUBLIC_TOKEN` / `SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Optional Storefront API tokens. | + +## Notes + +- New Shopify apps should prefer GraphQL Admin API over REST. +- Do not commit `.env` or production tokens. +- Use this Ruby adapter for secure server-side Admin API work, and keep `assets/integrations.js` limited to safe browser-side Storefront API/demo behavior. diff --git a/shopify-ruby/config/shopify_context.rb b/shopify-ruby/config/shopify_context.rb new file mode 100644 index 0000000..3814d75 --- /dev/null +++ b/shopify-ruby/config/shopify_context.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "shopify_api" + +module FormaShopify + module Context + module_function + + def setup! + ShopifyAPI::Context.setup( + api_key: env!("SHOPIFY_API_KEY"), + api_secret_key: env!("SHOPIFY_API_SECRET"), + host_name: env!("SHOPIFY_SHOP_DOMAIN"), + scope: ENV.fetch("SHOPIFY_SCOPES", "read_products"), + is_embedded: false, + is_private: false, + api_version: ENV.fetch("SHOPIFY_API_VERSION", "2026-04"), + rest_disabled: true + ) + end + + def env!(key) + value = ENV[key] + return value unless value.nil? || value.strip.empty? + + raise KeyError, "Missing required environment variable: #{key}" + end + end +end diff --git a/shopify-ruby/lib/forma_shopify/admin_client.rb b/shopify-ruby/lib/forma_shopify/admin_client.rb new file mode 100644 index 0000000..dad5f61 --- /dev/null +++ b/shopify-ruby/lib/forma_shopify/admin_client.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "shopify_api" + +module FormaShopify + class AdminClient + PRODUCTS_QUERY = <<~GRAPHQL + query FormaProducts($first: Int!) { + products(first: $first, sortKey: CREATED_AT, reverse: true) { + edges { + node { + id + title + handle + productType + status + createdAt + onlineStoreUrl + featuredImage { url altText } + variants(first: 10) { + edges { + node { + id + title + sku + price + compareAtPrice + selectedOptions { name value } + } + } + } + } + } + } + } + GRAPHQL + + def initialize(shop: ENV.fetch("SHOPIFY_SHOP_DOMAIN"), access_token: ENV.fetch("SHOPIFY_ADMIN_ACCESS_TOKEN")) + @session = ShopifyAPI::Auth::Session.new(shop: shop, access_token: access_token) + @client = ShopifyAPI::Clients::Graphql::Admin.new(session: @session) + end + + def products(first: 10) + response = @client.query(query: PRODUCTS_QUERY, variables: { first: first }) + response.body.fetch("data").fetch("products").fetch("edges").map { |edge| edge.fetch("node") } + end + end +end diff --git a/shopify-ruby/lib/forma_shopify/storefront_client.rb b/shopify-ruby/lib/forma_shopify/storefront_client.rb new file mode 100644 index 0000000..8bc3e71 --- /dev/null +++ b/shopify-ruby/lib/forma_shopify/storefront_client.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "shopify_api" + +module FormaShopify + class StorefrontClient + PRODUCTS_QUERY = <<~GRAPHQL + query FormaStorefrontProducts($first: Int!) { + products(first: $first) { + edges { + node { + id + title + handle + description + featuredImage { url altText } + priceRange { + minVariantPrice { amount currencyCode } + } + } + } + } + } + GRAPHQL + + def initialize( + shop_url: ENV.fetch("SHOPIFY_SHOP_DOMAIN"), + public_token: ENV["SHOPIFY_STOREFRONT_PUBLIC_TOKEN"], + private_token: ENV["SHOPIFY_STOREFRONT_PRIVATE_TOKEN"] + ) + token_options = private_token.to_s.empty? ? { public_token: public_token } : { private_token: private_token } + @client = ShopifyAPI::Clients::Graphql::Storefront.new(shop_url, **token_options) + end + + def products(first: 10) + response = @client.query(query: PRODUCTS_QUERY, variables: { first: first }) + response.body.fetch("data").fetch("products").fetch("edges").map { |edge| edge.fetch("node") } + end + end +end diff --git a/shopify-ruby/scripts/list_products.rb b/shopify-ruby/scripts/list_products.rb new file mode 100755 index 0000000..152e9c3 --- /dev/null +++ b/shopify-ruby/scripts/list_products.rb @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "dotenv/load" +require_relative "../config/shopify_context" +require_relative "../lib/forma_shopify/admin_client" + +FormaShopify::Context.setup! +client = FormaShopify::AdminClient.new + +client.products(first: Integer(ENV.fetch("SHOPIFY_PRODUCT_LIMIT", "5"))).each do |product| + puts "#{product.fetch("title")} — #{product.fetch("id")}" +end diff --git a/shopify-theme/Gemfile b/shopify-theme/Gemfile new file mode 100644 index 0000000..bb205e8 --- /dev/null +++ b/shopify-theme/Gemfile @@ -0,0 +1,9 @@ +source 'https://rubygems.org' + +gem 'nokogiri' +gem 'htmlentities' +gem 'rspec' + +group :development do + gem 'pry' +end diff --git a/shopify-theme/README.md b/shopify-theme/README.md new file mode 100644 index 0000000..065e1da --- /dev/null +++ b/shopify-theme/README.md @@ -0,0 +1,42 @@ +# FORMA Shopify Liquid theme scaffold + +This folder mirrors the classic Shopify/Timber theme layout so the static FORMA storefront can be ported into a Shopify Liquid theme. + +Reference reviewed: + +- `Shopify/Timber`: +- Shopify theme architecture: + +## Structure + +```text +shopify-theme/ +├── assets/ # JavaScript, CSS, and theme images +├── layout/ # theme.liquid and optional alternate layouts +├── snippets/ # reusable Liquid snippets +├── spec/ # structure tests and helpers +├── templates/ # page, collection, product, cart, blog, search, customer templates +├── config/ # Shopify theme settings schema/data +└── config.yml # legacy Theme Kit style config placeholder +``` + +## Validate + +```bash +ruby shopify-theme/spec/theme_structure_spec.rb +``` + +Optional Timber-style specs require Bundler gems: + +```bash +cd shopify-theme +bundle install +bundle exec rspec +``` + +## Notes + +- This is a minimal migration scaffold, not a full Timber copy. +- Keep static Vercel assets in the repo root and Shopify Liquid assets under `shopify-theme/`. +- Shopify theme upload tools expect the theme root to contain supported directories such as `assets`, `layout`, `snippets`, `templates`, `config`, `locales`, and related theme folders. +- Timber-inspired additions include `timber.js.liquid`, `ajax-cart.js.liquid`, `breadcrumb`, `collection-sorting`, onboarding snippets, comment snippet, `oldIE-js`, and `gift_card.liquid`. diff --git a/shopify-theme/assets/ajax-cart.js.liquid b/shopify-theme/assets/ajax-cart.js.liquid new file mode 100644 index 0000000..698c4c2 --- /dev/null +++ b/shopify-theme/assets/ajax-cart.js.liquid @@ -0,0 +1,33 @@ +/*============================================================================ + Lightweight Ajax cart helper inspired by Timber ajax-cart.js.liquid. + Uses Shopify Ajax API endpoints when fetch is available and falls back to forms. +==============================================================================*/ +(function formaAjaxCart(window, document) { + 'use strict'; + + window.FORMA = window.FORMA || {}; + + window.FORMA.getCart = function getCart() { + return fetch('/cart.js', { headers: { Accept: 'application/json' } }).then(function parse(response) { + return response.json(); + }); + }; + + window.FORMA.changeCart = function changeCart(line, quantity) { + return fetch('/cart/change.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ line: line, quantity: quantity }), + }).then(function parse(response) { return response.json(); }); + }; + + document.addEventListener('click', function onCartChange(event) { + var button = event.target.closest('[data-cart-change]'); + if (!button || !window.fetch) return; + event.preventDefault(); + window.FORMA.changeCart(Number(button.dataset.line), Number(button.dataset.quantity)).then(function notify(cart) { + document.dispatchEvent(new CustomEvent('forma:cart:change', { detail: cart })); + window.location.reload(); + }); + }); +})(window, document); diff --git a/shopify-theme/assets/forma-theme.css.liquid b/shopify-theme/assets/forma-theme.css.liquid new file mode 100644 index 0000000..e4b704d --- /dev/null +++ b/shopify-theme/assets/forma-theme.css.liquid @@ -0,0 +1,152 @@ +:root { + --forma-ink: {{ settings.color_ink | default: '#16130f' }}; + --forma-paper: {{ settings.color_paper | default: '#fffaf2' }}; + --forma-accent: {{ settings.color_accent | default: '#b96f4f' }}; +} + +body { + margin: 0; + color: var(--forma-ink); + background: var(--forma-paper); + font-family: {{ settings.body_font.family | default: 'Inter' }}, system-ui, sans-serif; +} + +.forma-shell { + width: min(1180px, calc(100% - 2rem)); + margin: 0 auto; +} + +.forma-header, +.forma-footer { + border-bottom: 1px solid rgba(22, 19, 15, 0.1); + padding: 1rem 0; +} + +.forma-header__inner, +.forma-footer__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.forma-logo { + color: var(--forma-ink); + font-weight: 900; + letter-spacing: 0.22em; + text-decoration: none; +} + +.forma-nav { + display: flex; + gap: 1rem; +} + +.forma-nav a, +.forma-link { + color: inherit; + font-weight: 800; + text-decoration: none; +} + +.forma-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr); + gap: clamp(2rem, 6vw, 5rem); + align-items: center; + padding: clamp(3rem, 7vw, 7rem) 0; +} + +.forma-hero h1, +.forma-title { + margin: 0 0 1rem; + font-size: clamp(2.7rem, 7vw, 6rem); + line-height: 0.95; + letter-spacing: -0.055em; +} + +.forma-hero p, +.forma-muted { + color: rgba(22, 19, 15, 0.68); + line-height: 1.7; +} + +.forma-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3rem; + padding: 0 1.1rem; + color: #fff; + background: var(--forma-ink); + border: 0; + border-radius: 999px; + font-weight: 900; + text-decoration: none; +} + +.forma-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.forma-card { + overflow: hidden; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(22, 19, 15, 0.08); + border-radius: 1.2rem; +} + +.forma-card__media { + aspect-ratio: 1 / 1.1; + background: #efe4d4; +} + +.forma-card__media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.forma-card__body { + padding: 1rem; +} + +.forma-price { + font-weight: 900; +} + +.forma-form { + display: grid; + gap: 0.8rem; + max-width: 36rem; +} + +.forma-form input, +.forma-form textarea { + width: 100%; + padding: 0.9rem 1rem; + border: 1px solid rgba(22, 19, 15, 0.18); + border-radius: 0.8rem; +} + +@media (max-width: 860px) { + .forma-header__inner, + .forma-footer__inner, + .forma-hero { + align-items: flex-start; + flex-direction: column; + grid-template-columns: 1fr; + } + + .forma-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .forma-grid { + grid-template-columns: 1fr; + } +} diff --git a/shopify-theme/assets/forma-theme.js b/shopify-theme/assets/forma-theme.js new file mode 100644 index 0000000..ab0e201 --- /dev/null +++ b/shopify-theme/assets/forma-theme.js @@ -0,0 +1,22 @@ +(function formaTheme() { + document.documentElement.classList.add('forma-js'); + + document.addEventListener('submit', function handleAjaxCart(event) { + var form = event.target.closest('form[action*="/cart/add"]'); + if (!form || !window.fetch) return; + + event.preventDefault(); + + fetch('/cart/add.js', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: new FormData(form), + }) + .then(function parse(response) { return response.json(); }) + .then(function notify(item) { + document.dispatchEvent(new CustomEvent('forma:cart:add', { detail: item })); + form.querySelector('[data-add-label]').textContent = 'Agregado'; + }) + .catch(function fallback() { form.submit(); }); + }); +})(); diff --git a/shopify-theme/assets/gift-card.scss.liquid b/shopify-theme/assets/gift-card.scss.liquid new file mode 100644 index 0000000..bb76f8f --- /dev/null +++ b/shopify-theme/assets/gift-card.scss.liquid @@ -0,0 +1,29 @@ +/*============================================================================ + Gift card styles inspired by Timber's dedicated gift card stylesheet. +==============================================================================*/ +.gift-card-template { + min-height: 100vh; + color: #16130f; + background: #fffaf2; + font-family: system-ui, sans-serif; +} + +.gift-card { + width: min(38rem, calc(100% - 2rem)); + margin: 4rem auto; + padding: 2rem; + background: #ffffff; + border: 1px solid rgba(22, 19, 15, 0.1); + border-radius: 1.5rem; + text-align: center; +} + +.gift-card__code { + display: inline-block; + margin: 1rem 0; + padding: 0.9rem 1rem; + background: #efe4d4; + border-radius: 0.75rem; + font-weight: 900; + letter-spacing: 0.12em; +} diff --git a/shopify-theme/assets/timber.js.liquid b/shopify-theme/assets/timber.js.liquid new file mode 100644 index 0000000..57f77e2 --- /dev/null +++ b/shopify-theme/assets/timber.js.liquid @@ -0,0 +1,33 @@ +/*============================================================================ + Timber-inspired utility helpers for FORMA. + Includes transition prep and URL parameter replacement patterns commonly used + by vintage Shopify themes. +==============================================================================*/ +(function formaTimber(window, document) { + 'use strict'; + + window.FORMA = window.FORMA || {}; + + window.FORMA.prepareTransition = function prepareTransition(element) { + if (!element) return; + element.classList.add('is-transitioning'); + element.addEventListener('transitionend', function onTransitionEnd() { + element.classList.remove('is-transitioning'); + element.removeEventListener('transitionend', onTransitionEnd); + }); + element.offsetWidth; + }; + + window.FORMA.replaceUrlParam = function replaceUrlParam(url, paramName, paramValue) { + var pattern = new RegExp('(' + paramName + '=).*?(&|$)'); + if (url.search(pattern) >= 0) { + return url.replace(pattern, '$1' + paramValue + '$2'); + } + return url + (url.indexOf('?') > 0 ? '&' : '?') + paramName + '=' + paramValue; + }; + + document.addEventListener('change', function onSortChange(event) { + if (!event.target.matches('[data-collection-sort]')) return; + window.location.href = window.FORMA.replaceUrlParam(window.location.href, 'sort_by', event.target.value); + }); +})(window, document); diff --git a/shopify-theme/config.yml b/shopify-theme/config.yml new file mode 100644 index 0000000..1d47a32 --- /dev/null +++ b/shopify-theme/config.yml @@ -0,0 +1,5 @@ +development: + password: ${SHOPIFY_THEME_PASSWORD} + theme_id: ${SHOPIFY_THEME_ID} + store: ${SHOPIFY_SHOP_DOMAIN} + directory: shopify-theme diff --git a/shopify-theme/config/settings_data.json b/shopify-theme/config/settings_data.json new file mode 100644 index 0000000..e0faa7d --- /dev/null +++ b/shopify-theme/config/settings_data.json @@ -0,0 +1,7 @@ +{ + "current": { + "color_ink": "#16130f", + "color_paper": "#fffaf2", + "color_accent": "#b96f4f" + } +} diff --git a/shopify-theme/config/settings_schema.json b/shopify-theme/config/settings_schema.json new file mode 100644 index 0000000..272a26d --- /dev/null +++ b/shopify-theme/config/settings_schema.json @@ -0,0 +1,30 @@ +[ + { + "name": "Theme settings", + "settings": [ + { + "type": "color", + "id": "color_ink", + "label": "Ink color", + "default": "#16130f" + }, + { + "type": "color", + "id": "color_paper", + "label": "Paper color", + "default": "#fffaf2" + }, + { + "type": "color", + "id": "color_accent", + "label": "Accent color", + "default": "#b96f4f" + }, + { + "type": "collection", + "id": "featured_collection", + "label": "Featured collection" + } + ] + } +] diff --git a/shopify-theme/layout/theme.liquid b/shopify-theme/layout/theme.liquid new file mode 100644 index 0000000..57d886d --- /dev/null +++ b/shopify-theme/layout/theme.liquid @@ -0,0 +1,27 @@ + + + + + + + + + {{ page_title }}{% if current_tags %} – {{ current_tags | join: ', ' }}{% endif %}{% unless page_title contains shop.name %} – {{ shop.name }}{% endunless %} + {{ content_for_header }} + {{ 'forma-theme.css.liquid' | asset_url | stylesheet_tag }} + + + + {% render 'oldIE-js' %} + + + {% render 'site-header' %} + {% render 'breadcrumb' %} + +
+ {{ content_for_layout }} +
+ + {% render 'site-footer' %} + + diff --git a/shopify-theme/locales/en.default.json b/shopify-theme/locales/en.default.json new file mode 100644 index 0000000..15e3a7d --- /dev/null +++ b/shopify-theme/locales/en.default.json @@ -0,0 +1,96 @@ +{ + "general": { + "cart": "Cart", + "shop": "Shop", + "returns": "Returns", + "privacy": "Privacy", + "breadcrumbs": { + "aria_label": "Breadcrumbs", + "home": "Home", + "home_link_title": "Back to the frontpage" + }, + "404": { + "title": "404", + "subtext_html": "The page you requested does not exist." + } + }, + "home": { + "eyebrow": "New campaign", + "heading": "Essentials with form, intention and character.", + "text": "A Shopify experience inspired by Timber, ready for products, collections and cart.", + "cta": "Shop now", + "theme_card_title": "FORMA Shopify Theme", + "theme_card_text": "Layout, snippets, assets and templates organized." + }, + "homepage": { + "onboarding": { + "modal_title": "Almost there", + "no_collections_html": "Create a featured collection to populate this section." + } + }, + "collections": { + "title": "Collections", + "sorting": { + "title": "Sort by", + "featured": "Featured", + "best_selling": "Best selling", + "az": "Alphabetically, A-Z", + "za": "Alphabetically, Z-A", + "price_ascending": "Price, low to high", + "price_descending": "Price, high to low", + "date_descending": "Date, new to old", + "date_ascending": "Date, old to new" + }, + "onboarding": { + "modal_title": "Add products", + "no_products_html": "This collection is ready for products from Shopify Admin." + } + }, + "product": { + "add_to_cart": "Add to cart", + "sold_out": "Sold out" + }, + "cart": { + "title": "Cart", + "checkout": "Checkout", + "empty": "Your cart is empty.", + "total": "Total" + }, + "search": { + "title": "Search", + "placeholder": "Search products", + "submit": "Search", + "empty": "No results." + }, + "customer": { + "account": "Account", + "create_account": "Create account", + "addresses": "Addresses", + "no_addresses": "No saved addresses.", + "logout": "Log out", + "password": "Password", + "login": "Log in", + "last_name": "Last name", + "order": "Order" + }, + "contact": { + "title": "Contact", + "success": "Thank you. We will reply soon.", + "name": "Name", + "email": "Email", + "message": "Message", + "submit": "Send" + }, + "blogs": { + "comments": { + "title": "Comments" + } + }, + "gift_cards": { + "issued": { + "title": "Gift card", + "subtext": "Here is your gift card.", + "redeem": "Use this code at checkout." + } + } +} diff --git a/shopify-theme/locales/es.default.json b/shopify-theme/locales/es.default.json new file mode 100644 index 0000000..75d2103 --- /dev/null +++ b/shopify-theme/locales/es.default.json @@ -0,0 +1,96 @@ +{ + "general": { + "cart": "Carrito", + "shop": "Tienda", + "returns": "Devoluciones", + "privacy": "Privacidad", + "breadcrumbs": { + "aria_label": "Breadcrumbs", + "home": "Inicio", + "home_link_title": "Volver al inicio" + }, + "404": { + "title": "404", + "subtext_html": "La página solicitada no existe." + } + }, + "home": { + "eyebrow": "Nueva campaña", + "heading": "Esenciales con forma, intención y carácter.", + "text": "Una experiencia Shopify inspirada en Timber, lista para productos, colecciones y carrito.", + "cta": "Comprar ahora", + "theme_card_title": "FORMA Shopify Theme", + "theme_card_text": "Layout, snippets, assets y templates organizados." + }, + "homepage": { + "onboarding": { + "modal_title": "Casi listo", + "no_collections_html": "Crea una colección destacada para poblar esta sección." + } + }, + "collections": { + "title": "Colecciones", + "sorting": { + "title": "Ordenar por", + "featured": "Destacados", + "best_selling": "Más vendidos", + "az": "Alfabéticamente, A-Z", + "za": "Alfabéticamente, Z-A", + "price_ascending": "Precio, menor a mayor", + "price_descending": "Precio, mayor a menor", + "date_descending": "Fecha, nuevo a antiguo", + "date_ascending": "Fecha, antiguo a nuevo" + }, + "onboarding": { + "modal_title": "Agrega productos", + "no_products_html": "Esta colección está lista para productos desde Shopify Admin." + } + }, + "product": { + "add_to_cart": "Agregar al carrito", + "sold_out": "Agotado" + }, + "cart": { + "title": "Carrito", + "checkout": "Checkout", + "empty": "Tu carrito está vacío.", + "total": "Total" + }, + "search": { + "title": "Buscar", + "placeholder": "Buscar productos", + "submit": "Buscar", + "empty": "No hay resultados." + }, + "customer": { + "account": "Cuenta", + "create_account": "Crear cuenta", + "addresses": "Direcciones", + "no_addresses": "No hay direcciones guardadas.", + "logout": "Cerrar sesión", + "password": "Contraseña", + "login": "Entrar", + "last_name": "Apellido", + "order": "Pedido" + }, + "contact": { + "title": "Contacto", + "success": "Gracias. Te responderemos pronto.", + "name": "Nombre", + "email": "Email", + "message": "Mensaje", + "submit": "Enviar" + }, + "blogs": { + "comments": { + "title": "Comentarios" + } + }, + "gift_cards": { + "issued": { + "title": "Tarjeta de regalo", + "subtext": "Aquí está tu tarjeta de regalo.", + "redeem": "Usa este código en checkout." + } + } +} diff --git a/shopify-theme/snippets/ajax-cart-template.liquid b/shopify-theme/snippets/ajax-cart-template.liquid new file mode 100644 index 0000000..247743d --- /dev/null +++ b/shopify-theme/snippets/ajax-cart-template.liquid @@ -0,0 +1,12 @@ + +
+

{{ 'cart.title' | t }}

+ {% for item in cart.items %} +
+ {{ item.product.title }} + {{ item.quantity }} × {{ item.final_price | money }} +
+ {% else %} +

{{ 'cart.empty' | t }}

+ {% endfor %} +
diff --git a/shopify-theme/snippets/breadcrumb.liquid b/shopify-theme/snippets/breadcrumb.liquid new file mode 100644 index 0000000..b5d6112 --- /dev/null +++ b/shopify-theme/snippets/breadcrumb.liquid @@ -0,0 +1,28 @@ + +{% unless template == 'index' or template == 'cart' %} + +{% endunless %} diff --git a/shopify-theme/snippets/collection-sorting.liquid b/shopify-theme/snippets/collection-sorting.liquid new file mode 100644 index 0000000..efa756c --- /dev/null +++ b/shopify-theme/snippets/collection-sorting.liquid @@ -0,0 +1,14 @@ + +
+ + +
diff --git a/shopify-theme/snippets/comment.liquid b/shopify-theme/snippets/comment.liquid new file mode 100644 index 0000000..c609d8f --- /dev/null +++ b/shopify-theme/snippets/comment.liquid @@ -0,0 +1,6 @@ + +
+

{{ comment.author }}

+

{{ comment.created_at | date: format: 'date' }}

+
{{ comment.content }}
+
diff --git a/shopify-theme/snippets/oldIE-js.liquid b/shopify-theme/snippets/oldIE-js.liquid new file mode 100644 index 0000000..3e9083c --- /dev/null +++ b/shopify-theme/snippets/oldIE-js.liquid @@ -0,0 +1,7 @@ + +{% comment %} + oldIE fixes/shivs inspired by Timber. Kept conditional so modern browsers skip it. +{% endcomment %} + diff --git a/shopify-theme/snippets/onboarding-empty-collection.liquid b/shopify-theme/snippets/onboarding-empty-collection.liquid new file mode 100644 index 0000000..8f7d22c --- /dev/null +++ b/shopify-theme/snippets/onboarding-empty-collection.liquid @@ -0,0 +1,10 @@ + +
+
+
+ +

{{ 'collections.onboarding.modal_title' | t }}

+

{{ 'collections.onboarding.no_products_html' | t }}

+
+
+
diff --git a/shopify-theme/snippets/onboarding-featured-collections.liquid b/shopify-theme/snippets/onboarding-featured-collections.liquid new file mode 100644 index 0000000..5176805 --- /dev/null +++ b/shopify-theme/snippets/onboarding-featured-collections.liquid @@ -0,0 +1,10 @@ + +
+
+
+ +

{{ 'homepage.onboarding.modal_title' | t }}

+

{{ 'homepage.onboarding.no_collections_html' | t }}

+
+
+
diff --git a/shopify-theme/snippets/product-card.liquid b/shopify-theme/snippets/product-card.liquid new file mode 100644 index 0000000..737e0c5 --- /dev/null +++ b/shopify-theme/snippets/product-card.liquid @@ -0,0 +1,16 @@ + diff --git a/shopify-theme/snippets/site-footer.liquid b/shopify-theme/snippets/site-footer.liquid new file mode 100644 index 0000000..e93876d --- /dev/null +++ b/shopify-theme/snippets/site-footer.liquid @@ -0,0 +1,13 @@ + diff --git a/shopify-theme/snippets/site-header.liquid b/shopify-theme/snippets/site-header.liquid new file mode 100644 index 0000000..b5fe6ba --- /dev/null +++ b/shopify-theme/snippets/site-header.liquid @@ -0,0 +1,14 @@ + diff --git a/shopify-theme/spec/helpers/html_helper.rb b/shopify-theme/spec/helpers/html_helper.rb new file mode 100644 index 0000000..fbd1753 --- /dev/null +++ b/shopify-theme/spec/helpers/html_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module HTMLValidator + HTML5_TAGS = [ + 'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', 'b', 'base', 'bdi', 'bdo', + 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup', + 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'embed', 'fieldset', + 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', + 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', + 'link', 'main', 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noscript', 'object', 'ol', + 'optgroup', 'option', 'output', 'p', 'param', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', + 'ruby', 's', 'samp', 'script', 'section', 'select', 'small', 'source', 'span', 'strong', 'style', + 'sub', 'summary', 'sup', 'svg', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', + 'thead', 'time', 'title', 'tr', 'track', 'u', 'ul', 'var', 'video', 'wbr' + ].freeze + + LIQUID_TAG_PATTERN = /\{[%{].*?[%}]\}/m + + module_function + + def html_fragment_for(path) + source = File.read(path) + without_liquid = HTMLEntities.new.decode(source.gsub(LIQUID_TAG_PATTERN, '')) + Nokogiri::HTML5.fragment(without_liquid) + rescue NameError + Nokogiri::HTML.fragment(without_liquid) + end + + def invalid_html_tags(path) + html_fragment_for(path).css('*').map(&:name).uniq.reject { |tag| HTML5_TAGS.include?(tag) } + end +end diff --git a/shopify-theme/spec/helpers/i18n_helper.rb b/shopify-theme/spec/helpers/i18n_helper.rb new file mode 100644 index 0000000..10892dc --- /dev/null +++ b/shopify-theme/spec/helpers/i18n_helper.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +EXCLUDED_KEYWORDS = ['date_formats'].freeze + +module I18nHelper + module_function + + def match_tags(path) + File.open(path).read.scan(/\{\{\s*(?:'|")([a-z0-9._]+?)(?:'|")(?:\s*\|\s*t)/).flatten.map do |key| + next if include_excluded_keywords?(key) + + truncate_plural_key(key.split('.')) + end.compact.uniq + end + + def include_excluded_keywords?(key) + EXCLUDED_KEYWORDS.any? { |keyword| key.include?(keyword) } + end + + def truncate_plural_key(parts) + return parts.join('.') unless parts.last&.match?(/^(one|other|many|few|zero|two)$/) + + parts[0...-1].join('.') + end + + def flatten_locale_keys(hash, prefix = nil) + hash.each_with_object([]) do |(key, value), keys| + next if include_excluded_keywords?(key) + + full_key = [prefix, key].compact.join('.') + if value.is_a?(Hash) + keys.concat(flatten_locale_keys(value, full_key)) + else + keys << truncate_plural_key(full_key.split('.')) + end + end.uniq + end +end diff --git a/shopify-theme/spec/html_validity_spec.rb b/shopify-theme/spec/html_validity_spec.rb new file mode 100644 index 0000000..8d33d33 --- /dev/null +++ b/shopify-theme/spec/html_validity_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Theme HTML' do + Dir[THEME_ROOT.join('{layout,snippets,templates}/**/*.liquid')].each do |path| + it "uses known HTML tags in #{Pathname.new(path).relative_path_from(THEME_ROOT)}" do + expect(HTMLValidator.invalid_html_tags(path)).to eq([]) + end + end +end diff --git a/shopify-theme/spec/i18n_validity_spec.rb b/shopify-theme/spec/i18n_validity_spec.rb new file mode 100644 index 0000000..d63bd2c --- /dev/null +++ b/shopify-theme/spec/i18n_validity_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Theme i18n' do + liquid_files = Dir[THEME_ROOT.join('**/*.liquid')] + locale_files = Dir[THEME_ROOT.join('locales/*.json')] + + file_keys = liquid_files.flat_map { |path| I18nHelper.match_tags(path) }.compact.sort.uniq + locale_keys = locale_files.each_with_object({}) do |path, result| + result[File.basename(path)] = I18nHelper.flatten_locale_keys(JSON.parse(File.read(path))).sort + end + + it 'has locale files' do + expect(locale_files).not_to be_empty + end + + it 'defines every translation key used by Liquid files' do + missing_by_locale = locale_keys.transform_values { |keys| file_keys - keys }.reject { |_locale, missing| missing.empty? } + expect(missing_by_locale).to eq({}) + end + + it 'does not contain completely unused locale keys' do + unused_by_locale = locale_keys.transform_values { |keys| keys - file_keys }.reject { |_locale, unused| unused.empty? } + expect(unused_by_locale).to eq({}) + end +end diff --git a/shopify-theme/spec/spec_helper.rb b/shopify-theme/spec/spec_helper.rb new file mode 100644 index 0000000..0f01063 --- /dev/null +++ b/shopify-theme/spec/spec_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'rubygems' +require 'bundler' +require 'nokogiri' +require 'htmlentities' +require 'pry' +require 'json' +require 'pathname' + +Dir[File.join(__dir__, 'helpers', '*.rb')].sort.each { |helper| require helper } + +THEME_ROOT = Pathname.new(__dir__).join('..').expand_path diff --git a/shopify-theme/spec/theme_structure_spec.rb b/shopify-theme/spec/theme_structure_spec.rb new file mode 100644 index 0000000..4191ff0 --- /dev/null +++ b/shopify-theme/spec/theme_structure_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'pathname' + +ROOT = Pathname.new(__dir__).join('..') +REQUIRED_FILES = %w[ + Gemfile + assets/forma-theme.css.liquid + assets/forma-theme.js + assets/timber.js.liquid + assets/ajax-cart.js.liquid + assets/gift-card.scss.liquid + layout/theme.liquid + snippets/site-header.liquid + snippets/site-footer.liquid + snippets/product-card.liquid + snippets/breadcrumb.liquid + snippets/collection-sorting.liquid + snippets/onboarding-empty-collection.liquid + snippets/onboarding-featured-collections.liquid + snippets/comment.liquid + snippets/oldIE-js.liquid + snippets/ajax-cart-template.liquid + templates/404.liquid + templates/article.liquid + templates/blog.liquid + templates/cart.liquid + templates/collection.liquid + templates/collection.list.liquid + templates/index.liquid + templates/list-collections.liquid + templates/page.contact.liquid + templates/page.liquid + templates/product.liquid + templates/search.liquid + templates/gift_card.liquid + templates/customers/account.liquid + templates/customers/addresses.liquid + templates/customers/login.liquid + templates/customers/order.liquid + templates/customers/register.liquid + config.yml + config/settings_schema.json + config/settings_data.json +].freeze + +missing = REQUIRED_FILES.reject { |file| ROOT.join(file).file? && ROOT.join(file).read.strip.length.positive? } +abort "Missing or empty theme files:\n#{missing.join("\n")}" if missing.any? + +puts "Verified #{REQUIRED_FILES.length} Shopify theme files." diff --git a/shopify-theme/templates/404.liquid b/shopify-theme/templates/404.liquid new file mode 100644 index 0000000..069d2c3 --- /dev/null +++ b/shopify-theme/templates/404.liquid @@ -0,0 +1,7 @@ +
+
+

{{ 'general.404.title' | t }}

+

{{ 'general.404.subtext_html' | t }}

+ {{ 'general.breadcrumbs.home' | t }} +
+
diff --git a/shopify-theme/templates/article.liquid b/shopify-theme/templates/article.liquid new file mode 100644 index 0000000..c9b32a2 --- /dev/null +++ b/shopify-theme/templates/article.liquid @@ -0,0 +1,14 @@ +
+

{{ article.title }}

+

{{ article.published_at | date: format: 'date' }}

+
{{ article.content }}
+
+ +{% if blog.comments_enabled? %} +
+

{{ 'blogs.comments.title' | t }}

+ {% for comment in article.comments %} + {% render 'comment', comment: comment %} + {% endfor %} +
+{% endif %} diff --git a/shopify-theme/templates/blog.liquid b/shopify-theme/templates/blog.liquid new file mode 100644 index 0000000..6f53924 --- /dev/null +++ b/shopify-theme/templates/blog.liquid @@ -0,0 +1,9 @@ +
+

{{ blog.title }}

+ {% for article in blog.articles %} + + {% endfor %} +
diff --git a/shopify-theme/templates/cart.liquid b/shopify-theme/templates/cart.liquid new file mode 100644 index 0000000..8b48717 --- /dev/null +++ b/shopify-theme/templates/cart.liquid @@ -0,0 +1,18 @@ +
+

{{ 'cart.title' | t }}

+ {% if cart.item_count > 0 %} +
+ {% for item in cart.items %} +
+ {{ item.product.title }} +

{{ item.variant.title }} × {{ item.quantity }}

+

{{ item.final_line_price | money }}

+
+ {% endfor %} +

{{ 'cart.total' | t }}: {{ cart.total_price | money }}

+ +
+ {% else %} +

{{ 'cart.empty' | t }}

+ {% endif %} +
diff --git a/shopify-theme/templates/collection.liquid b/shopify-theme/templates/collection.liquid new file mode 100644 index 0000000..7e78381 --- /dev/null +++ b/shopify-theme/templates/collection.liquid @@ -0,0 +1,15 @@ +
+

{{ collection.title }}

+ {% if collection.description != blank %} +
{{ collection.description }}
+ {% endif %} + {% render 'collection-sorting' %} + +
+ {% for product in collection.products %} + {% render 'product-card', product: product, collection: collection %} + {% else %} + {% render 'onboarding-empty-collection' %} + {% endfor %} +
+
diff --git a/shopify-theme/templates/collection.list.liquid b/shopify-theme/templates/collection.list.liquid new file mode 100644 index 0000000..6fdcd38 --- /dev/null +++ b/shopify-theme/templates/collection.list.liquid @@ -0,0 +1,9 @@ +
+

{{ collection.title }}

+ {% for product in collection.products %} + + {% endfor %} +
diff --git a/shopify-theme/templates/customers/account.liquid b/shopify-theme/templates/customers/account.liquid new file mode 100644 index 0000000..844d914 --- /dev/null +++ b/shopify-theme/templates/customers/account.liquid @@ -0,0 +1,5 @@ +
+

{{ 'customer.account' | t }}

+

{{ customer.email }}

+ {{ 'customer.logout' | t }} +
diff --git a/shopify-theme/templates/customers/addresses.liquid b/shopify-theme/templates/customers/addresses.liquid new file mode 100644 index 0000000..45d04f2 --- /dev/null +++ b/shopify-theme/templates/customers/addresses.liquid @@ -0,0 +1,8 @@ +
+

{{ 'customer.addresses' | t }}

+ {% for address in customer.addresses %} +
{{ address | format_address }}
+ {% else %} +

{{ 'customer.no_addresses' | t }}

+ {% endfor %} +
diff --git a/shopify-theme/templates/customers/login.liquid b/shopify-theme/templates/customers/login.liquid new file mode 100644 index 0000000..15c9cfe --- /dev/null +++ b/shopify-theme/templates/customers/login.liquid @@ -0,0 +1,9 @@ +
+

{{ 'customer.account' | t }}

+ {% form 'customer_login', class: 'forma-form' %} + {{ form.errors | default_errors }} + + + + {% endform %} +
diff --git a/shopify-theme/templates/customers/order.liquid b/shopify-theme/templates/customers/order.liquid new file mode 100644 index 0000000..b9cbcd4 --- /dev/null +++ b/shopify-theme/templates/customers/order.liquid @@ -0,0 +1,5 @@ +
+

{{ 'customer.order' | t }} {{ order.name }}

+

{{ order.created_at | date: format: 'date' }}

+

{{ order.total_price | money }}

+
diff --git a/shopify-theme/templates/customers/register.liquid b/shopify-theme/templates/customers/register.liquid new file mode 100644 index 0000000..a92bb14 --- /dev/null +++ b/shopify-theme/templates/customers/register.liquid @@ -0,0 +1,11 @@ +
+

{{ 'customer.create_account' | t }}

+ {% form 'create_customer', class: 'forma-form' %} + {{ form.errors | default_errors }} + + + + + + {% endform %} +
diff --git a/shopify-theme/templates/gift_card.liquid b/shopify-theme/templates/gift_card.liquid new file mode 100644 index 0000000..afa2173 --- /dev/null +++ b/shopify-theme/templates/gift_card.liquid @@ -0,0 +1,23 @@ + +{% layout none %} +{% assign formatted_initial_value = gift_card.initial_value | money_without_trailing_zeros: gift_card.currency %} + + + + + + + + {{ 'gift_cards.issued.title' | t }} – {{ shop.name }} + {{ 'gift-card.scss.liquid' | asset_url | stylesheet_tag }} + + +
+

{{ 'gift_cards.issued.title' | t }}

+

{{ 'gift_cards.issued.subtext' | t }}

+ {{ formatted_initial_value }} +
{{ gift_card.code | format_code }}
+

{{ 'gift_cards.issued.redeem' | t }}

+
+ + diff --git a/shopify-theme/templates/index.liquid b/shopify-theme/templates/index.liquid new file mode 100644 index 0000000..2765512 --- /dev/null +++ b/shopify-theme/templates/index.liquid @@ -0,0 +1,31 @@ +
+
+

{{ 'home.eyebrow' | t }}

+

{{ 'home.heading' | t }}

+

{{ 'home.text' | t }}

+ {{ 'home.cta' | t }} +
+
+
+
+ {{ 'home.theme_card_title' | t }} +

{{ 'home.theme_card_text' | t }}

+
+
+
+ +{% assign featured_collection = collections[settings.featured_collection] | default: collections.all %} +{% if featured_collection.products_count > 0 %} +
+

{{ featured_collection.title }}

+
+ {% for product in featured_collection.products limit: 4 %} + {% render 'product-card', product: product, collection: featured_collection %} + {% endfor %} +
+
+{% else %} +
+ {% render 'onboarding-featured-collections' %} +
+{% endif %} diff --git a/shopify-theme/templates/list-collections.liquid b/shopify-theme/templates/list-collections.liquid new file mode 100644 index 0000000..efbe91b --- /dev/null +++ b/shopify-theme/templates/list-collections.liquid @@ -0,0 +1,10 @@ +
+

{{ 'collections.title' | t }}

+
+ {% for collection in collections %} + + {% endfor %} +
+
diff --git a/shopify-theme/templates/page.contact.liquid b/shopify-theme/templates/page.contact.liquid new file mode 100644 index 0000000..d2a2869 --- /dev/null +++ b/shopify-theme/templates/page.contact.liquid @@ -0,0 +1,13 @@ +
+

{% if page.title != blank %}{{ page.title }}{% else %}{{ 'contact.title' | t }}{% endif %}

+ {% form 'contact', class: 'forma-form' %} + {% if form.posted_successfully? %} +

{{ 'contact.success' | t }}

+ {% endif %} + {{ form.errors | default_errors }} + + + + + {% endform %} +
diff --git a/shopify-theme/templates/page.liquid b/shopify-theme/templates/page.liquid new file mode 100644 index 0000000..87b3b1f --- /dev/null +++ b/shopify-theme/templates/page.liquid @@ -0,0 +1,4 @@ +
+

{{ page.title }}

+
{{ page.content }}
+
diff --git a/shopify-theme/templates/product.liquid b/shopify-theme/templates/product.liquid new file mode 100644 index 0000000..7c5b8d1 --- /dev/null +++ b/shopify-theme/templates/product.liquid @@ -0,0 +1,19 @@ +
+
+ {% if product.featured_image %} + {{ product.featured_image | image_url: width: 1000 | image_tag: loading: 'eager', alt: product.featured_image.alt | escape }} + {% endif %} +
+
+

{{ product.title }}

+

{{ product.selected_or_first_available_variant.price | money }}

+
{{ product.description }}
+ + {% form 'product', product %} + + + {% endform %} +
+
diff --git a/shopify-theme/templates/search.liquid b/shopify-theme/templates/search.liquid new file mode 100644 index 0000000..a70ea3d --- /dev/null +++ b/shopify-theme/templates/search.liquid @@ -0,0 +1,18 @@ +
+

{{ 'search.title' | t }}

+
+ + +
+ {% if search.performed %} +
+ {% for item in search.results %} + {% if item.object_type == 'product' %} + {% render 'product-card', product: item %} + {% endif %} + {% else %} +

{{ 'search.empty' | t }}

+ {% endfor %} +
+ {% endif %} +
diff --git a/supabase/migrations/202605130001_storefront_core.sql b/supabase/migrations/202605130001_storefront_core.sql new file mode 100644 index 0000000..568f2af --- /dev/null +++ b/supabase/migrations/202605130001_storefront_core.sql @@ -0,0 +1,32 @@ +create table if not exists public.store_events ( + id bigint generated by default as identity primary key, + event_name text not null, + payload jsonb not null default '{}'::jsonb, + page_path text, + user_agent text, + created_at timestamptz not null default now() +); + +create table if not exists public.newsletter_signups ( + id bigint generated by default as identity primary key, + email text not null unique, + source text not null default 'forma-storefront', + created_at timestamptz not null default now() +); + +alter table public.store_events enable row level security; +alter table public.newsletter_signups enable row level security; + +drop policy if exists "Allow anonymous storefront event inserts" on public.store_events; +create policy "Allow anonymous storefront event inserts" + on public.store_events + for insert + to anon + with check (true); + +drop policy if exists "Allow anonymous newsletter inserts" on public.newsletter_signups; +create policy "Allow anonymous newsletter inserts" + on public.newsletter_signups + for insert + to anon + with check (source = 'forma-storefront'); diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..80b91be --- /dev/null +++ b/turbo.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "tasks": { + "check": { + "dependsOn": [ + "^check", + "verify:files", + "smoke:static" + ], + "outputs": [] + }, + "verify:files": { + "outputs": [] + }, + "smoke:static": { + "outputs": [] + }, + "theme:spec": { + "outputs": [] + }, + "dev": { + "cache": false, + "persistent": true + }, + "start": { + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + "build/**", + ".next/**", + "!.next/cache/**" + ] + } + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..9d263c9 --- /dev/null +++ b/vercel.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "cleanUrls": true, + "trailingSlash": false, + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" } + ] + }, + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + } + ], + "rewrites": [ + { "source": "/", "destination": "/index.html" } + ] +}