diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index bc7959a..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Build - -on: - push: - branches: [main] - release: - types: [published] - -permissions: - id-token: write - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 24 - registry-url: 'https://registry.npmjs.org' - - - run: npm ci - - - run: npm run build - - - name: Publish to npm - if: github.event_name == 'release' - run: npm publish -w packages/lib --provenance --access public diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3067cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - run: npm ci + + # Runtimes first: the web app type-checks against their generated .d.ts, and + # the bundle contract test asserts against dist/. + - name: Build runtimes + run: npm run build:runtimes + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Format check + run: npm run format:check + + - name: Build site + run: npm run build:web + + - name: Test + run: npm test + + # These bundles load on other people's sites; growth should be a decision. + - name: Bundle size budget + run: npm run size + + # Every handler under api/sites/[siteId]/** must check ownership before any + # other query (ADR-0026) — catches a new widget's route copied from an existing + # one with the getOwnedSite() call forgotten. + - name: Ownership check + run: npm run authz-check + + # The CDN URL pasted into third-party sites points at this exact path. + - name: Check published bundle path + run: test -f packages/floating-contact-button/dist/floating-contact.min.js + + # The package README used to be copied from the repo root at publish time. + # Now that the root README describes the platform, the package must ship its own. + - name: Check package README ships with the tool docs + run: grep -q '@codions/floating-contact-button' packages/floating-contact-button/README.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..9d55f52 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,45 @@ +name: Deploy + +# Nuxt SSR runs in a Cloudflare Worker; static files use Workers Assets. +# +# Triggered by CI finishing on main rather than by the push itself, so a commit +# that fails lint/typecheck/tests never reaches production — see the CI workflow's +# `verify` job. `workflow_dispatch` stays for a manual re-deploy of the last green +# commit without needing a new push. +on: + workflow_dispatch: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +# `cancel-in-progress: false`: a running deploy must finish, not be pre-empted by a +# newer commit's deploy starting out of order and leaving main on an older build. +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + +jobs: + deploy: + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - run: npm ci + + - run: npm run build + + - name: Deploy to Cloudflare + run: npx wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..83c8bf2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release + +# Publishing is driven by GitHub Releases, not by pushes: cutting a release is a +# deliberate act. CI (ci.yml) covers every pull request and push to main. +on: + release: + types: [published] + +permissions: + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + registry-url: 'https://registry.npmjs.org' + + - run: npm ci + + - run: npm run build + + - name: Test + run: npm test + + # Only the Floating Contact Button is published today. The consent manager is + # marked private until its API settles; flipping that is a one-line change in + # its package.json plus a line here. + - name: Publish @codions/floating-contact-button + run: npm publish -w packages/floating-contact-button --provenance --access public diff --git a/.gitignore b/.gitignore index 05ee5ed..a5b131f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,18 @@ node_modules .DS_Store *.tgz +.wrangler +.nuxt +.output +.data +.playwright-cli +output/playwright -# Root build outputs (CDN / GitHub Pages deployment) -/dist -/docs +# Build outputs +apps/*/dist +packages/*/dist -# Package build outputs -packages/lib/dist -packages/lib/README.md -packages/docs/dist +# Third-party static clone kept locally for reference/evidence only — see +# roadmap/06-widgets/README.md. Not our source, never committed (includes its own +# nested .git and is not ours to distribute). +roadmap/_references diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8fe8676 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +dist +node_modules +package-lock.json +.wrangler +CHANGELOG.md +**/worker-configuration.d.ts +# Third-party static clone kept for reference/evidence only — see +# roadmap/06-widgets/README.md. Not our source, never formatted. +roadmap/_references diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..8fc6342 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all", + "arrowParens": "always" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..64bce59 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,241 @@ +# AGENTS.md + +Operating notes for an AI agent working in this repository. + +This file is the **what**. [CONTRIBUTING.md](./CONTRIBUTING.md) is the **why** — read it +before changing architecture, and [README.md](./README.md) before adding a tool. + +## What this repo is + +An npm workspaces monorepo holding the Codions Tools platform. + +- `apps/web` — Nuxt 4 + Nitro: tool registry, generic playground, docs and SSR. A + configuration interface. It never renders a widget preview itself. +- `packages/floating-contact-button`, `packages/consent-manager` — framework-free + runtimes built with tsup. This is what runs on a customer's website. +- `packages/core` (`@codions/tools-core`) — the shared contract: + `window[global].init(options)` returns `{ destroy() }`. Never published; bundled into + each runtime. + +The playground loads the **real built bundle** in a sandboxed iframe and drives it over +`postMessage`. It does not import the package and re-render it in Vue. + +## Where things are + +| What | Path | +| ------------------------------- | ----------------------------------------------- | +| Tool registry | `apps/web/app/tools/index.ts` | +| Tool contracts | `apps/web/app/tools/types.ts` | +| One tool | `apps/web/app/tools//` | +| Generic playground | `apps/web/app/playground/` | +| Iframe message protocol | `apps/web/app/playground/protocol.ts` | +| Iframe bootstrap | `apps/web/app/playground/buildPreviewSrcdoc.ts` | +| Shared UI primitives | `apps/web/app/ui/` | +| Shell strings + tool namespaces | `apps/web/app/i18n/messages.ts` | +| Markdown documentation | `apps/web/content//tools/*.md` | +| Content collection | `apps/web/content.config.ts` | +| MDC / Prose components | `apps/web/app/components/content/` | +| Design tokens | `apps/web/app/styles/theme.css` | +| Routes | `apps/web/app/pages/` | +| Runtime bundle list + versions | `apps/web/nuxt.config.ts` | +| Bundle size budgets | `scripts/size-check.mjs` | +| Test projects | `vitest.config.ts` | +| Deployment | `wrangler.json` (Cloudflare Workers Assets) | + +## Run before calling any work done + +In this order. The first command is a prerequisite for the rest, not a suggestion. + +```bash +npm run build:runtimes +npm run typecheck +npm run lint +npm run format:check +npm run build:web +npm test +npm run size +``` + +If you touched a runtime package, `npm run size` must still pass. If you touched the +snippet generator, expect the golden snapshots in `apps/web/test/__snapshots__/` to +change — read the diff and justify it; do not run `-u` reflexively. + +## Never do these + +- **Never change a bundle file name, a global name, or the shape of a generated + snippet** without treating it as a breaking change. Snippets are already pasted into + third-party sites nobody here can edit. +- **Never import one tool from another.** `apps/web/app/tools//` directories are + independent. Shared code goes to `app/ui/`, `app/playground/` or `packages/core`. +- **Never make the playground import a runtime package to render a preview.** It loads + `/__lib/.min.js` in the iframe. That is the point of the whole design. +- **Never use `innerHTML` with configuration data** in a runtime or a code generator. + Escape everything reaching HTML; check URLs against the protocol allowlist in + `packages/core/src/safety.ts`. +- **Never add a `window` global from a runtime** beyond the one documented global. +- **Never add a dependency to a runtime package.** They ship with zero. +- **Never add `allow-same-origin` to the preview iframe sandbox.** Combined with + `allow-scripts` it cancels the sandbox entirely. +- **Never `git commit` or `git push` unless asked.** +- **Never extract a shared UI primitive with fewer than three real call sites.** + +## Known traps in this repo + +Each of these cost time during the platform refactor. + +**Prettier breaks multi-statement inline Vue handlers.** Prettier reformats +`@click="a(); b()"` across lines in a way the Vue template compiler then rejects. Do not +fight the formatter — extract the body into a named function in ` + + +``` + +(`ck_dev0000000001` is the site key from the seed insert in §2.4 — swap in a real one +from your own `sites` table if you used a different key.) + +Serve it from a **different port**, with nothing beyond Node itself (no dependency to +install, works offline): + +```bash +node -e " +const http = require('node:http'); +const fs = require('node:fs'); +http.createServer((_, res) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(fs.readFileSync('dev/fixtures/third-party-site.html')); +}).listen(4000, () => console.log('third-party test site: http://localhost:4000')); +" +``` + +Open `http://localhost:4000` in a browser with `npm run dev:web` running. Confirm the +widget renders, and check the Network tab for `GET http://localhost:5173/p/ck_dev....js` +returning `200` with `Content-Type: application/javascript` — the same manual check +`01-fundacao.md` §1.10–1.11 already documents doing against a real tunnel; this is the +same verification, minus the internet dependency. No CORS configuration is needed for +this to work: a classic ` -``` +**Consent & Privacy Manager** — a consent notice with a preferences modal, persisted +preferences, gating of third-party tags by category, and Google Consent Mode v2 +signalling. -## Quick Start (WhatsApp Only) +> The Consent & Privacy Manager is infrastructure, not compliance. Installing it does +> not make a site compliant with the LGPD, the GDPR or any other regime — read +> [packages/consent-manager/LEGAL.md](./packages/consent-manager/LEGAL.md) for the full +> notice and the technical limitations that follow from how browsers work. -Add one script tag with `data-` attributes for a single WhatsApp button: +Option tables, snippet formats and runtime APIs live in each package's README. This +document does not repeat them. -```html - -``` - -The button appears automatically. No extra code needed. - -## Multi-Channel Setup - -For multiple contact channels with a floating channel bar: - -```html - - -``` +## Running locally -## npm / ES Modules +Node 22 or newer (CI runs 24, see `.nvmrc`). ```bash -npm install @codions/floating-contact-button +npm install +npm run build:runtimes # required once: the site type-checks and previews against dist/ +npm run dev # runtimes in watch mode + the site on http://localhost:5173 ``` -```ts -import { FloatingContact } from '@codions/floating-contact-button'; +Other scripts: -const widget = FloatingContact.init({ - channels: [ - { id: 'whatsapp', label: 'WhatsApp', phone: '5598991234567', action: { type: 'popup' } }, - { id: 'telegram', label: 'Telegram', action: { type: 'link', url: 'https://t.me/mybot' } }, - ], -}); +```bash +npm run dev:web # site only +npm run dev:runtimes # runtime packages only, in watch mode +npm run build # build:runtimes then build:web +npm run typecheck # tsc for the packages, vue-tsc for the site +npm run lint # eslint . (lint:fix to write) +npm run format # prettier --write . (format:check in CI) +npm test # vitest run (test:watch to watch) +npm run size # gzip budget for every runtime bundle +npm run deploy # wrangler deploy ``` -## Built-in Channels +`npm run dev` without a prior `build:runtimes` still starts, but the preview iframe +shows "Runtime bundle failed to load" until the bundles exist. -These channel IDs have built-in icons and colors. Just set the `id` and the rest is automatic: +## Architecture -| ID | Color | Description | -| --- | --- | --- | -| `whatsapp` | `#25D366` | WhatsApp (supports `phone` and `message` fields) | -| `telegram` | `#26A5E4` | Telegram | -| `instagram` | `#E4405F` | Instagram | -| `messenger` | `#006AFF` | Facebook Messenger | -| `email` | `#D44638` | Email (mailto link) | -| `phone` | `#34B7F1` | Phone call (supports `phone` field, auto-builds `tel:` link) | -| `sms` | `#4CAF50` | SMS message | -| `viber` | `#7360F2` | Viber | -| `line` | `#00C300` | LINE | -| `wechat` | `#09B83E` | WeChat | -| `tiktok` | `#000000` | TikTok | -| `x` | `#000000` | X (Twitter) | +Three layers, with one hard boundary between them. -## Channel Actions +**`apps/web`** — the Nuxt 4 platform, rendered by Nitro on Cloudflare Workers. Nuxt +Content owns the Markdown documentation, Nuxt I18n owns localized routes and messages, +and the sitemap/robots modules expose crawler metadata. The app holds the registry, +generic playground and code generators. It is a configuration interface; widget +previews still run only from the built runtime bundle inside the sandboxed iframe. -Each channel requires an `action` object that defines what happens when the user clicks it. There are three types: +**`packages/*`** — the runtimes. Framework-free TypeScript libraries built with tsup, +shipped as an IIFE bundle for ` -``` +### 1. Build the runtime package -## Custom Icons - -Every channel supports custom icons via the `icon` field. You can use: - -- **Built-in name**: `'whatsapp'`, `'telegram'`, `'email'`, etc. -- **Image URL**: path to a PNG, WebP, or SVG file with transparent background -- **Inline SVG**: raw SVG string starting with `...', - action: { type: 'popup' }, - }, - ], -}); ``` - -## Event Callbacks - -Widget-level event hooks (different from the channel `callback` action type): - -```js -FloatingContact.init({ - channels: [/* ... */], - onOpen: () => console.log('Channel bar opened'), - onClose: () => console.log('Channel bar closed'), - onChannelClick: (ch) => console.log('Channel clicked:', ch.id), - onPopupOpen: (ch) => console.log('Popup opened:', ch.id), - onPopupClose: () => console.log('Popup closed'), -}); +packages// + src/index.ts ESM/CJS entry: the typed API and options + src/cdn.ts IIFE entry: assigns the global, auto-inits from data-* attributes + src/styles.css imported with ?inline, minified into the bundle by tsup + tsup.config.ts two builds: esm+cjs with dts, and a minified IIFE + test/ vitest specs + package.json name, exports, files: ["dist"], build/dev scripts + README.md the tool's own documentation ``` -## Methods - -| Method | Description | -| --- | --- | -| `widget.open()` | Open the channel bar (multi) or popup (single) | -| `widget.close()` | Close everything | -| `widget.toggle()` | Toggle open/close | -| `widget.openPopup(channel)` | Open popup for a specific channel | -| `widget.closePopup()` | Close the popup | -| `widget.destroy()` | Remove the widget from the DOM | - -## WhatsApp Shorthand - -For backward compatibility or simple WhatsApp-only use: - -```js -FloatingContact.whatsapp({ - phone: '5598991234567', - headerTitle: 'WhatsApp Chat', - popupMessage: 'Hi! How can we help you?', - position: 'right', - buttonShape: 'circle', - notification: true, - notificationMessage: 'Chat with us!', -}); -``` +It must satisfy `WidgetFactory` from `@codions/tools-core`: `init(options)` returns an +object with `destroy()`. That is the whole contract the platform depends on. Copy an +existing `tsup.config.ts` — the CSS-inlining esbuild plugin and +`noExternal: ['@codions/tools-core']` are both load-bearing. -## Data Attributes +### 2. Wire the package into the root tooling -### Single-channel (WhatsApp) +- `package.json` — add it to `build:runtimes`, `dev:runtimes` and `typecheck`. +- `vitest.config.ts` — add a project pointing at the package, **and** add the same + `VITE__VERSION` define to the `web` project's `define` block (the tool's + `index.ts` reads it at module load; omitting it here fails `test/platform.spec.ts` + with a runtime `TypeError`, not an obvious type/i18n error). +- `scripts/size-check.mjs` — add the bundle and its gzip budget. +- `apps/web/env.d.ts` — declare the `VITE__VERSION` env var. -Convert option names to `data-` attributes with kebab-case: +### 3. Build the tool's platform directory -```html - ``` - -### Multi-channel - -Use `data-channels` with a JSON array: - -```html - -``` - -## Custom Styling - -Override CSS custom properties or target the widget classes: - -```css -/* Button color and size */ -.fc-widget { - --fc-btn-bg: #E4405F; - --fc-size: 56px; -} - -/* Button shape (border radius) */ -.fc-widget { - --fc-btn-radius: 50%; /* circle (default) */ - --fc-btn-radius: 12px; /* rounded */ - --fc-btn-radius: 0; /* square */ -} - -/* Override channel buttons independently */ -.fc-widget { - --fc-channel-size: 48px; /* defaults to --fc-size */ - --fc-channel-radius: 8px; /* defaults to --fc-btn-radius */ - --fc-channel-gap: 8px; -} - -/* Custom popup width */ -.fc-widget__popup { - width: 380px; -} +apps/web/app/tools// + index.ts the ToolDefinition: metadata, runtime block, playground loader + defaults.ts editor defaults and the version-pinned CDN_URL + playground.ts the ToolPlayground: createState, toOptions, generateSnippets + codegen.ts snippet generation (escape everything that reaches HTML) + panel/ConfigPanel.vue the configuration form, plus its sections + docs/reference.ts data used by MDC reference tables + i18n/en.ts, i18n/pt-BR.ts the tool's own message namespace ``` -## Single Channel Mode - -When only one channel is configured, the widget skips the channel bar and behaves like a simple floating button — clicking it directly triggers the channel's action (popup or link). The main button adopts the channel's color and icon automatically. No X morph animation in this mode. - -## Deploy to Cloudflare Pages - -1. Fork or clone this repository -2. Connect it to [Cloudflare Pages](https://pages.cloudflare.com) -3. Set build configuration: - - **Build command:** `npm run build` - - **Build output directory:** `packages/docs/dist` - -## Development +`index.ts` loads eagerly because the catalogue needs it. `loadPlayground` is a dynamic +import, so visiting the home page does not pull in every tool's editor. Documentation +is queried from the Nuxt Content collection by locale and slug. + +### 4. Register it + +- **`apps/web/app/tools/index.ts`** — import the definition and add it to `TOOLS`. Array + order is the order shown on the home page and the tools index. +- **`apps/web/app/i18n/messages.ts`** — add the tool's namespace under `tools` for both + locales. `en` is the source of truth for the shape; a key missing from `pt-BR` is a + compile error. +- **`apps/web/nuxt.config.ts`** — add `{ pkg, file }` to `RUNTIME_BUNDLES` (this serves + the bundle at `/__lib/` in dev and copies it into `.output/public/__lib` on + build), a + `VITE__VERSION` define so the snippet can pin its CDN URL, and both + `/tools/` URLs to `sitemap.urls`. +- **`apps/web/content/en/tools/.md`** and + **`apps/web/content/pt-BR/tools/.md`** — add the localized documentation. Use + the MDC components in `app/components/content/`; reference tables must continue to + read `docs/reference.ts`, not duplicate those rows in Markdown. +- **`apps/web/content.config.ts`** — add the slug to the `tool` Zod enum, or the + content collection fails validation at typecheck/build time. +- **`apps/web/app/components/content/OptionsTable.vue`** — add a branch for the new + tool if its docs page uses `::options-table`; this component dispatches per tool by + hand rather than reading the registry generically. +- **`apps/web/server/tool-catalog.ts`** — add an entry to `TOOL_CATALOG` if the tool + should also be selectable from the single pixel (roadmap/00-arquitetura.md §5, the + Fase C platform under `roadmap/`). + +No route to add: `/tools/:slug` and `/tools/:slug/playground` resolve through the +registry. + +### 5. Verify + +`npm test` covers new tools without changes: `apps/web/test/platform.spec.ts` asserts +slug uniqueness, translated metadata in both locales, a version-pinned CDN URL, and +that every declared playground loads and produces renderable snippets. Nuxt Content +validates the Markdown front matter and collection schema during typecheck and build. + +## Build and distribution + +Runtimes are built with tsup, two builds per package: + +- **IIFE** from `src/cdn.ts`, minified, emitted as `dist/.min.js`. This is what a + ` + + diff --git a/apps/web/app/app/AppFooter.vue b/apps/web/app/app/AppFooter.vue new file mode 100644 index 0000000..b85c899 --- /dev/null +++ b/apps/web/app/app/AppFooter.vue @@ -0,0 +1,69 @@ + + + diff --git a/apps/web/app/app/AppHeader.vue b/apps/web/app/app/AppHeader.vue new file mode 100644 index 0000000..cf60eea --- /dev/null +++ b/apps/web/app/app/AppHeader.vue @@ -0,0 +1,218 @@ + + + diff --git a/apps/web/app/app/useAuthSignedIn.ts b/apps/web/app/app/useAuthSignedIn.ts new file mode 100644 index 0000000..afc80c4 --- /dev/null +++ b/apps/web/app/app/useAuthSignedIn.ts @@ -0,0 +1,14 @@ +/** + * Whether the current visitor has a valid session — reads `event.context.user` (set by + * `server/middleware/session.ts` for any page request) during SSR only, then carries + * that boolean through hydration via `useState` so the client never re-derives it (and + * never mismatches what the server already rendered). + * + * Used by `AppHeader.vue` to show "Panel" vs. "Sign in" — see roadmap/07-painel.md §1.2. + */ +export function useAuthSignedIn() { + return useState('auth-signed-in', () => { + const event = useRequestEvent() + return Boolean(event?.context.user) + }) +} diff --git a/apps/web/app/app/useDocumentMeta.ts b/apps/web/app/app/useDocumentMeta.ts new file mode 100644 index 0000000..f021337 --- /dev/null +++ b/apps/web/app/app/useDocumentMeta.ts @@ -0,0 +1,33 @@ +import { computed } from 'vue' +import type { I18nContext } from '@/i18n' +import { getTool } from '@/tools' + +const SITE_NAME = 'Codions Tools' + +/** + * Keeps server-rendered and client-side metadata in step with the active route. + */ +export function useDocumentMeta(i18n: I18nContext) { + const route = useRoute() + const { t } = i18n + const tool = computed(() => getTool(route.params.slug as string | undefined)) + const title = computed(() => (tool.value ? `${t(tool.value.nameKey)} — ${SITE_NAME}` : SITE_NAME)) + const description = computed(() => + tool.value ? t(tool.value.taglineKey) : t('home.hero.subtitle'), + ) + + useSeoMeta({ + title, + description, + ogTitle: title, + ogDescription: description, + ogImage: '/images/opengraph.png', + }) + + const localeHead = useLocaleHead({ seo: { canonicalQueries: [] } }) + useHead(() => ({ + htmlAttrs: localeHead.value.htmlAttrs, + link: localeHead.value.link, + meta: localeHead.value.meta, + })) +} diff --git a/packages/docs/src/composables/useTheme.ts b/apps/web/app/app/useTheme.ts similarity index 74% rename from packages/docs/src/composables/useTheme.ts rename to apps/web/app/app/useTheme.ts index e3a8b2a..b778b5f 100644 --- a/packages/docs/src/composables/useTheme.ts +++ b/apps/web/app/app/useTheme.ts @@ -26,21 +26,16 @@ export interface ThemeContext { setTheme: (t: Theme) => void } -const THEME_KEY: InjectionKey = Symbol('theme') - -function detectTheme(): Theme { - const stored = localStorage.getItem('theme') - if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored - return 'auto' -} +export const THEME_KEY: InjectionKey = Symbol('theme') function resolveTheme(theme: Theme): 'light' | 'dark' { if (theme !== 'auto') return theme + if (import.meta.server) return 'dark' return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } -export function createTheme(): ThemeContext { - const theme = ref(detectTheme()) +export function createTheme(initialTheme: Theme = 'auto'): ThemeContext { + const theme = ref(initialTheme) const resolved = ref<'light' | 'dark'>(resolveTheme(theme.value)) function updateResolved() { @@ -49,17 +44,21 @@ export function createTheme(): ThemeContext { function setTheme(t: Theme) { theme.value = t - localStorage.setItem('theme', t) + if (import.meta.client) localStorage.setItem('theme', t) updateResolved() } - const mq = window.matchMedia('(prefers-color-scheme: dark)') - mq.addEventListener('change', () => { - if (theme.value === 'auto') updateResolved() - }) + if (import.meta.client) { + const mq = window.matchMedia('(prefers-color-scheme: dark)') + mq.addEventListener('change', () => { + if (theme.value === 'auto') updateResolved() + }) + } watchEffect(() => { - document.documentElement.classList.toggle('light', resolved.value === 'light') + if (import.meta.client) { + document.documentElement.classList.toggle('light', resolved.value === 'light') + } }) return { theme, resolved, setTheme } diff --git a/apps/web/app/components/content/Callout.vue b/apps/web/app/components/content/Callout.vue new file mode 100644 index 0000000..f2edf8a --- /dev/null +++ b/apps/web/app/components/content/Callout.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/web/app/components/content/DocsBody.vue b/apps/web/app/components/content/DocsBody.vue new file mode 100644 index 0000000..3059b57 --- /dev/null +++ b/apps/web/app/components/content/DocsBody.vue @@ -0,0 +1,5 @@ + diff --git a/apps/web/app/components/content/FeatureCard.vue b/apps/web/app/components/content/FeatureCard.vue new file mode 100644 index 0000000..541c035 --- /dev/null +++ b/apps/web/app/components/content/FeatureCard.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/web/app/components/content/FeatureGrid.vue b/apps/web/app/components/content/FeatureGrid.vue new file mode 100644 index 0000000..50a5b59 --- /dev/null +++ b/apps/web/app/components/content/FeatureGrid.vue @@ -0,0 +1,5 @@ + diff --git a/apps/web/app/components/content/OptionsTable.vue b/apps/web/app/components/content/OptionsTable.vue new file mode 100644 index 0000000..da64d39 --- /dev/null +++ b/apps/web/app/components/content/OptionsTable.vue @@ -0,0 +1,148 @@ + + + diff --git a/apps/web/app/components/content/PlaygroundLink.vue b/apps/web/app/components/content/PlaygroundLink.vue new file mode 100644 index 0000000..d876465 --- /dev/null +++ b/apps/web/app/components/content/PlaygroundLink.vue @@ -0,0 +1,16 @@ + + + diff --git a/apps/web/app/components/content/ProseCode.vue b/apps/web/app/components/content/ProseCode.vue new file mode 100644 index 0000000..31c114b --- /dev/null +++ b/apps/web/app/components/content/ProseCode.vue @@ -0,0 +1,3 @@ + diff --git a/apps/web/app/components/content/ProseH2.vue b/apps/web/app/components/content/ProseH2.vue new file mode 100644 index 0000000..1eb3495 --- /dev/null +++ b/apps/web/app/components/content/ProseH2.vue @@ -0,0 +1,11 @@ + + diff --git a/apps/web/app/components/content/ProseH3.vue b/apps/web/app/components/content/ProseH3.vue new file mode 100644 index 0000000..13c3a05 --- /dev/null +++ b/apps/web/app/components/content/ProseH3.vue @@ -0,0 +1,6 @@ + + diff --git a/apps/web/app/components/content/ProseLi.vue b/apps/web/app/components/content/ProseLi.vue new file mode 100644 index 0000000..bbef743 --- /dev/null +++ b/apps/web/app/components/content/ProseLi.vue @@ -0,0 +1,3 @@ + diff --git a/apps/web/app/components/content/ProseP.vue b/apps/web/app/components/content/ProseP.vue new file mode 100644 index 0000000..97639b6 --- /dev/null +++ b/apps/web/app/components/content/ProseP.vue @@ -0,0 +1,3 @@ + diff --git a/apps/web/app/components/content/ProsePre.vue b/apps/web/app/components/content/ProsePre.vue new file mode 100644 index 0000000..393d7db --- /dev/null +++ b/apps/web/app/components/content/ProsePre.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/web/app/components/content/ProseUl.vue b/apps/web/app/components/content/ProseUl.vue new file mode 100644 index 0000000..11a76f6 --- /dev/null +++ b/apps/web/app/components/content/ProseUl.vue @@ -0,0 +1,5 @@ + diff --git a/apps/web/app/components/content/ToolHero.vue b/apps/web/app/components/content/ToolHero.vue new file mode 100644 index 0000000..f7b2260 --- /dev/null +++ b/apps/web/app/components/content/ToolHero.vue @@ -0,0 +1,67 @@ + + + diff --git a/apps/web/app/components/content/ToolLiveDemo.client.vue b/apps/web/app/components/content/ToolLiveDemo.client.vue new file mode 100644 index 0000000..aec0e45 --- /dev/null +++ b/apps/web/app/components/content/ToolLiveDemo.client.vue @@ -0,0 +1,9 @@ + + + diff --git a/apps/web/app/error.vue b/apps/web/app/error.vue new file mode 100644 index 0000000..4b7171c --- /dev/null +++ b/apps/web/app/error.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/web/app/i18n/index.ts b/apps/web/app/i18n/index.ts new file mode 100644 index 0000000..39096b2 --- /dev/null +++ b/apps/web/app/i18n/index.ts @@ -0,0 +1,24 @@ +import { useI18n as useVueI18n } from 'vue-i18n' +import type { Ref } from 'vue' + +export type Locale = 'en' | 'pt-BR' + +export const LOCALES: { code: Locale; label: string }[] = [ + { code: 'en', label: 'EN' }, + { code: 'pt-BR', label: 'PT' }, +] + +export interface I18nContext { + locale: Ref + t: (key: string) => string + setLocale: (locale: Locale) => Promise +} + +export function useI18n(): I18nContext { + const { locale, setLocale, t } = useVueI18n() + return { + locale: locale as Ref, + setLocale: (value) => setLocale(value), + t: (key) => t(key), + } +} diff --git a/apps/web/app/i18n/locales/en.ts b/apps/web/app/i18n/locales/en.ts new file mode 100644 index 0000000..f121b07 --- /dev/null +++ b/apps/web/app/i18n/locales/en.ts @@ -0,0 +1,244 @@ +const en = { + playground: { + unpublished: + 'This package is not on npm yet, so the CDN URL below does not resolve. The configuration and preview are real; publishing is the remaining step.', + }, + brand: { + name: 'Codions Tools', + }, + nav: { + tools: 'Tools', + playground: 'Playground', + signIn: 'Sign in', + panel: 'Panel', + account: 'Account', + }, + // === Platform-wide === + common: { + docsComingSoon: 'Documentation for this tool is on its way.', + status: { + comingSoon: 'Coming soon', + }, + }, + home: { + hero: { + badge: 'Built on Cloudflare Workers', + title: 'One pixel. Every tool your site needs.', + subtitle: + "Register a site, paste one snippet — the pixel — then turn tools on, configure them and read their numbers from your panel afterwards. No per-tool script, no redeploys. Prefer to try first? Every tool's playground works with no account at all.", + ctaTools: 'Browse tools', + ctaAccount: 'Sign in', + exampleAlt: 'The Coupon widget running in its live playground preview.', + freeNotice: + 'Free to use today, with no limit on how many sites you register. If paid plans ever arrive as the platform grows, current users will hear about it well in advance.', + }, + integrations: { + title: 'Integrations', + subtitle: + 'Webhook delivery already works on the capture widgets today; native Slack, Discord, Microsoft Teams and Telegram translators are next.', + now: 'Available now', + soon: 'Coming soon', + channels: { + webhook: 'Webhook', + slack: 'Slack', + discord: 'Discord', + msTeams: 'Microsoft Teams', + telegram: 'Telegram', + googleChat: 'Google Chat', + whatsapp: 'WhatsApp', + sms: 'SMS', + ntfy: 'NTFY', + }, + }, + }, + toolCategories: { + 'social-proof': { + title: 'Social proof', + desc: 'Show visitors that other people already trust you.', + }, + capture: { + title: 'Capture', + desc: 'Turn visitors into leads and contacts you can follow up with.', + }, + engagement: { + title: 'Engagement', + desc: 'Keep visitors on the page and guide them to the next step.', + }, + compliance: { + title: 'Compliance', + desc: 'Consent and privacy controls for the tools running on your site.', + }, + utility: { + title: 'Utility', + desc: 'General-purpose building blocks for whatever the others do not cover.', + }, + }, + toolsIndex: { + title: 'Tools', + subtitle: 'Every tool on the platform, with its documentation and playground.', + }, + notFound: { + title: 'Page not found', + desc: 'The page you are looking for does not exist, or it has moved.', + backHome: 'Back to home', + }, + auth: { + login: { + title: 'Sign in', + subtitle: 'No password — we will email you a link to sign in.', + emailLabel: 'Email', + // No '@' here on purpose: it starts vue-i18n's "linked message" syntax and + // breaks the compiler even quoted. See AGENTS.md's i18n trap note. + emailPlaceholder: 'Your email address', + submit: 'Send magic link', + submitting: 'Sending…', + sent: 'Check your email — we sent you a link to sign in. It expires in 15 minutes.', + invalidEmail: 'Enter a valid email address.', + error: 'Something went wrong. Try again in a moment.', + }, + }, + painel: { + title: 'Your sites', + subtitle: 'Register a site, get one pixel, turn tools on and off from here.', + addSite: 'Add site', + empty: { + title: 'No sites yet', + desc: 'Add your first site to get its pixel snippet.', + }, + siteCard: { + activeTools: 'active tools', + }, + form: { + nameLabel: 'Site name', + namePlaceholder: 'My Store', + domainLabel: 'Domain', + domainPlaceholder: 'example.com', + domainHint: 'You can verify ownership after creating the site.', + submit: 'Create site', + submitting: 'Creating…', + cancel: 'Cancel', + error: 'Enter a name and a domain.', + }, + site: { + snippetTitle: 'Your pixel', + snippetDesc: 'Paste this once in your site — everything else happens from here.', + toolsTitle: 'Tools', + configure: 'Configure', + metricsTitle: 'Metrics', + backToSite: 'Back to site', + save: 'Save', + saving: 'Saving…', + saved: 'Saved', + domainVerification: { + title: 'Domain verification', + verifiedBadge: 'Verified', + unverifiedDesc: + 'Paste this tag in your homepage’s , then verify — once verified, your pixel only runs on this domain.', + metaTagLabel: 'Verification tag', + verifyButton: 'Verify now', + verifying: 'Verifying…', + verifyFailed: 'Not found yet — make sure the tag is live on your homepage and try again.', + verifiedDesc: 'Your pixel is restricted to this domain.', + }, + campaigns: { + title: 'Scheduling & display rules', + startsAtLabel: 'Starts at', + startsAtHint: 'Leave empty to already be active.', + endsAtLabel: 'Ends at', + endsAtHint: 'Leave empty to run indefinitely.', + deviceLabel: 'Device', + deviceAll: 'All', + deviceDesktop: 'Desktop only', + deviceMobile: 'Mobile only', + frequencyLabel: 'Frequency', + frequencyAlways: 'Every pageview', + frequencyOncePerSession: 'Once per session', + frequencyOncePerBrowser: 'Once per browser', + }, + metrics: { + subtitle: 'Counters for the last 30 days, per active tool.', + noActiveTools: { + title: 'No active tools', + desc: 'Turn on a tool from the site page to start seeing its metrics here.', + }, + noEvents: 'No events reported in the last 30 days yet.', + event: 'Event', + last30d: 'Last 30 days', + today: 'Today', + avgPrev7d: '7-day avg. before today', + }, + submissions: { + title: 'Submissions', + subtitle: 'Everyone who submitted the Email Collector form on this site.', + exportCsv: 'Export CSV', + email: 'Email', + name: 'Name', + date: 'Date', + empty: { + title: 'No submissions yet', + desc: 'They will show up here as soon as a visitor fills the form.', + }, + }, + }, + conta: { + title: 'Account', + emailLabel: 'Signed in as', + signOut: 'Sign out', + freeNotice: 'Free to use today, with no limit on registered sites — details in our', + }, + }, + footer: { + brand: 'Codions Tools', + tagline: 'Embeddable tools for any website.', + license: 'MIT License', + copyright: 'Copyright © 2026', + company: 'Codions Tecnologia Criativa LTDA', + privacyPolicy: 'Privacy Policy', + termsOfService: 'Terms of Service', + }, + + // === Legal === + legal: { + privacy: { + title: 'Privacy Policy', + }, + termsOfService: { + title: 'Terms of Service', + }, + }, + + // === Theme === + theme: { + light: 'Light', + dark: 'Dark', + auto: 'System', + }, + // === Playground UI Components === + previewPanel: { + hideCode: 'Hide Code', + showCode: 'Show Code', + }, + previewToolbar: { + background: 'Background:', + white: 'White', + lightGray: 'Light Gray', + dark: 'Dark', + veryDark: 'Very Dark', + }, + copyButton: { + copied: 'Copied!', + copy: 'Copy', + }, + playgroundPage: { + closeConfig: 'Close Configuration', + openConfig: 'Open Configuration', + mobileWarning: + 'The playground is optimized for desktop and larger screens. For the best experience, use a wider browser window or a desktop device.', + mobileWarningDismiss: 'Got it', + }, + + // === DocsPage === +} + +export type Messages = typeof en +export default en diff --git a/apps/web/app/i18n/locales/pt-BR.ts b/apps/web/app/i18n/locales/pt-BR.ts new file mode 100644 index 0000000..b4e98f2 --- /dev/null +++ b/apps/web/app/i18n/locales/pt-BR.ts @@ -0,0 +1,244 @@ +import type { Messages } from './en' + +const ptBR: Messages = { + playground: { + unpublished: + 'O pacote desta ferramenta ainda não está no npm, então a URL do CDN abaixo não resolve. A configuração e o preview são reais; falta apenas publicar.', + }, + brand: { + name: 'Codions Tools', + }, + nav: { + tools: 'Ferramentas', + playground: 'Playground', + signIn: 'Entrar', + panel: 'Painel', + account: 'Conta', + }, + // === Platform-wide === + common: { + docsComingSoon: 'A documentação desta ferramenta está a caminho.', + status: { + comingSoon: 'Em breve', + }, + }, + home: { + hero: { + badge: 'Rodando na Cloudflare Workers', + title: 'Um pixel. Todas as ferramentas que seu site precisa.', + subtitle: + 'Cadastre um site, cole um único trecho de código — o pixel — e depois ligue ferramentas, configure e acompanhe os números pelo painel. Sem script por ferramenta, sem novo deploy. Quer testar primeiro? O playground de cada ferramenta funciona sem conta nenhuma.', + ctaTools: 'Ver ferramentas', + ctaAccount: 'Entrar', + exampleAlt: 'O widget de Cupom rodando no preview ao vivo do playground.', + freeNotice: + 'Uso gratuito hoje, sem limite de sites cadastrados. Se planos pagos surgirem conforme a plataforma crescer, quem já usa será avisado com bastante antecedência.', + }, + integrations: { + title: 'Integrações', + subtitle: + 'O envio via webhook já funciona hoje nas ferramentas de captura; os tradutores nativos de Slack, Discord, Microsoft Teams e Telegram vêm a seguir.', + now: 'Disponível agora', + soon: 'Em breve', + channels: { + webhook: 'Webhook', + slack: 'Slack', + discord: 'Discord', + msTeams: 'Microsoft Teams', + telegram: 'Telegram', + googleChat: 'Google Chat', + whatsapp: 'WhatsApp', + sms: 'SMS', + ntfy: 'NTFY', + }, + }, + }, + toolCategories: { + 'social-proof': { + title: 'Prova social', + desc: 'Mostre aos visitantes que outras pessoas já confiam em você.', + }, + capture: { + title: 'Captura', + desc: 'Transforme visitantes em contatos que você pode seguir depois.', + }, + engagement: { + title: 'Engajamento', + desc: 'Mantenha o visitante na página e guie o próximo passo.', + }, + compliance: { + title: 'Conformidade', + desc: 'Controles de consentimento e privacidade para as ferramentas do seu site.', + }, + utility: { + title: 'Utilidade', + desc: 'Blocos de uso geral para o que as outras categorias não cobrem.', + }, + }, + toolsIndex: { + title: 'Ferramentas', + subtitle: 'Todas as ferramentas da plataforma, com documentação e playground.', + }, + notFound: { + title: 'Página não encontrada', + desc: 'A página que você procura não existe ou foi movida.', + backHome: 'Voltar para o início', + }, + auth: { + login: { + title: 'Entrar', + subtitle: 'Sem senha — enviaremos um link de acesso para o seu e-mail.', + emailLabel: 'E-mail', + emailPlaceholder: 'Seu e-mail', + submit: 'Enviar link de acesso', + submitting: 'Enviando…', + sent: 'Verifique seu e-mail — enviamos um link de acesso. Ele expira em 15 minutos.', + invalidEmail: 'Informe um e-mail válido.', + error: 'Algo deu errado. Tente de novo em instantes.', + }, + }, + painel: { + title: 'Seus sites', + subtitle: 'Registre um site, receba um pixel único, ligue e desligue ferramentas por aqui.', + addSite: 'Adicionar site', + empty: { + title: 'Nenhum site ainda', + desc: 'Adicione seu primeiro site para receber o snippet do pixel.', + }, + siteCard: { + activeTools: 'ferramentas ativas', + }, + form: { + nameLabel: 'Nome do site', + namePlaceholder: 'Minha Loja', + domainLabel: 'Domínio', + domainPlaceholder: 'exemplo.com', + domainHint: 'Você pode verificar a posse depois de criar o site.', + submit: 'Criar site', + submitting: 'Criando…', + cancel: 'Cancelar', + error: 'Informe um nome e um domínio.', + }, + site: { + snippetTitle: 'Seu pixel', + snippetDesc: 'Cole isto uma vez no seu site — o resto acontece por aqui.', + toolsTitle: 'Ferramentas', + configure: 'Configurar', + metricsTitle: 'Métricas', + backToSite: 'Voltar para o site', + save: 'Salvar', + saving: 'Salvando…', + saved: 'Salvo', + domainVerification: { + title: 'Verificação de domínio', + verifiedBadge: 'Verificado', + unverifiedDesc: + 'Cole esta tag no da sua página inicial e verifique — depois de verificado, seu pixel só roda nesse domínio.', + metaTagLabel: 'Tag de verificação', + verifyButton: 'Verificar agora', + verifying: 'Verificando…', + verifyFailed: + 'Ainda não encontrada — confirme que a tag está publicada na sua página inicial e tente de novo.', + verifiedDesc: 'Seu pixel está restrito a este domínio.', + }, + campaigns: { + title: 'Agendamento e regras de exibição', + startsAtLabel: 'Começa em', + startsAtHint: 'Deixe em branco para já estar ativo.', + endsAtLabel: 'Termina em', + endsAtHint: 'Deixe em branco para rodar por tempo indeterminado.', + deviceLabel: 'Dispositivo', + deviceAll: 'Todos', + deviceDesktop: 'Só desktop', + deviceMobile: 'Só celular', + frequencyLabel: 'Frequência', + frequencyAlways: 'Toda visita', + frequencyOncePerSession: 'Uma vez por sessão', + frequencyOncePerBrowser: 'Uma vez por navegador', + }, + metrics: { + subtitle: 'Contadores dos últimos 30 dias, por ferramenta ativa.', + noActiveTools: { + title: 'Nenhuma ferramenta ativa', + desc: 'Ative uma ferramenta na página do site para começar a ver as métricas aqui.', + }, + noEvents: 'Nenhum evento reportado nos últimos 30 dias ainda.', + event: 'Evento', + last30d: 'Últimos 30 dias', + today: 'Hoje', + avgPrev7d: 'Média dos 7 dias anteriores', + }, + submissions: { + title: 'Envios', + subtitle: 'Todo mundo que enviou o formulário do Coletor de E-mail neste site.', + exportCsv: 'Exportar CSV', + email: 'E-mail', + name: 'Nome', + date: 'Data', + empty: { + title: 'Nenhum envio ainda', + desc: 'Eles vão aparecer aqui assim que um visitante preencher o formulário.', + }, + }, + }, + conta: { + title: 'Conta', + emailLabel: 'Conectado como', + signOut: 'Sair', + freeNotice: 'Uso gratuito hoje, sem limite de sites cadastrados — detalhes nos nossos', + }, + }, + footer: { + brand: 'Codions Tools', + tagline: 'Ferramentas embutíveis para qualquer site.', + license: 'Licença MIT', + copyright: 'Copyright © 2026', + company: 'Codions Tecnologia Criativa LTDA', + privacyPolicy: 'Política de Privacidade', + termsOfService: 'Termos de Uso', + }, + + // === Legal === + legal: { + privacy: { + title: 'Política de Privacidade', + }, + termsOfService: { + title: 'Termos de Uso', + }, + }, + + // === Theme === + theme: { + light: 'Claro', + dark: 'Escuro', + auto: 'Sistema', + }, + // === Playground UI Components === + previewPanel: { + hideCode: 'Ocultar Código', + showCode: 'Mostrar Código', + }, + previewToolbar: { + background: 'Fundo:', + white: 'Branco', + lightGray: 'Cinza Claro', + dark: 'Escuro', + veryDark: 'Muito Escuro', + }, + copyButton: { + copied: 'Copiado!', + copy: 'Copiar', + }, + playgroundPage: { + closeConfig: 'Fechar Configuração', + openConfig: 'Abrir Configuração', + mobileWarning: + 'O playground é otimizado para desktop e telas maiores. Para uma melhor experiência, use uma janela de navegador mais ampla ou um dispositivo desktop.', + mobileWarningDismiss: 'Entendi', + }, + + // === DocsPage === +} + +export default ptBR diff --git a/apps/web/app/i18n/messages.ts b/apps/web/app/i18n/messages.ts new file mode 100644 index 0000000..88a2aad --- /dev/null +++ b/apps/web/app/i18n/messages.ts @@ -0,0 +1,95 @@ +import shellEn from './locales/en' +import shellPtBR from './locales/pt-BR' +import fcbEn from '@/tools/floating-contact-button/i18n/en' +import fcbPtBR from '@/tools/floating-contact-button/i18n/pt-BR' +import consentEn from '@/tools/consent-manager/i18n/en' +import consentPtBR from '@/tools/consent-manager/i18n/pt-BR' +import announcementEn from '@/tools/announcement-bar/i18n/en' +import announcementPtBR from '@/tools/announcement-bar/i18n/pt-BR' +import recentActivityEn from '@/tools/recent-activity/i18n/en' +import recentActivityPtBR from '@/tools/recent-activity/i18n/pt-BR' +import liveCounterEn from '@/tools/live-counter/i18n/en' +import liveCounterPtBR from '@/tools/live-counter/i18n/pt-BR' +import reviewsEn from '@/tools/reviews/i18n/en' +import reviewsPtBR from '@/tools/reviews/i18n/pt-BR' +import couponEn from '@/tools/coupon/i18n/en' +import couponPtBR from '@/tools/coupon/i18n/pt-BR' +import emailCollectorEn from '@/tools/email-collector/i18n/en' +import emailCollectorPtBR from '@/tools/email-collector/i18n/pt-BR' +import requestCollectorEn from '@/tools/request-collector/i18n/en' +import requestCollectorPtBR from '@/tools/request-collector/i18n/pt-BR' +import countdownEn from '@/tools/countdown/i18n/en' +import countdownPtBR from '@/tools/countdown/i18n/pt-BR' +import newsletterEn from '@/tools/newsletter/i18n/en' +import newsletterPtBR from '@/tools/newsletter/i18n/pt-BR' +import feedbackEn from '@/tools/feedback/i18n/en' +import feedbackPtBR from '@/tools/feedback/i18n/pt-BR' +import socialShareEn from '@/tools/social-share/i18n/en' +import socialSharePtBR from '@/tools/social-share/i18n/pt-BR' +import mediaEmbedEn from '@/tools/media-embed/i18n/en' +import mediaEmbedPtBR from '@/tools/media-embed/i18n/pt-BR' +import linkHubEn from '@/tools/link-hub/i18n/en' +import linkHubPtBR from '@/tools/link-hub/i18n/pt-BR' +import customHtmlEn from '@/tools/custom-html/i18n/en' +import customHtmlPtBR from '@/tools/custom-html/i18n/pt-BR' +import badgeEn from '@/tools/badge/i18n/en' +import badgePtBR from '@/tools/badge/i18n/pt-BR' +import faqHelpCenterEn from '@/tools/faq-help-center/i18n/en' +import faqHelpCenterPtBR from '@/tools/faq-help-center/i18n/pt-BR' + +/** + * Shell strings plus one namespace per tool, so a tool owns its own copy. + * + * `en` is the source of truth for the shape; every other locale is annotated with + * `Messages`, which turns a missing key into a compile error rather than a string + * that silently renders as its own key at runtime. + */ +export const en = { + ...shellEn, + tools: { + 'floating-contact-button': fcbEn, + 'consent-manager': consentEn, + 'announcement-bar': announcementEn, + 'recent-activity': recentActivityEn, + 'live-counter': liveCounterEn, + reviews: reviewsEn, + coupon: couponEn, + 'email-collector': emailCollectorEn, + 'request-collector': requestCollectorEn, + countdown: countdownEn, + newsletter: newsletterEn, + feedback: feedbackEn, + 'social-share': socialShareEn, + 'media-embed': mediaEmbedEn, + 'link-hub': linkHubEn, + 'custom-html': customHtmlEn, + badge: badgeEn, + 'faq-help-center': faqHelpCenterEn, + }, +} + +export type Messages = typeof en + +export const ptBR: Messages = { + ...shellPtBR, + tools: { + 'floating-contact-button': fcbPtBR, + 'consent-manager': consentPtBR, + 'announcement-bar': announcementPtBR, + 'recent-activity': recentActivityPtBR, + 'live-counter': liveCounterPtBR, + reviews: reviewsPtBR, + coupon: couponPtBR, + 'email-collector': emailCollectorPtBR, + 'request-collector': requestCollectorPtBR, + countdown: countdownPtBR, + newsletter: newsletterPtBR, + feedback: feedbackPtBR, + 'social-share': socialSharePtBR, + 'media-embed': mediaEmbedPtBR, + 'link-hub': linkHubPtBR, + 'custom-html': customHtmlPtBR, + badge: badgePtBR, + 'faq-help-center': faqHelpCenterPtBR, + }, +} diff --git a/apps/web/app/i18n/useToolI18n.ts b/apps/web/app/i18n/useToolI18n.ts new file mode 100644 index 0000000..1f897c1 --- /dev/null +++ b/apps/web/app/i18n/useToolI18n.ts @@ -0,0 +1,17 @@ +import { useI18n } from './index' + +/** + * Scopes translation lookups to one tool's namespace, so a tool's components address + * their own keys directly instead of repeating their slug in every call. + * + * `tRoot` remains available for shell strings (copy button, preview toolbar, …). + */ +export function useToolI18n(slug: string) { + const { t, locale, setLocale } = useI18n() + return { + t: (key: string) => t(`tools.${slug}.${key}`), + tRoot: t, + locale, + setLocale, + } +} diff --git a/apps/web/app/pages/[...path].vue b/apps/web/app/pages/[...path].vue new file mode 100644 index 0000000..bc0ceb6 --- /dev/null +++ b/apps/web/app/pages/[...path].vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/web/app/pages/docs.vue b/apps/web/app/pages/docs.vue new file mode 100644 index 0000000..e84bd36 --- /dev/null +++ b/apps/web/app/pages/docs.vue @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/app/pages/entrar.vue b/apps/web/app/pages/entrar.vue new file mode 100644 index 0000000..0cf7320 --- /dev/null +++ b/apps/web/app/pages/entrar.vue @@ -0,0 +1,71 @@ + + + diff --git a/apps/web/app/pages/index.vue b/apps/web/app/pages/index.vue new file mode 100644 index 0000000..b172451 --- /dev/null +++ b/apps/web/app/pages/index.vue @@ -0,0 +1,105 @@ + + + diff --git a/apps/web/app/pages/painel/conta.vue b/apps/web/app/pages/painel/conta.vue new file mode 100644 index 0000000..ecdf9e5 --- /dev/null +++ b/apps/web/app/pages/painel/conta.vue @@ -0,0 +1,47 @@ + + + diff --git a/apps/web/app/pages/painel/index.vue b/apps/web/app/pages/painel/index.vue new file mode 100644 index 0000000..5b603c2 --- /dev/null +++ b/apps/web/app/pages/painel/index.vue @@ -0,0 +1,127 @@ + + + diff --git a/apps/web/app/pages/painel/sites/[siteId]/index.vue b/apps/web/app/pages/painel/sites/[siteId]/index.vue new file mode 100644 index 0000000..2484735 --- /dev/null +++ b/apps/web/app/pages/painel/sites/[siteId]/index.vue @@ -0,0 +1,202 @@ + + + diff --git a/apps/web/app/pages/painel/sites/[siteId]/metricas.vue b/apps/web/app/pages/painel/sites/[siteId]/metricas.vue new file mode 100644 index 0000000..15e353b --- /dev/null +++ b/apps/web/app/pages/painel/sites/[siteId]/metricas.vue @@ -0,0 +1,106 @@ + + + diff --git a/apps/web/app/pages/painel/sites/[siteId]/tools/[toolSlug]/index.vue b/apps/web/app/pages/painel/sites/[siteId]/tools/[toolSlug]/index.vue new file mode 100644 index 0000000..6180c30 --- /dev/null +++ b/apps/web/app/pages/painel/sites/[siteId]/tools/[toolSlug]/index.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/web/app/pages/painel/sites/[siteId]/tools/email-collector/envios.vue b/apps/web/app/pages/painel/sites/[siteId]/tools/email-collector/envios.vue new file mode 100644 index 0000000..2f47bd4 --- /dev/null +++ b/apps/web/app/pages/painel/sites/[siteId]/tools/email-collector/envios.vue @@ -0,0 +1,91 @@ + + + diff --git a/apps/web/app/pages/painel/sites/[siteId]/tools/request-collector/envios.vue b/apps/web/app/pages/painel/sites/[siteId]/tools/request-collector/envios.vue new file mode 100644 index 0000000..d468fd1 --- /dev/null +++ b/apps/web/app/pages/painel/sites/[siteId]/tools/request-collector/envios.vue @@ -0,0 +1,98 @@ + + + diff --git a/apps/web/app/pages/playground.vue b/apps/web/app/pages/playground.vue new file mode 100644 index 0000000..9fa57c8 --- /dev/null +++ b/apps/web/app/pages/playground.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/web/app/pages/privacy.vue b/apps/web/app/pages/privacy.vue new file mode 100644 index 0000000..5e3aead --- /dev/null +++ b/apps/web/app/pages/privacy.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/web/app/pages/terms.vue b/apps/web/app/pages/terms.vue new file mode 100644 index 0000000..50a19a2 --- /dev/null +++ b/apps/web/app/pages/terms.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/web/app/pages/tools/[slug]/index.vue b/apps/web/app/pages/tools/[slug]/index.vue new file mode 100644 index 0000000..dee9e17 --- /dev/null +++ b/apps/web/app/pages/tools/[slug]/index.vue @@ -0,0 +1,31 @@ + + + diff --git a/apps/web/app/pages/tools/[slug]/playground.vue b/apps/web/app/pages/tools/[slug]/playground.vue new file mode 100644 index 0000000..3ca6bec --- /dev/null +++ b/apps/web/app/pages/tools/[slug]/playground.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/web/app/pages/tools/index.vue b/apps/web/app/pages/tools/index.vue new file mode 100644 index 0000000..0e85df9 --- /dev/null +++ b/apps/web/app/pages/tools/index.vue @@ -0,0 +1,26 @@ + + + diff --git a/apps/web/app/playground/CodeOutput.vue b/apps/web/app/playground/CodeOutput.vue new file mode 100644 index 0000000..0c41b74 --- /dev/null +++ b/apps/web/app/playground/CodeOutput.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/docs/src/pages/PlaygroundPage.vue b/apps/web/app/playground/PlaygroundLayout.vue similarity index 58% rename from packages/docs/src/pages/PlaygroundPage.vue rename to apps/web/app/playground/PlaygroundLayout.vue index e4355c7..37d9cc6 100644 --- a/packages/docs/src/pages/PlaygroundPage.vue +++ b/apps/web/app/playground/PlaygroundLayout.vue @@ -1,19 +1,28 @@ @@ -21,29 +30,39 @@ function dismissMobileBanner() {
- +
- - + + {{ t('playgroundPage.mobileWarning') }}
@@ -52,16 +71,16 @@ function dismissMobileBanner() {
- +
diff --git a/apps/web/app/playground/PreviewFrame.vue b/apps/web/app/playground/PreviewFrame.vue new file mode 100644 index 0000000..ea07ce2 --- /dev/null +++ b/apps/web/app/playground/PreviewFrame.vue @@ -0,0 +1,103 @@ + + +