diff --git a/composer.json b/composer.json index 8e28427882..d737e014d9 100644 --- a/composer.json +++ b/composer.json @@ -345,7 +345,7 @@ ] }, "scripts": { - "pre-autoload-dump": [ + "pre-autoload-dump": [ "Google\\Task\\Composer::cleanup" ], "post-install-cmd": [ diff --git a/web/landing/.env b/web/landing/.env index 02ca1822ef..a9cf1afe9a 100644 --- a/web/landing/.env +++ b/web/landing/.env @@ -11,6 +11,3 @@ TURNSTILE_SITE_KEY=0x4AAAAAACAjJaWImyaJwWCy TURNSTILE_APPEARANCE=interaction-only PLAYGROUND_API_URL=http://localhost:8787/api/playground/snippets PLAYGROUND_SNIPPETS_URL=http://localhost:8787 -DATADOG_APPLICATION_ID=4a297124-5ea7-4fc5-b75a-6fb71e699f54 -DATADOG_CLIENT_TOKEN=pubf4d7626f7fd335a27355e01cd877c277 -DATADOG_SITE=datadoghq.eu \ No newline at end of file diff --git a/web/landing/.env.dev b/web/landing/.env.dev index 0ecf547999..fa540eb1ec 100644 --- a/web/landing/.env.dev +++ b/web/landing/.env.dev @@ -1,6 +1,3 @@ POSTHOG_KEY= POSTHOG_API_HOST= POSTHOG_UI_HOST= -DATADOG_APPLICATION_ID= -DATADOG_CLIENT_TOKEN= -DATADOG_SITE= \ No newline at end of file diff --git a/web/landing/.env.test b/web/landing/.env.test index fc02db2e05..6e34ff0d7b 100644 --- a/web/landing/.env.test +++ b/web/landing/.env.test @@ -6,9 +6,6 @@ DOMAIN='flow-php.wip' POSTHOG_KEY= POSTHOG_API_HOST= POSTHOG_UI_HOST= -DATADOG_APPLICATION_ID= -DATADOG_CLIENT_TOKEN= -DATADOG_SITE= FLOW_VERSION=1.x-dev TURNSTILE_SITE_KEY=0x4AAAAAACAjJaWImyaJwWCy TURNSTILE_APPEARANCE=interaction-only diff --git a/web/landing/assets/codemirror/completions/dsl.js b/web/landing/assets/codemirror/completions/dsl.js index df60b8b97b..e7e5ac0013 100644 --- a/web/landing/assets/codemirror/completions/dsl.js +++ b/web/landing/assets/codemirror/completions/dsl.js @@ -1,7 +1,7 @@ /** * CodeMirror Completer for Flow PHP DSL Functions * - * Total functions: 821 + * Total functions: 826 * * This completer provides autocompletion for all Flow PHP DSL functions: * - Extractors (flow-extractors) @@ -2963,6 +2963,21 @@ const dslFunctions = [ }, apply: snippet("\\Flow\\PostgreSql\\DSL\\current_date()"), boost: 10 + }, { + label: "current_row", + type: "function", + detail: "flow\u002Ddsl\u002Ddata\u002Dframe", + info: () => { + const div = document.createElement("div") + div.innerHTML = ` +
+ current_row() : FrameBound +
+ ` + return div + }, + apply: snippet("\\Flow\\ETL\\DSL\\current_row()"), + boost: 10 }, { label: "current_time", type: "function", @@ -4205,6 +4220,21 @@ const dslFunctions = [ }, apply: snippet("\\Flow\\ETL\\DSL\\flow_context(" + "$" + "{" + "1:config" + "}" + ")"), boost: 10 + }, { + label: "following", + type: "function", + detail: "flow\u002Ddsl\u002Ddata\u002Dframe", + info: () => { + const div = document.createElement("div") + div.innerHTML = ` +
+ following(int $offset) : FrameBound +
+ ` + return div + }, + apply: snippet("\\Flow\\ETL\\DSL\\following(" + "$" + "{" + "1:offset" + "}" + ")"), + boost: 10 }, { label: "foreign_key", type: "function", @@ -7913,6 +7943,21 @@ const dslFunctions = [ }, apply: snippet("\\Flow\\ETL\\Adapter\\Doctrine\\postgresql_update_options(" + "$" + "{" + "1:primary_key_columns" + "}" + ", " + "$" + "{" + "2:update_columns" + "}" + ")"), boost: 10 + }, { + label: "preceding", + type: "function", + detail: "flow\u002Ddsl\u002Ddata\u002Dframe", + info: () => { + const div = document.createElement("div") + div.innerHTML = ` +
+ preceding(int $offset) : FrameBound +
+ ` + return div + }, + apply: snippet("\\Flow\\ETL\\DSL\\preceding(" + "$" + "{" + "1:offset" + "}" + ")"), + boost: 10 }, { label: "prepare_transaction", type: "function", @@ -12626,6 +12671,36 @@ const dslFunctions = [ }, apply: snippet("\\Flow\\ETL\\DSL\\ulid(" + "$" + "{" + "1:value" + "}" + ")"), boost: 10 + }, { + label: "unbounded_following", + type: "function", + detail: "flow\u002Ddsl\u002Ddata\u002Dframe", + info: () => { + const div = document.createElement("div") + div.innerHTML = ` +
+ unbounded_following() : FrameBound +
+ ` + return div + }, + apply: snippet("\\Flow\\ETL\\DSL\\unbounded_following()"), + boost: 10 + }, { + label: "unbounded_preceding", + type: "function", + detail: "flow\u002Ddsl\u002Ddata\u002Dframe", + info: () => { + const div = document.createElement("div") + div.innerHTML = ` +
+ unbounded_preceding() : FrameBound +
+ ` + return div + }, + apply: snippet("\\Flow\\ETL\\DSL\\unbounded_preceding()"), + boost: 10 }, { label: "union_schema", type: "function", diff --git a/web/landing/assets/controllers/shop_carousel_controller.js b/web/landing/assets/controllers/shop_carousel_controller.js new file mode 100644 index 0000000000..e6d000a4a6 --- /dev/null +++ b/web/landing/assets/controllers/shop_carousel_controller.js @@ -0,0 +1,59 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["track", "slide", "dot"] + static values = { index: { type: Number, default: 0 } } + + indexValueChanged() { + const count = this.slideTargets.length + + if (count === 0) { + return + } + + if (this.indexValue < 0) { + this.indexValue = count - 1 + return + } + + if (this.indexValue > count - 1) { + this.indexValue = 0 + return + } + + this.trackTarget.style.transform = `translateX(-${this.indexValue * 100}%)` + + this.slideTargets.forEach((slide, i) => { + slide.setAttribute('aria-hidden', i === this.indexValue ? 'false' : 'true') + }) + + this.dotTargets.forEach((dot, i) => { + const active = i === this.indexValue + dot.classList.toggle('bg-white', active) + dot.classList.toggle('bg-white/40', !active) + dot.setAttribute('aria-current', active ? 'true' : 'false') + }) + } + + next() { + this.indexValue++ + } + + prev() { + this.indexValue-- + } + + goTo(event) { + this.indexValue = Number(event.params.index) + } + + keydown(event) { + if (event.key === "ArrowLeft") { + event.preventDefault() + this.prev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + this.next() + } + } +} diff --git a/web/landing/assets/controllers/shop_checkout_controller.js b/web/landing/assets/controllers/shop_checkout_controller.js new file mode 100644 index 0000000000..1c3e8ea1a2 --- /dev/null +++ b/web/landing/assets/controllers/shop_checkout_controller.js @@ -0,0 +1,14 @@ +import { Controller } from "@hotwired/stimulus" +import { PolarEmbedCheckout } from "@polar-sh/checkout/embed" + +export default class extends Controller { + static values = { url: String } + + open(event) { + event.preventDefault() + + const theme = document.documentElement.classList.contains("dark") ? "dark" : "light" + + PolarEmbedCheckout.create(this.urlValue, { theme }) + } +} diff --git a/web/landing/assets/controllers/shop_confetti_controller.js b/web/landing/assets/controllers/shop_confetti_controller.js new file mode 100644 index 0000000000..daeba0dea9 --- /dev/null +++ b/web/landing/assets/controllers/shop_confetti_controller.js @@ -0,0 +1,153 @@ +import { Controller } from "@hotwired/stimulus" + +// Brand palette from tailwind.config.js, plus white so the fall reads on the dark theme. +const COLORS = ["#ff5547", "#806dfe", "#e1761a", "#5945d8", "#ffffff"] +const PIECES = 150 +const GRAVITY = 0.04 +const MAX_FALL = 3.5 +// Pieces start staggered above the fold so they keep arriving instead of landing all at once. +const STAGGER = 1.1 +const FADE_FROM = 0.82 + +export default class extends Controller { + connect() { + this.motion = window.matchMedia("(prefers-reduced-motion: reduce)") + this.pieces = [] + this.frame = null + this.onResize = () => this.#resize() + + this.burst() + } + + disconnect() { + this.#stop() + this.#unmount() + } + + burst() { + if (this.motion.matches) { + return + } + + this.#mount() + + const width = window.innerWidth + const height = window.innerHeight + + for (let i = 0; i < PIECES; i++) { + this.pieces.push({ + x: Math.random() * width, + y: -20 - Math.random() * height * STAGGER, + vx: (Math.random() - 0.5) * 0.7, + vy: 1 + Math.random() * 1.5, + sway: 0.4 + Math.random() * 1.1, + swaySpeed: 0.01 + Math.random() * 0.03, + phase: Math.random() * Math.PI * 2, + width: 5 + Math.random() * 5, + height: 9 + Math.random() * 6, + rotation: Math.random() * Math.PI * 2, + spin: (Math.random() - 0.5) * 0.12, + flip: Math.random() * Math.PI * 2, + flipSpeed: 0.05 + Math.random() * 0.09, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + }) + } + + this.#start() + } + + #tick() { + const context = this.context + const height = window.innerHeight + + context.clearRect(0, 0, window.innerWidth, height) + + this.pieces = this.pieces.filter((piece) => { + piece.vy = Math.min(piece.vy + GRAVITY, MAX_FALL) + piece.phase += piece.swaySpeed + piece.y += piece.vy + piece.x += piece.vx + Math.sin(piece.phase) * piece.sway + piece.rotation += piece.spin + piece.flip += piece.flipSpeed + + if (piece.y > height + 40) { + return false + } + + const travelled = piece.y / height + + context.save() + context.translate(piece.x, piece.y) + context.rotate(piece.rotation) + // Squashing the height as it tumbles fakes a flat piece turning over. + context.scale(1, Math.abs(Math.cos(piece.flip))) + context.globalAlpha = travelled > FADE_FROM ? Math.max(0, 1 - (travelled - FADE_FROM) / (1 - FADE_FROM)) : 1 + context.fillStyle = piece.color + context.fillRect(-piece.width / 2, -piece.height / 2, piece.width, piece.height) + context.restore() + + return true + }) + + if (this.pieces.length === 0) { + this.#stop() + this.#unmount() + + return + } + + this.frame = window.requestAnimationFrame(() => this.#tick()) + } + + #mount() { + if (this.canvas) { + return + } + + this.canvas = document.createElement("canvas") + this.canvas.setAttribute("aria-hidden", "true") + this.canvas.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:60" + document.body.appendChild(this.canvas) + this.context = this.canvas.getContext("2d") + + this.#resize() + window.addEventListener("resize", this.onResize) + } + + #resize() { + if (!this.canvas) { + return + } + + const ratio = window.devicePixelRatio || 1 + + this.canvas.width = window.innerWidth * ratio + this.canvas.height = window.innerHeight * ratio + this.canvas.style.width = `${window.innerWidth}px` + this.canvas.style.height = `${window.innerHeight}px` + this.context.setTransform(ratio, 0, 0, ratio, 0, 0) + } + + #unmount() { + window.removeEventListener("resize", this.onResize) + + if (this.canvas) { + this.canvas.remove() + this.canvas = null + this.context = null + } + } + + #start() { + if (this.frame === null) { + this.frame = window.requestAnimationFrame(() => this.#tick()) + } + } + + #stop() { + if (this.frame !== null) { + window.cancelAnimationFrame(this.frame) + this.frame = null + } + } +} diff --git a/web/landing/assets/controllers/shop_nav_controller.js b/web/landing/assets/controllers/shop_nav_controller.js new file mode 100644 index 0000000000..b8c353e862 --- /dev/null +++ b/web/landing/assets/controllers/shop_nav_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["link"] + + connect() { + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.#activate(entry.target.id) + } + }) + }, + { rootMargin: "-45% 0px -50% 0px", threshold: 0 }, + ) + + this.linkTargets.forEach((link) => { + const section = document.getElementById(link.dataset.section) + + if (section) { + this.observer.observe(section) + } + }) + } + + disconnect() { + if (this.observer) { + this.observer.disconnect() + } + } + + #activate(id) { + this.linkTargets.forEach((link) => { + if (link.dataset.section === id) { + link.setAttribute("aria-current", "true") + } else { + link.removeAttribute("aria-current") + } + }) + } +} diff --git a/web/landing/assets/controllers/shop_order_controller.js b/web/landing/assets/controllers/shop_order_controller.js new file mode 100644 index 0000000000..6ef36f5021 --- /dev/null +++ b/web/landing/assets/controllers/shop_order_controller.js @@ -0,0 +1,21 @@ +import { Controller } from "@hotwired/stimulus" + +// Polar appends ?checkout_id= to the success URL. Anything that isn't a +// UUID is someone playing with the query string, so we leave the block hidden +// rather than render it. +const CHECKOUT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export default class extends Controller { + static targets = ["reference"] + + connect() { + const checkoutId = new URLSearchParams(window.location.search).get("checkout_id") + + if (checkoutId === null || !CHECKOUT_ID.test(checkoutId)) { + return + } + + this.referenceTarget.textContent = checkoutId + this.element.hidden = false + } +} diff --git a/web/landing/assets/images/icons/cart.svg b/web/landing/assets/images/icons/cart.svg new file mode 100644 index 0000000000..0255d67e88 --- /dev/null +++ b/web/landing/assets/images/icons/cart.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/web/landing/assets/images/shop/consulting-2.jpg b/web/landing/assets/images/shop/consulting-2.jpg new file mode 100644 index 0000000000..6441401eb7 Binary files /dev/null and b/web/landing/assets/images/shop/consulting-2.jpg differ diff --git a/web/landing/assets/images/shop/consulting.jpg b/web/landing/assets/images/shop/consulting.jpg new file mode 100644 index 0000000000..de8e04636d Binary files /dev/null and b/web/landing/assets/images/shop/consulting.jpg differ diff --git a/web/landing/assets/images/shop/symfony-backoffice-api.png b/web/landing/assets/images/shop/symfony-backoffice-api.png new file mode 100644 index 0000000000..fcf135fb32 Binary files /dev/null and b/web/landing/assets/images/shop/symfony-backoffice-api.png differ diff --git a/web/landing/assets/images/shop/symfony-backoffice-app.png b/web/landing/assets/images/shop/symfony-backoffice-app.png new file mode 100644 index 0000000000..50919ea7e2 Binary files /dev/null and b/web/landing/assets/images/shop/symfony-backoffice-app.png differ diff --git a/web/landing/assets/images/shop/symfony-backoffice-import.png b/web/landing/assets/images/shop/symfony-backoffice-import.png new file mode 100644 index 0000000000..308d3c4db6 Binary files /dev/null and b/web/landing/assets/images/shop/symfony-backoffice-import.png differ diff --git a/web/landing/assets/images/shop/symfony-backoffice-profiler.png b/web/landing/assets/images/shop/symfony-backoffice-profiler.png new file mode 100644 index 0000000000..65b89fdd2a Binary files /dev/null and b/web/landing/assets/images/shop/symfony-backoffice-profiler.png differ diff --git a/web/landing/assets/images/shop/symfony-backoffice-telemetry.png b/web/landing/assets/images/shop/symfony-backoffice-telemetry.png new file mode 100644 index 0000000000..84236ea7f9 Binary files /dev/null and b/web/landing/assets/images/shop/symfony-backoffice-telemetry.png differ diff --git a/web/landing/assets/images/sponsors/anthropic-dark.svg b/web/landing/assets/images/sponsors/anthropic-dark.svg new file mode 100644 index 0000000000..2acba0ee25 --- /dev/null +++ b/web/landing/assets/images/sponsors/anthropic-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/landing/assets/images/sponsors/anthropic-light.svg b/web/landing/assets/images/sponsors/anthropic-light.svg new file mode 100644 index 0000000000..cf2a4af121 --- /dev/null +++ b/web/landing/assets/images/sponsors/anthropic-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/landing/assets/styles/app.css b/web/landing/assets/styles/app.css index 2847b581b2..5028570499 100644 --- a/web/landing/assets/styles/app.css +++ b/web/landing/assets/styles/app.css @@ -1,4 +1,5 @@ @import './changelog.css'; +@import './shop-legal.css'; @tailwind base; @tailwind components; diff --git a/web/landing/assets/styles/shop-legal.css b/web/landing/assets/styles/shop-legal.css new file mode 100644 index 0000000000..c3f8d82a85 --- /dev/null +++ b/web/landing/assets/styles/shop-legal.css @@ -0,0 +1,53 @@ +section.shop-legal { + @apply text-slate-700 leading-relaxed dark:text-white/85; +} + +section.shop-legal h1 { + @apply font-bold text-3xl sm:text-4xl tracking-tight text-slate-900 mt-2 mb-6 dark:text-white; +} + +section.shop-legal h2 { + @apply font-bold text-2xl tracking-tight text-slate-900 mt-10 mb-3 dark:text-white; +} + +section.shop-legal h3 { + @apply font-semibold text-xl text-slate-900 mt-7 mb-2 dark:text-white; +} + +section.shop-legal p { + @apply mb-4; +} + +section.shop-legal a { + @apply text-blue-300 underline-offset-2 hover:text-orange-300 transition-colors + dark:text-blue-100 dark:hover:text-orange-100; +} + +section.shop-legal ul { + @apply list-disc pl-6 mb-4 space-y-1.5; +} + +section.shop-legal ol { + @apply list-decimal pl-6 mb-4 space-y-1.5; +} + +section.shop-legal strong { + @apply font-semibold text-slate-900 dark:text-white; +} + +section.shop-legal blockquote { + @apply my-6 rounded-r-lg border-l-4 border-orange-100/70 bg-orange-100/10 px-4 py-3 text-sm muted; +} + +section.shop-legal blockquote p { + @apply mb-0; +} + +section.shop-legal hr { + @apply my-8 border-0 h-px bg-slate-200 dark:bg-white/10; +} + +section.shop-legal :not(pre) > code { + @apply font-mono text-[0.9em] px-1.5 py-0.5 rounded bg-violet-50 text-violet-700 + dark:bg-white/[0.06] dark:text-[#d2a8ff]; +} diff --git a/web/landing/config/packages/twig.yaml b/web/landing/config/packages/twig.yaml index 8f1ef8c9f8..675f13db20 100644 --- a/web/landing/config/packages/twig.yaml +++ b/web/landing/config/packages/twig.yaml @@ -9,9 +9,6 @@ twig: turnstile_appearance: '%env(default:turnstile_appearance_default:TURNSTILE_APPEARANCE)%' playground_api_url: '%env(PLAYGROUND_API_URL)%' playground_snippets_url: '%env(PLAYGROUND_SNIPPETS_URL)%' - datadog_application_id: '%env(DATADOG_APPLICATION_ID)%' - datadog_client_token: '%env(DATADOG_CLIENT_TOKEN)%' - datadog_site: '%env(DATADOG_SITE)%' when@test: twig: strict_variables: true diff --git a/web/landing/config/services.yaml b/web/landing/config/services.yaml index f18fe4c328..936c384b93 100644 --- a/web/landing/config/services.yaml +++ b/web/landing/config/services.yaml @@ -11,6 +11,8 @@ parameters: router.request_context.scheme: '%env(SCHEME)%' flow_version: '%env(FLOW_VERSION)%' turnstile_appearance_default: 'interaction-only' + shop_checkout_links: + symfony_backoffice: 'https://buy.polar.sh/polar_cl_KAb6EWLVtewQhsDZV2bGeqsxSxTWKOOvl1KAq4GbDlx' services: _defaults: @@ -25,6 +27,10 @@ services: Flow\Website\Service\: resource: '../src/Flow/Website/Service/' + Flow\Website\Controller\ShopController: + arguments: + $checkoutLinks: '%shop_checkout_links%' + Flow\Website\Factory\Github\ContributorsRequestFactory: arguments: $githubToken: '%env(GITHUB_TOKEN)%' diff --git a/web/landing/content/shop/privacy-policy.md b/web/landing/content/shop/privacy-policy.md new file mode 100644 index 0000000000..c325e35931 --- /dev/null +++ b/web/landing/content/shop/privacy-policy.md @@ -0,0 +1,158 @@ +# Flow PHP Work-Shop - Privacy Policy + +Last updated: 31 July 2026 + +## 1. What this policy covers + +This policy covers the Flow PHP Work-Shop: the shop pages, buying digital content, and +booking consulting. It describes the data involved in becoming and being a +customer, and nothing else. + +The controller of that data is: + +Norbert Orzechowicz, sole proprietorship registered in Poland, +NIP 6852282788 (EU VAT ID PL6852282788), +Gołyszyn 100, 32-046 Skała, Poland, +contact: support@flow-php.com + +Payments are handled by Polar (Polar Software, Inc.) as Merchant of Record. For +payment data, Polar acts as its own controller under Polar's privacy policy. We +are the controller for the data we use to deliver access and provide support. + +## 2. How the shop works + +The shop pages are statically generated. There is no application server and no +database behind them: the pages you are reading are plain files. We do not run +accounts, and there is nothing here to log in to. + +Everything described below therefore sits with the providers listed in section 6, +in accounts that belong to us. We remain the controller for it, because we chose +those providers and decide what they collect and why. + +## 3. Analytics + +We use PostHog (PostHog Cloud EU, hosted in the European Union) to see which +pages get visited. It is served through `d.flow-php.com`, a proxy on our own +domain. + +PostHog runs in **cookieless mode**. It stores nothing in your browser: no +cookie, no local storage, no session storage. Rather than giving you a persistent +identifier, visits are grouped using a hash computed on PostHog's servers that +rotates daily. We cannot recognise you across days, and we cannot link a visit to +a person. + +PostHog receives your IP address, browser and device type, and the pages you +visit. The IP address is used to compute that daily hash and is then stripped, so +it is not stored against the events. We use the result in aggregate. We run no +advertising, we do not sell this data, and we do not combine it with purchase +data to identify individuals. + +Legal basis: our legitimate interest in understanding and maintaining the shop +(art. 6(1)(f) GDPR). + +## 4. What we collect when you buy something, why, and on what legal basis + +- Email address. To deliver access, send purchase-related messages, and provide + support. This is collected by Polar at checkout and visible to us in the Polar + dashboard. Legal basis: performance of a contract (art. 6(1)(b) GDPR). +- GitHub account or username. Where what you bought is delivered through GitHub, + to grant you access as a collaborator. You connect this yourself in the Polar + customer portal. Legal basis: performance of a contract (art. 6(1)(b) GDPR). +- Support correspondence. To handle your questions and complaints. Legal basis: + performance of a contract and our legitimate interest in supporting customers + (art. 6(1)(b) and (f) GDPR). +- Billing details and transaction records, including the billing address you give + at checkout and any VAT ID you enter. Collected by Polar to issue your invoice + and to meet accounting and tax obligations. These are primarily handled by + Polar as Merchant of Record; where we keep records (for example for + consultations), the legal basis is our legal obligation (art. 6(1)(c) GDPR). +- Sponsor listing (optional). If you opt in, the GitHub profile you choose to + display, shown publicly on a sponsors list. Legal basis: your consent + (art. 6(1)(a) GDPR), which you can withdraw at any time. +- Security and abuse prevention. Limited data to protect our repositories and + detect misuse. Legal basis: our legitimate interest (art. 6(1)(f) GDPR). + +We do not make any automated decisions about you, and we do not profile you. + +## 5. Payment data + +We do not collect or store your card or payment details, and we never see them. +Payment is processed by Polar as Merchant of Record, which issues your invoice or +receipt and handles applicable taxes. Polar processes payment data under its own +privacy policy. + +## 6. Service providers we use + +We rely on the following providers. They process personal data on our behalf or +as independent controllers, as applicable: + +- Polar (payment, invoicing, tax as Merchant of Record) - United States +- GitHub (hosting repositories and managing access) - United States +- PostHog (shop analytics) - European Union +- Discord (optional community access, if you join) - United States + +## 7. International transfers + +Our analytics provider stores data in the European Union. Polar, GitHub, and +Discord are based in the United States. Where your data is transferred outside +the European Economic Area, we rely on appropriate safeguards such as Standard +Contractual Clauses or an adequacy decision, as provided by the respective +provider. You can request more information about these safeguards using the +contact details below. + +## 8. How long we keep your data + +We keep your data only as long as necessary for the purposes above. Access and +support data are kept for as long as you have access and for a reasonable period +afterwards. Records needed for accounting or tax are kept for the period required +by law. Analytics data is retained according to the retention settings of the +provider in section 3. Consent-based data, such as the sponsor listing, is kept +until you withdraw consent. + +## 9. Your rights + +Under the GDPR you have the right to access your data, to rectification, to +erasure, to restriction of processing, to data portability, and to object to +processing based on legitimate interests, including the analytics described in +section 3. Where processing is based on consent, you can withdraw it at any time +without affecting processing carried out before withdrawal. To exercise any of +these rights, contact us at support@flow-php.com. + +You also have the right to lodge a complaint with the Polish supervisory +authority, the President of the Personal Data Protection Office (Prezes Urzędu +Ochrony Danych Osobowych, PUODO). + +## 10. Sponsors list + +If you opt in at checkout, we display the GitHub profile you choose on a public +sponsors list. This is entirely optional and based on your consent. You can ask +us to remove your listing at any time by contacting support@flow-php.com, and we +will remove it. Withdrawing consent is as easy as giving it. + +## 11. Cookies and local storage + +We set no cookies on the shop pages. Our analytics sets none either, and there is +no advertising or cross-site tracking. That is why you see no cookie banner. + +The only things kept in your browser are there because you used a feature that +needs them: + +- `theme` in local storage. Remembers whether you chose light or dark mode. It + never leaves your browser. + +One third party sets its own storage, and only when you act: + +- Polar's checkout, when you click to buy, under Polar's privacy policy. + +You can block or delete any of this through your browser settings without losing +any feature of the shop. + +## 12. Changes to this policy + +We may update this policy. The current version is always available at this +address, and material changes are reflected by updating the date above. + +## 13. Contact + +For any privacy question or request, contact us at support@flow-php.com. +Our Terms of Sale are available at [Terms of Sale](/work-shop/terms-of-sales). diff --git a/web/landing/content/shop/terms-of-sales.md b/web/landing/content/shop/terms-of-sales.md new file mode 100644 index 0000000000..9ee04d8c30 --- /dev/null +++ b/web/landing/content/shop/terms-of-sales.md @@ -0,0 +1,178 @@ +# Flow PHP Work-Shop - Terms of Sale and Use + +Last updated: 31 July 2026 + +## 1. Who we are + +These terms govern the purchase and use of the digital content sold through the +Flow PHP Work-Shop (the "Products"), provided by: + +Norbert Orzechowicz, sole proprietorship registered in Poland, +NIP 6852282788 (EU VAT ID PL6852282788), +Gołyszyn 100, 32-046 Skała, Poland, +contact: support@flow-php.com +(the "Provider", "we", "us"). + +The Products are separate, paid products. Flow PHP, the open source data +processing framework, is provided separately under its own open source license +and is not governed by these terms. + +## 2. Provider and Merchant of Record + +Payments for the Products are processed by Polar (Polar Software, Inc.) acting as +Merchant of Record. This means Polar is the seller of record for the transaction: +Polar handles checkout, payment, invoicing to you, and the calculation and +remittance of applicable taxes. Polar's own terms apply to the payment +transaction. + +These terms cover the Provider's part of the relationship: the nature of the +Product, the license to use it, support for defects, and related matters. + +## 3. What you are buying + +A Product is digital content delivered electronically and not supplied on a +tangible medium. A purchase is a one-time payment, not a subscription, unless the +Product is explicitly offered on a recurring basis and described as such at +checkout. + +What you receive, and how it reaches you, depends on the Product and is described +on its listing. + +We intend to keep that access available indefinitely and will not withdraw it +while you comply with the license. We cannot promise it forever, because delivery +depends on services we do not control. If we ever have to retire a Product, we +will give you reasonable notice and time to take your own copy, which you may +keep and continue to use under the license. + +Each Product's listing describes what it is, and what it is not. A Product may be +deliberately limited in scope: for example, it may demonstrate a technique +without being a complete, production-ready application, or ship without parts you +would need before deploying it. Where a listing describes limits like these, they +are part of what is offered and are not shortcomings. + +## 4. Eligibility and requirements + +Where a Product is delivered through GitHub, you need a GitHub account. In all +cases you need a working email address. + +Checkout does not ask for your GitHub account. Access is tied to the **email +address you use at checkout**, because that address is how you sign in to the +Polar customer portal, and the portal is the only place access can be granted. +Polar signs you in by sending a one-time code to that address; there is no +password. It is therefore your responsibility to enter an email address you can +actually receive mail at. If you mistype it, contact support@flow-php.com and we +will help you sort it out with Polar. + +## 5. Delivery and access + +Access is not granted automatically, and we do not grant it on your behalf. + +After a successful purchase you sign in to the Polar customer portal with the +email you bought with and open your order. Your order is where access is granted +and, if you bought more than one seat, where you assign the remaining seats to +other people. + +Where a Product is delivered through GitHub, that means connecting your GitHub +account from the order and granting repository access. GitHub then emails an +invitation, which has to be accepted before the repository can be cloned. Other +Products may be delivered differently, in which case the listing and your order +say how. + +Delivery is considered complete once the order is available in the customer +portal and access can be granted from it. If any step of this fails, contact +support@flow-php.com and we will help. + +## 6. Price, payment, taxes, invoices + +Prices are shown at checkout. Payment, invoicing, and applicable taxes (such as +VAT or sales tax) are handled by Polar as Merchant of Record. Your invoice or +receipt is issued by Polar. Any questions about the invoice or payment may be +directed to Polar through their support, or to us at support@flow-php.com and we +will help route it. + +Prices shown on our website are indicative and exclude tax. The currency and the +final amount, including any tax, are determined at checkout by Polar. + +## 7. License to use the Products + +Each Product is proprietary and is not open source. The license that applies is +the one stated for that Product on its listing, and where the Product includes a +LICENSE file, that file is authoritative. + +In summary, and subject to the license that applies to your Product: + +- You may use, run, and modify the content for your own internal purposes, + including studying it and adapting patterns within your own internal projects. +- You may not redistribute, publish, resell, sublicense, or otherwise share the + content, or access to it, with anyone who has not purchased a license. + +Patterns, techniques, and architecture are ideas, not protected expression, and +you remain free to learn from them and build your own implementations. + +All intellectual property rights in the Products remain with the Provider. A +serious or repeated breach of the license may result in termination of your +access, without prejudice to any other remedies. + +## 8. Right of withdrawal and digital content + +The Products are digital content supplied immediately after purchase. + +Because Polar acts as Merchant of Record, the sales contract for your purchase is +concluded with Polar, and the statutory right of withdrawal in respect of that +contract is governed by Polar's terms. At checkout you are asked to expressly +request immediate access and to acknowledge that you thereby lose the right of +withdrawal once access begins. Where that consent is given, the 14-day right of +withdrawal does not apply once delivery has started, consistent with art. 38 of +the Polish Act on Consumer Rights and the equivalent provisions of the EU +Consumer Rights Directive. + +This affects only withdrawal (changing your mind). It does not limit your rights +where the Product is faulty or not as described. See section 9. + +## 9. Defects and complaints + +If a Product does not work as described or is not in conformity with what was +offered, you have rights under applicable law, which these terms do not exclude +or limit. Please contact us at support@flow-php.com with a description of the +issue. We aim to respond to complaints within 14 days. + +Note that the limits described on a Product's listing, as referred to in section +3, form part of what is offered and are not defects. + +## 10. Disclaimers and limitation of liability + +The Products are provided "as is" and "as available", without warranties of any +kind except those that cannot be excluded under applicable law. + +To the maximum extent permitted by law, the Provider is not liable for any +indirect or consequential loss, or for any damage resulting from deploying or +relying on a Product in a production setting. Nothing in these terms limits +liability that cannot be limited under applicable law, including mandatory +consumer protections. + +## 11. Consultations + +Paid consultations are a separate service, arranged directly through the booking +link or by email, and are not sold or governed under these terms. Separate +arrangements and invoicing apply. + +## 12. Personal data + +We process personal data needed to deliver access and provide support (such as +your email and GitHub account). Polar processes data needed for payment as +Merchant of Record. For details on how we handle personal data, see our +[Privacy Policy](/work-shop/privacy-policy). Where we list purchasers as project +sponsors, we do so only for those who explicitly opt in, and only the GitHub +profile they choose to display. + +## 13. Changes to these terms + +We may update these terms. The version in force at the time of your purchase +applies to that purchase. Material changes will be reflected by updating the date +above. + +## 14. Governing law and contact + +These terms are governed by Polish law, without prejudice to Polar's terms for +the payment transaction and to any mandatory consumer protections available to +you. Contact: support@flow-php.com. diff --git a/web/landing/importmap.php b/web/landing/importmap.php index 8a4888a1cb..548b979390 100644 --- a/web/landing/importmap.php +++ b/web/landing/importmap.php @@ -167,4 +167,7 @@ '@lezer/xml' => [ 'version' => '1.0.6', ], + '@polar-sh/checkout/embed' => [ + 'version' => '0.3.0', + ], ]; diff --git a/web/landing/src/Flow/Website/Controller/ShopController.php b/web/landing/src/Flow/Website/Controller/ShopController.php new file mode 100644 index 0000000000..c3290f5958 --- /dev/null +++ b/web/landing/src/Flow/Website/Controller/ShopController.php @@ -0,0 +1,76 @@ + $checkoutLinks + */ + public function __construct( + private readonly array $checkoutLinks, + ) {} + + #[Route('/work-shop', name: 'shop', options: ['sitemap' => false])] + public function index(): Response + { + return $this->render('shop/index.html.twig'); + } + + #[Route('/work-shop/blueprints/symfony-backoffice', name: 'shop_blueprint_symfony', options: ['sitemap' => false])] + public function blueprintSymfony(): Response + { + return $this->render('shop/blueprints/symfony-backoffice/index.html.twig', [ + 'listing_checkout_url' => $this->checkoutLinks['symfony_backoffice'] ?? null, + ]); + } + + #[Route('/work-shop/blueprints/how-it-works', name: 'shop_blueprint_how_it_works', options: ['sitemap' => false])] + public function blueprintHowItWorks(): Response + { + return $this->render('shop/blueprints/how-it-works/index.html.twig', [ + 'listing_checkout_url' => $this->checkoutLinks['symfony_backoffice'] ?? null, + ]); + } + + #[Route('/work-shop/success', name: 'shop_success', options: ['sitemap' => false])] + public function success(): Response + { + return $this->render('shop/success/index.html.twig'); + } + + #[Route('/work-shop/consulting', name: 'shop_consulting', options: ['sitemap' => false])] + public function consulting(): Response + { + return $this->render('shop/consulting/index.html.twig'); + } + + #[Route('/work-shop/terms-of-sales', name: 'shop_terms_of_sales', options: ['sitemap' => false])] + public function termsOfSales(): Response + { + return $this->render('shop/terms-of-sales/index.html.twig', [ + 'terms_markdown' => file_get_contents( + type_string()->assert($this->getParameter('kernel.project_dir')) . '/content/shop/terms-of-sales.md', + ), + ]); + } + + #[Route('/work-shop/privacy-policy', name: 'shop_privacy_policy', options: ['sitemap' => false])] + public function privacyPolicy(): Response + { + return $this->render('shop/privacy-policy/index.html.twig', [ + 'privacy_markdown' => file_get_contents( + type_string()->assert($this->getParameter('kernel.project_dir')) . '/content/shop/privacy-policy.md', + ), + ]); + } +} diff --git a/web/landing/templates/base.html.twig b/web/landing/templates/base.html.twig index 937c7681b5..22daa47aea 100644 --- a/web/landing/templates/base.html.twig +++ b/web/landing/templates/base.html.twig @@ -62,35 +62,11 @@ posthog.init('{{ posthog_key }}', { api_host: '{{ posthog_api_host }}', ui_host: '{{ posthog_ui_host }}', - defaults: '2026-01-30' + defaults: '2026-01-30', + cookieless_mode: 'always' }) {% endif %} - {% if datadog_application_id|length %} - - {% endif %} {% endblock %} {% block javascripts %} diff --git a/web/landing/templates/main/sponsor.html.twig b/web/landing/templates/main/sponsor.html.twig index 9e7c70d856..809618e90a 100644 --- a/web/landing/templates/main/sponsor.html.twig +++ b/web/landing/templates/main/sponsor.html.twig @@ -54,7 +54,7 @@ Hire me for consulting, workshops, or implementation support. Engagements of 20+ hours include a sponsor logo on this page.

- Learn more @@ -75,7 +75,8 @@ {% set sponsors = [ - {url: 'https://blacksmith.sh/', name: 'Blacksmith', slug: 'blacksmith', size: 'h-8'}, + {url: 'https://www.anthropic.com/', name: 'Anthropic', slug: 'anthropic', size: 'h-8'}, + {url: 'https://blacksmith.sh/', name: 'Blacksmith', slug: 'blacksmith', size: 'h-8'}, {url: 'https://www.datadoghq.com/', name: 'Datadog', slug: 'datadog', size: 'h-24'}, {url: 'https://www.digitalocean.com/?refcode=0313c3031eb9&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge', name: 'DigitalOcean', slug: 'digitalocean', size: 'h-24'}, {url: 'https://blackfire.io/', name: 'Blackfire', slug: 'blackfire', size: 'h-16'}, diff --git a/web/landing/templates/shop/_listing.html.twig b/web/landing/templates/shop/_listing.html.twig new file mode 100644 index 0000000000..535ea68cd1 --- /dev/null +++ b/web/landing/templates/shop/_listing.html.twig @@ -0,0 +1,149 @@ +{% extends 'base.html.twig' %} + +{# + Base listing template. Each product page extends this and provides metadata via + `{% set %}` plus the `listing_body` block: + listing_name (string) — product title + listing_price (string) — formatted price, e.g. "$19.99"; omit to hide the price + listing_tagline (string) — short one-line summary under the title + listing_eyebrow (string) — small label above the title (default "Shop") + listing_available (bool) — false renders a disabled "Coming soon" button + listing_cta_label (string) — CTA button text (default "Buy now") + listing_cta_url (string) — CTA target (default "#", checkout is out of scope) + listing_checkout_url (string) — Polar checkout link; when set the CTA opens the + embedded checkout and falls back to this URL without JS + listing_image_aspect (string) — aspect-ratio for the image/carousel frame (default "16 / 9"). + Inline style rather than a Tailwind class because the value is + per-listing and Tailwind only emits classes it finds literally. +#} + +{%- block title -%}{{ listing_name|default('Shop') }} - Flow PHP - Data Processing Framework{%- endblock -%} + +{%- block description -%}{{ listing_tagline|default('The Flow PHP Work-Shop. Blueprints and consulting for teams building with Flow.') }}{%- endblock -%} + +{%- block hero -%}{%- endblock -%} + +{% block main %} + {# `??` (not the `default` filter) so an explicit `false` is respected — `false|default(true)` returns true #} + {% set listing_available = listing_available ?? true %} + {% set listing_cta_url = listing_cta_url ?? '#' %} + {% set listing_cta_label = listing_cta_label ?? 'Buy now' %} + {% set listing_checkout_url = listing_checkout_url ?? null %} + {% set listing_image = listing_image ?? null %} + {% set listing_images = listing_images ?? [] %} + {% set listing_image_aspect = listing_image_aspect ?? '16 / 9' %} +
+ + Back to the Work-Shop + + +
+
+ {% if listing_images|length %} +
+
+ {% for image in listing_images %} +
+ {{ listing_name }} screenshot {{ loop.index }} +
+ {% endfor %} +
+ + {% if listing_images|length > 1 %} + + +
+ {% for image in listing_images %} + + {% endfor %} +
+ {% endif %} +
+ {% elseif listing_image %} + {{ listing_name }} + {% endif %} + + + + {{ listing_eyebrow|default('Shop') }} + +

{{ listing_name }}

+ {% if listing_tagline|default(false) %} +

{{ listing_tagline }}

+ {% endif %} + +
+ {% block listing_body %}{% endblock %} +
+
+ + +
+
+{% endblock %} diff --git a/web/landing/templates/shop/blueprints/how-it-works/index.html.twig b/web/landing/templates/shop/blueprints/how-it-works/index.html.twig new file mode 100644 index 0000000000..db342e94d5 --- /dev/null +++ b/web/landing/templates/shop/blueprints/how-it-works/index.html.twig @@ -0,0 +1,228 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + How it works - Blueprints - Flow PHP - Data Processing Framework +{%- endblock -%} + +{%- block description -%} + What happens when you buy a Blueprint: how access is granted, why your email matters, and how seats work for teams. +{%- endblock -%} + +{%- block hero -%}{%- endblock -%} + +{% block main %} + {% set surprises = [ + { + title: 'Checkout never asks for your GitHub account', + body: 'You do not enter a username anywhere during payment. GitHub is connected afterwards, by you, from your order.' + }, + { + title: 'Access is not sent automatically', + body: 'Nothing lands in your inbox with the repository attached. You grant access to yourself, and it takes about a minute.' + }, + { + title: 'Your email address is the only key', + body: 'It is how you get into the customer portal, and the portal is the only place access can be granted. A typo locks you out of it.' + } + ] %} + + {% set steps = [ + { + title: 'Pay', + body: 'Checkout is handled by Polar, who are the seller of record. Card details never touch our systems. Choose how many seats you need before paying, and enter an email address you can actually read.' + }, + { + title: 'Open the customer portal', + body: 'Go to the Polar customer portal and sign in with the email you bought with. There is no password: Polar emails a one-time code to that address. Then open your order.' + }, + { + title: 'Connect GitHub and grant access', + body: 'From the order, connect your GitHub account and grant it repository access. If you bought several seats, you assign the rest to your colleagues from the same screen.' + }, + { + title: 'Accept the invitation', + body: 'GitHub emails an invitation to each person, and it also shows up in their GitHub notifications. It has to be accepted before the repository can be cloned.' + } + ] %} + +
+ + + + Back to the Blueprint + + +
+ + + Before you buy + +

How it works

+

+ Buying a Blueprint gets you access to a private GitHub repository, but the way that access + reaches you surprises people. Here is the whole process, written down before you pay rather + than after. +

+
+ +
+ {% for surprise in surprises %} +
+

{{ surprise.title }}

+

{{ surprise.body }}

+
+ {% endfor %} +
+
+ +
+
+

The four steps

+

+ From paying to cloning. Steps two and three are the ones people do not expect. +

+
+ +
    + {% for step in steps %} +
  1. + + {{ loop.index }} + +

    {{ step.title }}

    +

    {{ step.body }}

    +
  2. + {% endfor %} +
+ +

+ The portal lives at + polar.sh/flow-php/portal. + You will not need it until after you have paid. +

+
+ +
+
+
+

Buying for a team

+
+

+ One seat covers one developer. If four people will read the code, that is four seats, + and you choose the quantity at checkout before paying rather than buying four times. +

+

+ Whoever pays does not have to be one of the developers. + A team lead, an office manager or finance can buy the seats and hand them out afterwards. + The person who paid owns the order, signs in to the portal, and assigns each seat to a + colleague's GitHub account. +

+

+ Every person still accepts their own GitHub invitation. Sharing one account between + several developers is not a way around buying seats, and it is not allowed by the licence. +

+
+
+ +
+

Payment, tax and invoices

+
+

+ Polar acts as Merchant of Record, which means they are the seller for the transaction. + They handle the payment, the tax and the invoice, and the invoice comes from them. +

+

+ Prices on the site exclude tax and are shown in US dollars. Your currency and the final + amount are worked out at checkout based on where you are, so what you pay may be shown + in a different currency than what you browsed. +

+

+ Buying from an EU business? Enter your VAT ID at checkout + and the reverse charge applies, so no VAT is added. +

+

+ It is a one-time payment. There is no subscription and nothing renews. +

+
+
+
+
+ +
+
+

Worth knowing before you pay

+
+ +
+
+

What you can do with the code

+

+ Read it, run it, change it, and use it inside your own projects, including at work. What you + cannot do is republish it, resell it, or pass it to someone who has not bought a licence. + The patterns themselves are just ideas, and you are free to rebuild them however you like. +

+
+ +
+

It is a Blueprint, not a product

+

+ It is deliberately incomplete. There is no authentication or authorization layer, and the + front end is plain on purpose. It is built to be read and pulled apart, not deployed as-is. + That is the offer, not a defect. +

+
+ +
+

Updates

+

+ You keep access to the repository as it evolves, so improvements and fixes show up with a + git pull. There is nothing extra to pay for them. +

+
+ +
+

What you need

+

+ A GitHub account, an email address you can read, and either Docker or nix to run the thing + locally. The README walks through the setup once you are in. +

+
+ +
+

If something goes wrong

+

+ Mistyped your email, invitation never arrived, or the portal is not showing your order? + Email support@flow-php.com + and we will sort it out with Polar. +

+
+
+
+ +
+
+

+ That is the whole process +

+

+ No surprises left. If anything above is still unclear, ask before you pay rather than after. +

+ +

+ The full terms are in our + Terms of Sale + and + Privacy Policy. +

+
+
+{% endblock %} diff --git a/web/landing/templates/shop/blueprints/symfony-backoffice/index.html.twig b/web/landing/templates/shop/blueprints/symfony-backoffice/index.html.twig new file mode 100644 index 0000000000..0a69cbd0f0 --- /dev/null +++ b/web/landing/templates/shop/blueprints/symfony-backoffice/index.html.twig @@ -0,0 +1,167 @@ +{% extends 'shop/_listing.html.twig' %} + +{% set listing_name = 'Symfony Backoffice Blueprint' %} +{% set listing_eyebrow = 'Blueprint' %} +{% set listing_price = '$59' %} +{% set listing_available = true %} +{# Matches the shipped screenshot dimensions, so nothing is cropped. #} +{% set listing_image_aspect = '1600 / 692' %} +{% set listing_images = [ + asset('images/shop/symfony-backoffice-app.png'), + asset('images/shop/symfony-backoffice-import.png'), + asset('images/shop/symfony-backoffice-api.png'), + asset('images/shop/symfony-backoffice-profiler.png'), + asset('images/shop/symfony-backoffice-telemetry.png'), +] %} +{% set listing_tagline = 'A working Symfony back office built on Flow PHP. Read it, run it, take the patterns to your own project.' %} + +{% block listing_body %} +

+ This is a real Symfony 7.4 application, not a tutorial repository. It manages products, categories, + customers, orders, reviews, warehouses and stock for an imaginary shop, and it uses that boring + domain as an excuse to show how the hard parts fit together. +

+

+ Three things it focuses on. +

+ +

One schema, four jobs

+

+ Every model has a schema() method that returns a Flow Type. Nothing else + describes the shape of that model. Decorated with a bit of metadata, that one definition is what + generates: +

+ +

+ Add a column in one place and the migration, the API contract, the import format and the validation + all follow. There is no second definition to keep in sync. +

+ +

Data processing that streams

+

+ Imports run through a Flow DataFrame. You upload a CSV, it lands in S3 compatible storage, a Messenger + job picks it up and runs the pipeline inside a transaction with batched upserts. Row counts and errors + are written back to an imports table you can watch from the UI. +

+

+ Exports and the API stream properly. /export/products.csv, .json and + .xml go straight from a server side PostgreSQL cursor to the response without building an + array in memory, and so does /api/{resource}/stream. The paginated endpoints use keyset + pagination with opaque cursors, not OFFSET. +

+ +

PostgreSQL, typed properly

+

+ There is no ORM, and there is not a single SQL string in src. Every query is built + programmatically with the flow-php/postgresql query builder, from a plain + count(*) up to the keyset pagination that compares a row constructor against a bound + cursor. Columns, joins, predicates and ordering are typed expressions, so renaming a column is a + static analysis error instead of a runtime one, and there is nowhere for a concatenated string to + sneak in. +

+

+ Results come back through the model schema, so what you get out of fetchAllInto() is a + typed object and not an array of mixed. Static analysis can follow your data from the + query all the way to the template. +

+

+ Migrations are the one place that holds plain SQL, and deliberately so. A migration is something you + have to be able to open and change by hand, whether that is a backfill, an index built concurrently or + a one-off data fix, so it stays as system queries you control. + flow:migrations:diff writes the first draft for you by comparing the database to the + catalog, and from there the file is yours. +

+

+ One PostgreSQL instance runs everything. Data, cache pools, sessions, the Messenger transport and + migrations. No Redis, no RabbitMQ. +

+

+ Static analysis is Mago on PHP 8.5. There are seven suppressions in the entire src tree. +

+ +

Telemetry from the browser to the query

+

+ Most PHP applications stop at Monolog. This one implements OpenTelemetry end to end. Spans are emitted + for the HTTP kernel, the controller and its argument resolvers, every SQL query, every Twig template, + cache operations, the HTTP client, Messenger consumers and console commands. +

+

+ The browser is instrumented too. The server renders its trace context into a meta tag, so the + documentLoad span from JavaScript ends up in the same trace as the SQL query that rendered + the page. One waterfall, front to back. +

+

+ None of which is much use while you are still writing the feature, so it is wired into the Symfony + Profiler as well. Every request gets a Flow Telemetry panel with the span count, the total trace time, + a timeline of that request, and the full list of spans with their kind, scope, duration and attributes. + The metrics and logs the request emitted are on the same page. You inspect signals in the toolbar while + you work, and the same signals go to the collector when you are done. +

+

+ If you have ever tried to wire OTEL into Symfony by hand, the configuration is the part to read first. + Named loggers, meters and tracers with their own scope attributes, a log processing pipeline with + severity and attribute filtering, batching, and a failover exporter that writes to disk when the + collector is unreachable. It all ships to an OTEL Collector and lands in OpenObserve, which comes up + with the rest of the stack. +

+ +

What's in the repository

+ + +

What it is not

+

+ It is a blueprint, not a product. There is no authentication or authorization layer, the HTML is + deliberately plain because the front end is not what this teaches, and you should not deploy it as-is. + It exists so you can read it, run it, pull it apart, and copy what works into your own codebase. +

+{% endblock %} + +{% block listing_aside %} + + How it works before you buy + + +

+ One-time payment, no subscription. One seat per developer, and sharing a licence is not allowed. +

+

+ Buying for a team? Choose the number of seats at checkout. Whoever pays does not have to be one of the + developers, so a team lead or finance can buy and hand the seats out afterwards. +

+

+ Use an email you can actually read at checkout. It is how you get into the Polar customer portal + afterwards, and there is no password, Polar sends a one-time code to that address. +

+

+ Checkout never asks for a GitHub account. You open the order in that portal, connect GitHub there and + grant access seat by seat. GitHub then emails each person an invitation to accept. +

+{% endblock %} diff --git a/web/landing/templates/shop/consulting/index.html.twig b/web/landing/templates/shop/consulting/index.html.twig new file mode 100644 index 0000000000..55387fb7ba --- /dev/null +++ b/web/landing/templates/shop/consulting/index.html.twig @@ -0,0 +1,86 @@ +{% extends 'shop/_listing.html.twig' %} + +{% set listing_name = 'Consulting' %} +{% set listing_eyebrow = 'Consulting' %} +{% set listing_available = true %} +{% set listing_images = [ + asset('images/shop/consulting.jpg'), + asset('images/shop/consulting-2.jpg'), +] %} +{% set listing_cta_label = 'Book a free call' %} +{% set listing_cta_url = 'https://calendar.proton.me/bookings#EyjcEe07NRPoD6ECOGt-Qd--PRYh7XmulLqtf5l2zKE=' %} +{% set listing_tagline = 'Sixteen years of building data systems, architecture and observability. Bring the problem, we work out what it actually needs.' %} + +{% block listing_body %} +

+ I have been building software for over sixteen years, from scrappy startups to large enterprises, + mostly the kind of work where getting the architecture wrong costs you months rather than days. +

+

+ One project shaped a lot of how I think about this. I designed and built a logistics platform for one of + the largest Amazon FBM sellers in the US, running orders worth over $250M a year. At that scale + collecting, processing and exposing data is not a feature you add later, it is the backbone the business + stands on. +

+ +

What I help with

+ + +

How that works in practice

+ + +

Why there is no price here

+

+ Because there is no standard engagement. A two day workshop and a six month fractional architecture + arrangement are not the same thing, and pricing them as though they were would be dishonest to one of + us. Every engagement is quoted individually, based on the complexity and size of the problem and how + much of it you want me carrying. +

+

+ The first call is free and costs you half an hour. That is usually enough to work out whether I am the + right person for this at all, and if I am not, I will say so. +

+ +

Words are cheap, here is the code

+

+ I maintain Flow PHP, close to fifty + packages covering DataFrames, Parquet, a PostgreSQL toolkit with a real query builder and SQL parser, + and an OpenTelemetry implementation. It is all open source, and it is a better description of how I + think about software than anything I could write on a sales page. +

+{% endblock %} + +{% block listing_aside %} +

+ Priced per engagement, based on the complexity and size of the problem. The first thirty minutes are + free and there is nothing to sign. +

+

+ Available in English and Polish, remote or on-site. Prefer email? Write to + norbert@flow-php.com. +

+{% endblock %} diff --git a/web/landing/templates/shop/index.html.twig b/web/landing/templates/shop/index.html.twig new file mode 100644 index 0000000000..753eaba6ad --- /dev/null +++ b/web/landing/templates/shop/index.html.twig @@ -0,0 +1,156 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + Work-Shop - Flow PHP - Data Processing Framework +{%- endblock -%} + +{%- block description -%} + The Flow PHP Work-Shop. Blueprints and consulting to help you build with Flow. +{%- endblock -%} + +{%- block hero -%}{%- endblock -%} + +{% block main %} + {% set categories = [ + { + id: 'blueprints', + heading: 'Blueprints', + subtitle: 'Working applications you can clone, run and pull apart. Read how the pieces fit together, then build your own on top.', + listings: [ + { + name: 'Symfony Backoffice Blueprint', + price: '$59', + href: path('shop_blueprint_symfony'), + image: asset('images/shop/symfony-backoffice-app.png'), + blurb: 'A working Symfony back office built on Flow PHP. One schema per model drives the database, the API and the imports, and every request is traced from the browser down to the SQL query.', + features: ['Schema drives the database, API and imports', 'Streaming CSV, JSON and XML exports', 'OpenTelemetry across browser and backend'], + available: true + }, + { + name: 'More Blueprints', + blurb: 'The Symfony back office is the first one. More are being built, same standards, different frameworks and different problems.', + features: ['Fully tested and statically analysed', 'Something you want to see? Let us know'], + available: false + } + ] + }, + { + id: 'consulting', + heading: 'Consulting', + subtitle: 'Data engineering, architecture and observability work, from a two day workshop to a fractional architect on your team.', + listings: [ + { + name: 'Consulting', + href: path('shop_consulting'), + image: asset('images/shop/consulting.jpg'), + blurb: 'Sixteen years of data engineering, software architecture and observability work, from pipelines that hold up to APM bills that come down.', + features: ['Data engineering, architecture, observability', 'Workshops, fractional architect, or hands-on build', 'Quoted per engagement, first call free'], + available: true + } + ] + } + ] %} + +
+ + +
+ + + Work-Shop + +

Welcome to the Work-Shop.

+

+ Go further than the docs. Blueprints + are starter templates that show how the pieces fit together, and + Consulting + puts the maintainer on your problem. +

+
+ + + + {% for category in categories %} +
+

{{ category.heading }}

+

{{ category.subtitle }}

+ +
+ {% for listing in category.listings %} + {# Listings without a href are placeholders, so they render as plain cards. #} + {% set tag = listing.href is defined ? 'a' : 'div' %} + <{{ tag }} {% if listing.href is defined %}href="{{ listing.href }}"{% endif %} + class="card group flex flex-col no-underline hover:no-underline + {{ listing.href is defined + ? 'card-hover transition duration-200 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/5 dark:hover:shadow-black/40' + : 'border-dashed bg-transparent opacity-60 saturate-0 dark:bg-transparent' }}"> +
+ {% if listing.image is defined %} + {{ listing.name }} + {% else %} +
+ In the works +
+ {% endif %} +
+
+

{{ listing.name }}

+ {% if listing.available %} + + Available + + {% else %} + + Coming soon + + {% endif %} +
+ +

{{ listing.blurb }}

+ +
    + {% for feature in listing.features %} +
  • + + {{ feature }} +
  • + {% endfor %} +
+ +
+ {% if listing.price is defined %} + + {{ listing.price }} + excl. tax + + {% endif %} + {% if listing.href is defined %} + + View details + + {% endif %} +
+ + {% endfor %} +
+
+ {% endfor %} +
+{% endblock %} diff --git a/web/landing/templates/shop/privacy-policy/index.html.twig b/web/landing/templates/shop/privacy-policy/index.html.twig new file mode 100644 index 0000000000..d5ab79e169 --- /dev/null +++ b/web/landing/templates/shop/privacy-policy/index.html.twig @@ -0,0 +1,27 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + Privacy Policy - Flow PHP - Data Processing Framework +{%- endblock -%} + +{%- block description -%} + Privacy Policy for the Flow PHP Work-Shop, covering how we handle your data and Polar as Merchant of Record. +{%- endblock -%} + +{%- block hero -%}{%- endblock -%} + +{% block main %} +
+ + Back to the Work-Shop + + + + Legal + +
+ +{% endblock %} diff --git a/web/landing/templates/shop/success/index.html.twig b/web/landing/templates/shop/success/index.html.twig new file mode 100644 index 0000000000..7031ebf606 --- /dev/null +++ b/web/landing/templates/shop/success/index.html.twig @@ -0,0 +1,171 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + Thank you - Flow PHP - Data Processing Framework +{%- endblock -%} + +{%- block description -%} + Your Flow PHP Work-Shop order is complete, and it helps keep Flow PHP going. +{%- endblock -%} + +{%- block hero -%}{%- endblock -%} + +{% block main %} + {% set steps = [ + { + title: 'Your receipt', + body: 'Polar handles the payment as Merchant of Record and emails your receipt. Questions about the invoice, VAT or the payment itself go to them first, and we will help route it if you get stuck.' + }, + { + title: 'Open your order', + body: 'Access is not sent automatically. Go to the Polar customer portal and sign in with the email you bought with. There is no password, Polar sends a one-time code to that address. Then open this order.' + }, + { + title: 'Grant yourself access', + body: 'On the order, connect your GitHub account and grant it repository access. If you bought more than one seat, you assign the rest to your colleagues from the same place.' + }, + { + title: 'Accept and clone', + body: 'GitHub then emails you an invitation, which also appears in your notifications. Accept it, then start at the README. It points at the documentation and the Docker or nix setup.' + } + ] %} + +
+
+ + + Order complete + +

+ +

+ +
+

+ Flow PHP is open source and it stays that way. Close to fifty standalone packages, no + company behind them, and far more maintenance work than one person can fund on their own + time. +

+

+ What you just bought goes straight into that. Not into a funding round or a growth target, + into the hours it takes to keep the framework tested, documented and moving. You did not + have to do this, and it matters more than a checkout page can really say. +

+
+ +
+

+ Access is not sent automatically +

+

+ You grant it to yourself from your order in the Polar customer portal. It takes a minute, + and the button below goes straight there. +

+
+ + +
+ +
+

What happens next

+ +
    + {% for step in steps %} +
  1. + + {{ loop.index }} + +

    {{ step.title }}

    +

    {{ step.body }}

    +
  2. + {% endfor %} +
+
+ + + +
+
+

+ If you want to help even more +

+

+ You have already done the hard part. Beyond that, the things that genuinely move the needle + for an open source project are free. +

+
+ + +
+ +

+ Invitation not arrived, or something else wrong? Email + support@flow-php.com + and we will sort it out. +

+
+{% endblock %} diff --git a/web/landing/templates/shop/terms-of-sales/index.html.twig b/web/landing/templates/shop/terms-of-sales/index.html.twig new file mode 100644 index 0000000000..1071e78921 --- /dev/null +++ b/web/landing/templates/shop/terms-of-sales/index.html.twig @@ -0,0 +1,27 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + Terms of Sale - Flow PHP - Data Processing Framework +{%- endblock -%} + +{%- block description -%} + Terms of Sale and Use for the Flow PHP Work-Shop, sold through Polar as Merchant of Record. +{%- endblock -%} + +{%- block hero -%}{%- endblock -%} + +{% block main %} +
+ + Back to the Work-Shop + + + + Legal + +
+ +{% endblock %} diff --git a/web/landing/tests/Flow/Website/Tests/Functional/ShopControllerTest.php b/web/landing/tests/Flow/Website/Tests/Functional/ShopControllerTest.php new file mode 100644 index 0000000000..ab75eb9599 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Functional/ShopControllerTest.php @@ -0,0 +1,224 @@ +request('GET', '/work-shop/consulting'); + + self::assertResponseIsSuccessful(); + + $text = $crawler->filter('main')->text(); + static::assertStringContainsString('Book a free call', $text); + // Consulting is quoted per engagement, so the page must explain the missing price. + static::assertStringContainsString('Why there is no price here', $text); + static::assertGreaterThan(0, $crawler->filter('main a[href^="https://calendar.proton.me/"]')->count()); + static::assertGreaterThan(0, $crawler->filter('main a[href^="mailto:"]')->count()); + static::assertSame(1, $crawler->filter('[data-controller~="shop-carousel"]')->count()); + static::assertGreaterThan(1, $crawler->filter('[data-shop-carousel-target="slide"]')->count()); + } + + public function test_legal_links_appear_only_where_something_is_sold(): void + { + $client = self::createClient(); + + $blueprint = $client->request('GET', '/work-shop/blueprints/symfony-backoffice'); + self::assertResponseIsSuccessful(); + static::assertSame(1, $blueprint->filter('main a[href="/work-shop/terms-of-sales"]')->count()); + static::assertSame(1, $blueprint->filter('main a[href="/work-shop/privacy-policy"]')->count()); + + // Consultations are arranged separately and are not governed by the Terms of Sale. + $consulting = $client->request('GET', '/work-shop/consulting'); + self::assertResponseIsSuccessful(); + static::assertSame(0, $consulting->filter('main a[href="/work-shop/terms-of-sales"]')->count()); + } + + public function test_legal_pages_are_scoped_to_the_shop(): void + { + $client = self::createClient(); + + $crawler = $client->request('GET', '/work-shop/privacy-policy'); + self::assertResponseIsSuccessful(); + + $text = $crawler->filter('main')->text(); + + foreach (['Playground', 'Turnstile', 'Cloudflare'] as $unrelated) { + static::assertStringNotContainsString($unrelated, $text, $unrelated); + } + } + + public function test_how_it_works_explains_what_surprises_buyers(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/work-shop/blueprints/how-it-works'); + + self::assertResponseIsSuccessful(); + + $text = $crawler->filter('main')->text(); + // The three things buyers get wrong, all of which have to appear before the CTA. + static::assertStringContainsString('Checkout never asks for your GitHub account', $text); + static::assertStringContainsString('Access is not sent automatically', $text); + static::assertStringContainsString('Your email address is the only key', $text); + static::assertStringContainsString('Merchant of Record', $text); + static::assertStringContainsString('VAT ID', $text); + static::assertSame(4, $crawler->filter('main ol li')->count()); + static::assertGreaterThan(0, $crawler->filter('main a[href="/work-shop/terms-of-sales"]')->count()); + static::assertGreaterThan(0, $crawler->filter('main a[href="/work-shop/privacy-policy"]')->count()); + } + + public function test_blueprint_listing_links_to_how_it_works(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/work-shop/blueprints/symfony-backoffice'); + + self::assertResponseIsSuccessful(); + static::assertSame(1, $crawler->filter('main a[href="/work-shop/blueprints/how-it-works"]')->count()); + } + + public function test_header_does_not_link_to_shop(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/'); + + self::assertResponseIsSuccessful(); + static::assertSame(0, $crawler->filter('header a[href^="/work-shop"]')->count()); + } + + public function test_shop_is_excluded_from_the_sitemap(): void + { + $client = self::createClient(); + $client->request('GET', '/sitemap.default.xml'); + + self::assertResponseIsSuccessful(); + static::assertStringNotContainsString('/work-shop', (string) $client->getResponse()->getContent()); + } + + public function test_shop_index_renders_categories_and_listings(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/work-shop'); + + self::assertResponseIsSuccessful(); + + $text = $crawler->filter('main')->text(); + static::assertStringContainsString('Blueprints', $text); + static::assertStringContainsString('Consulting', $text); + static::assertStringNotContainsString('Subscriptions', $text); + + foreach (['/work-shop/blueprints/symfony-backoffice', '/work-shop/consulting'] as $href) { + static::assertGreaterThan(0, $crawler->filter('a[href="' . $href . '"]')->count(), $href); + } + + static::assertStringContainsString('$59', $text); + static::assertStringContainsString('excl. tax', $text); + // Placeholder listings render as plain cards, so they must not become links. + static::assertStringContainsString('More Blueprints', $text); + static::assertGreaterThan(0, $crawler->filter('main div.card:not([href])')->count()); + static::assertGreaterThanOrEqual(2, $crawler->filter('main a[href^="/work-shop/"] img')->count()); + static::assertGreaterThan(0, $crawler->filter('main a[href^="/work-shop/"] ul li')->count()); + } + + public function test_success_page_explains_what_happens_next(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/work-shop/success'); + + self::assertResponseIsSuccessful(); + + $text = $crawler->filter('main')->text(); + static::assertStringContainsString('Thank you', $text); + static::assertStringContainsString('invitation', $text); + // Access is not granted automatically, so the page has to say so before anything else. + static::assertStringContainsString('Access is not sent automatically', $text); + static::assertGreaterThan(0, $crawler->filter('main a[href="https://polar.sh/flow-php/portal"]')->count()); + static::assertGreaterThan(0, $crawler->filter('main [data-controller~="shop-order"]')->count()); + static::assertGreaterThan(0, $crawler->filter('main [data-controller~="shop-confetti"]')->count()); + static::assertGreaterThan(0, $crawler->filter('main a[href^="mailto:support@flow-php.com"]')->count()); + static::assertGreaterThan(0, $crawler->filter('main a[href="/work-shop"]')->count()); + } + + public function test_subscription_routes_are_gone(): void + { + self::createClient(); + + $routes = type_instance_of(RouterInterface::class) + ->assert(self::getContainer()->get('router')) + ->getRouteCollection(); + + foreach (['shop_subscription_bronze', 'shop_subscription_silver', 'shop_subscription_gold'] as $route) { + static::assertNull($routes->get($route), $route); + } + } + + public function test_privacy_policy_renders_markdown_content(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/work-shop/privacy-policy'); + + self::assertResponseIsSuccessful(); + + $text = $crawler->filter('main')->text(); + static::assertStringContainsString('Privacy Policy', $text); + static::assertStringContainsString('GDPR', $text); + static::assertGreaterThan(0, $crawler->filter('section#shop-privacy h2')->count()); + static::assertGreaterThan(0, $crawler->filter('main a[href="/work-shop"]')->count()); + } + + public function test_terms_of_sales_renders_markdown_content(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/work-shop/terms-of-sales'); + + self::assertResponseIsSuccessful(); + + $text = $crawler->filter('main')->text(); + static::assertStringContainsString('Terms of Sale and Use', $text); + static::assertStringContainsString('Merchant of Record', $text); + static::assertGreaterThan(0, $crawler->filter('section#shop-terms h2')->count()); + static::assertGreaterThan(0, $crawler->filter('main a[href="/work-shop"]')->count()); + } + + public function test_symfony_backoffice_cta_opens_embedded_checkout(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/work-shop/blueprints/symfony-backoffice'); + + self::assertResponseIsSuccessful(); + + $cta = $crawler->filter('main a[data-controller~="shop-checkout"][data-action~="shop-checkout#open"]'); + static::assertSame(1, $cta->count()); + static::assertNotEmpty($cta->attr('data-shop-checkout-url-value')); + static::assertSame($cta->attr('data-shop-checkout-url-value'), $cta->attr('href')); + } + + public function test_symfony_backoffice_has_carousel_and_covered_features(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/work-shop/blueprints/symfony-backoffice'); + + self::assertResponseIsSuccessful(); + static::assertStringContainsString('$59', $crawler->filter('main')->text()); + static::assertStringContainsString('excl. tax', $crawler->filter('main')->text()); + static::assertStringContainsString('One schema, four jobs', $crawler->filter('main')->text()); + static::assertSame(1, $crawler->filter('[data-controller~="shop-carousel"]')->count()); + static::assertGreaterThan(1, $crawler->filter('[data-shop-carousel-target="slide"]')->count()); + } + + #[Override] + protected static function getKernelClass(): string + { + return Kernel::class; + } +}